diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts index 955676687..d8ebbdee5 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts @@ -116,10 +116,9 @@ export const DELETE = createSmartRouteHandler({ s.productId === params.product_id && isActiveSubscription(s) ); if (subscriptions.length === 0) { - // Owned but with no active sub: either it's already winding down - // (canceled with a future endedAt — still in effect, not - // re-cancelable), or the customer owns it via OTP. Distinguish the - // two so a double-cancel doesn't claim the product is an OTP. + // Owned but no active sub: either already winding down (canceled + // with a future endedAt) or owned via OTP — don't claim the former + // is a one-time purchase. const nowMillis = Date.now(); const windingDown = allSubs.find(s => s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis) @@ -136,12 +135,9 @@ export const DELETE = createSmartRouteHandler({ for (const subscription of subscriptions) { if (subscription.stripeSubscriptionId) { const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation."); - // Cancel at period end (not `subscriptions.cancel()`, which ends the - // sub immediately) — the confirm dialog promises the customer keeps - // what they paid for until the period ends, and the local-sub branch - // below implements the same semantics via a future `endedAt`. The - // Stripe webhook syncs the local row (and flips it to `canceled` - // with `endedAt` set when Stripe ends it at the period boundary). + // Cancel at period end, not `subscriptions.cancel()` (immediate) — + // matches the confirm dialog and the local-sub branch below. The + // webhook syncs the local row when Stripe ends it. await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); continue; } diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index 5590819da..1829010ac 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -67,11 +67,9 @@ export const GET = createSmartRouteHandler({ ]); // Deprecated: map productId → subscription for backward-compat fields. // ownedProducts keys use '__null__' for inline products (null productId), - // so we normalize subscription productIds to match. - // In-effect (not active): a canceled-at-period-end sub still backs its - // owned product until `endedAt`, and must keep rendering as a - // subscription ("Ends on ") rather than falling through to the - // one-time-purchase branch below. + // so we normalize subscription productIds to match. In-effect (not + // active): a canceled-at-period-end sub still backs its owned product + // and must not fall through to the one-time-purchase branch below. const nowMillis = Date.now(); const inEffectSubByProductId = new Map( Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const) @@ -133,9 +131,7 @@ export const GET = createSmartRouteHandler({ subscription_id: sub.id, current_period_end: sub.currentPeriodEndMillis ? new Date(sub.currentPeriodEndMillis).toISOString() : null, cancel_at_period_end: sub.cancelAtPeriodEnd, - // A sub that's already winding down (locally canceled, or a - // Stripe sub with cancel_at_period_end set) can't be canceled - // again — the DELETE route would 400 on it anyway. + // Already-winding-down subs can't be canceled again is_cancelable: isActiveSubscription(sub) && !sub.cancelAtPeriodEnd, } : null, switch_options: switchOptions, diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 36bc44c1d..5b7f4365a 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -112,11 +112,8 @@ export const POST = createSmartRouteHandler({ customerId: params.customer_id, }); // ownedProducts keys use '__null__' for inline products (null productId), - // so we normalize subscription productIds to match. - // In-effect (not active): this set classifies owned products as - // sub-backed vs OTP. A canceled-at-period-end sub still backs its - // product until `endedAt`; treating it as an OTP here would wrongly - // block switching for the rest of the paid-through window. + // so we normalize subscription productIds to match. In-effect (not + // active): a canceled-at-period-end sub is still sub-backed, not an OTP. const nowMillis = Date.now(); const subBackedProductIds = new Set( Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => s.productId ?? "__null__") diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 2ece10378..eb77d0bd6 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -117,13 +117,9 @@ export async function ensureProductIdOrInlineProduct( // were removed. All reads now go through customer-data.ts backed by Bulldozer. /** - * Lifecycle/actionability predicate: the subscription is in a state where it - * will renew and can be acted on (canceled, switched). This is deliberately - * NOT the same question as "does the customer currently have the product" — - * a locally-canceled sub keeps entitling the customer until `endedAt` (we - * model cancel-at-period-end as `status: canceled` + future `endedAt`). Use - * `isSubscriptionInEffect` for entitlement questions; using this one there - * makes still-paid-for products masquerade as one-time purchases. + * The subscription will renew and can be acted on (canceled, switched). For + * "does this still entitle the customer" use `isSubscriptionInEffect` — a + * canceled sub keeps entitling until its future `endedAt`. */ export function isActiveSubscription(subscription: { status: string }): boolean { const s = subscription.status; @@ -131,18 +127,10 @@ export function isActiveSubscription(subscription: { status: string }): boolean } /** - * Entitlement predicate: the subscription still confers its product/items on - * the customer, regardless of whether it will renew. Mirrors Bulldozer's - * grant semantics (subscription-start emits on row insert regardless of - * status; subscription-end emits at `endedAtMillis`), which is why it is - * deliberately status-agnostic: `canceled` subs stay in effect until their - * future `endedAt` passes, and `incomplete`/`past_due`/`unpaid` subs that - * arrive mid-Stripe-flow count too (they either transition to `active` or to - * a terminal status with `endedAt` set — every terminal writer sets - * `endedAt`, so `endedAt == null` means "no end scheduled"). - * - * Accepts both row shapes so Bulldozer reads (`endedAtMillis`) and Prisma - * reads (`endedAt`) can share the one definition. + * The subscription still confers its product/items, regardless of whether it + * will renew. Deliberately status-agnostic and `endedAt`-based, mirroring + * Bulldozer's grant timefold (grants run from row insert until + * `endedAtMillis`; every terminal writer sets `endedAt`). */ export function isSubscriptionInEffect( subscription: { endedAtMillis?: number | null, endedAt?: Date | null }, diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index 2a97b344a..83e1f803e 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -159,9 +159,7 @@ export async function readBillingSubscriptionMapOrSkip( function resolveActivePlanSubscription(subscriptions: Record): SubscriptionRow | null { // In-effect (not active): a canceled-at-period-end plan sub keeps its item - // grants until `endedAt`, so usage limits must be judged against that plan - // — otherwise this page reports "Free" while the customer's actual quotas - // are still the paid plan's. + // grants until `endedAt`, so usage must be judged against that plan. const nowMillis = Date.now(); const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis)); for (const planId of BASE_PLAN_IDS_BY_TIER) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index 6d2df76ee..40f602c14 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -273,10 +273,7 @@ it("should report a canceled-at-period-end subscription as still in effect, and }); expect(cancelResponse.status).toBe(200); - // The product must NOT masquerade as a one-time purchase during the - // paid-through window (it used to: the list route only recognized - // active/trialing subs, so a canceled-with-future-endedAt sub fell through - // to the OTP branch). + // Must not masquerade as a one-time purchase during the paid-through window const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", }); @@ -286,8 +283,6 @@ it("should report a canceled-at-period-end subscription as still in effect, and expect(item?.subscription?.cancel_at_period_end).toBe(true); expect(item?.subscription?.is_cancelable).toBe(false); - // Canceling again must not claim the product is a one-time purchase, nor - // silently rewrite the wind-down state. const secondCancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/pro-plan`, { method: "DELETE", accessType: "client",