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 8695c25e1..955676687 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 @@ -1,5 +1,5 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; -import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments"; +import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription, isSubscriptionInEffect } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan"; @@ -116,7 +116,17 @@ export const DELETE = createSmartRouteHandler({ s.productId === params.product_id && isActiveSubscription(s) ); if (subscriptions.length === 0) { - // Customer owns the product but via OTP, not subscription + // 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. + const nowMillis = Date.now(); + const windingDown = allSubs.find(s => + s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis) + ); + if (windingDown) { + throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period."); + } throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); } } @@ -126,7 +136,13 @@ export const DELETE = createSmartRouteHandler({ for (const subscription of subscriptions) { if (subscription.stripeSubscriptionId) { const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation."); - await stripeClient.subscriptions.cancel(subscription.stripeSubscriptionId); + // 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). + await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); continue; } await prisma.subscription.update({ 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 242b9523a..5590819da 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 @@ -1,4 +1,4 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionInEffect, productToInlineProduct } from "@/lib/payments"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -65,11 +65,16 @@ export const GET = createSmartRouteHandler({ customerId: params.customer_id, }), ]); - // Deprecated: map productId → active subscription for backward-compat fields. + // Deprecated: map productId → subscription for backward-compat fields. // ownedProducts keys use '__null__' for inline products (null productId), // so we normalize subscription productIds to match. - const activeSubByProductId = new Map( - Object.values(subMap).filter(s => isActiveSubscription(s)).map(s => [s.productId ?? "__null__", s] as const) + // 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. + const nowMillis = Date.now(); + const inEffectSubByProductId = new Map( + Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const) ); // Build switch options per product line (available plan upgrades/downgrades) @@ -108,7 +113,7 @@ export const GET = createSmartRouteHandler({ ? (switchOptionsByProductLineId.get(productLineId) ?? []).filter((option) => option.product_id !== productId) : undefined; // Deprecated fields for backward compat - const sub = activeSubByProductId.get(productId); + const sub = inEffectSubByProductId.get(productId); const type = sub ? "subscription" as const : "one_time" as const; return { @@ -128,7 +133,10 @@ 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, - is_cancelable: true, + // 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. + 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 d12908b39..36bc44c1d 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 @@ -1,4 +1,4 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, grantProductToCustomer, isActiveSubscription, isAddOnProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionInEffect } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees"; @@ -113,14 +113,19 @@ export const POST = createSmartRouteHandler({ }); // ownedProducts keys use '__null__' for inline products (null productId), // so we normalize subscription productIds to match. - const activeSubProductIds = new Set( - Object.values(subMap).filter(s => isActiveSubscription(s)).map(s => s.productId ?? "__null__") + // 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. + const nowMillis = Date.now(); + const subBackedProductIds = new Set( + Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => s.productId ?? "__null__") ); const hasOtpInProductLine = Object.entries(ownedProducts).some( ([productId, p]) => productId !== body.from_product_id && p.productLineId === fromProduct.productLineId && p.quantity > 0 - && !activeSubProductIds.has(productId) + && !subBackedProductIds.has(productId) ); if (hasOtpInProductLine) { throw new StatusError(400, "Customer already has a one-time purchase in this product line"); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 813bc0492..2ece10378 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -116,11 +116,44 @@ export async function ensureProductIdOrInlineProduct( // getCustomerPurchaseContext, OwnedProduct type, getOwnedProductsForCustomerLegacy // 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. + */ export function isActiveSubscription(subscription: { status: string }): boolean { const s = subscription.status; return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing; } +/** + * 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. + */ +export function isSubscriptionInEffect( + subscription: { endedAtMillis?: number | null, endedAt?: Date | null }, + nowMillis: number, +): boolean { + const endedAtMillis = subscription.endedAtMillis != null + ? subscription.endedAtMillis + : subscription.endedAt != null ? subscription.endedAt.getTime() : null; + return endedAtMillis == null || endedAtMillis > nowMillis; +} + /** * True when the given product config / snapshot declares itself as an add-on * to one or more other products. Add-ons share a product line with their base diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 3ec756bc0..411726e90 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -1,5 +1,5 @@ import { CustomerType, PrismaClient, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; -import { isAddOnProduct } from "@/lib/payments"; +import { isAddOnProduct, isSubscriptionInEffect } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import type { ProductSnapshot } from "@/lib/payments/schema/types"; @@ -146,17 +146,10 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi // Snapshot-based "occupies the free plan's product line" predicate. We // treat a sub as occupying the line iff its captured product snapshot - // lives in that line, isn't an add-on, and HASN'T ENDED YET (endedAt in - // the future or absent). Crucially we do NOT gate on `status` — - // `incomplete` / `past_due` / `unpaid` subs that arrive mid-Stripe-flow - // still reserve the line (they will either transition to `active` or to - // a terminal status with `endedAt` set), and this matches the semantics - // that `ownedProducts` derives via the Subscription TimeFold (see - // `subscription-timefold-algo.ts` — `subscription-start` emits on row - // insert regardless of status; `subscription-end` emits at - // `endedAtMillis`). Treating only active/trialing as occupying would - // (and did) cause the free plan to be double-granted on top of a - // just-created incomplete paid sub. + // lives in that line, isn't an add-on, and is still in effect + // (`isSubscriptionInEffect` — status-agnostic, endedAt-based; see its doc + // comment for why gating on `status` here would (and did) cause the free + // plan to be double-granted on top of a just-created incomplete paid sub). const nowMillis = Date.now(); const productLineStillOccupiedBy = (sub: { product: ProductSnapshot, @@ -165,10 +158,7 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi }): boolean => { if (sub.product.productLineId !== freeProductLineId) return false; if (isAddOnProduct(sub.product)) return false; - const endedAtMillis = sub.endedAtMillis != null - ? sub.endedAtMillis - : sub.endedAt != null ? sub.endedAt.getTime() : null; - return endedAtMillis == null || endedAtMillis > nowMillis; + return isSubscriptionInEffect(sub, nowMillis); }; // Fast path: read the customer's synchronous subscription LFold. Note diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index bf5c39894..2a97b344a 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -1,7 +1,7 @@ import { VerificationCodeType } from "@/generated/prisma/client"; import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; -import { isActiveSubscription } from "@/lib/payments"; +import { isSubscriptionInEffect } from "@/lib/payments"; import { arePlanLimitsEnforced, getBillingTeamId, @@ -158,9 +158,14 @@ export async function readBillingSubscriptionMapOrSkip( } function resolveActivePlanSubscription(subscriptions: Record): SubscriptionRow | null { - const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription); + // 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. + const nowMillis = Date.now(); + const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis)); for (const planId of BASE_PLAN_IDS_BY_TIER) { - const subscription = activeSubscriptions.find((candidate) => candidate.productId === planId); + const subscription = inEffectSubscriptions.find((candidate) => candidate.productId === planId); if (subscription != null) { return subscription; } diff --git a/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx index 3a7fc70f0..913632f1d 100644 --- a/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx +++ b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx @@ -366,12 +366,18 @@ function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, cust const isCancelable = isSubscription && !!product.subscription?.isCancelable; const canSwitchPlans = isSubscription && defaultPaymentMethod && !!product.id && (product.switchOptions?.length ?? 0) > 0; const renewsAt = isSubscription ? (product.subscription?.currentPeriodEnd ?? null) : null; + const endsAtPeriodEnd = isSubscription && !!product.subscription?.cancelAtPeriodEnd; + const formattedPeriodEnd = renewsAt + ? new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt) + : null; const subtitle = product.type === "one_time" ? "One-time purchase" - : renewsAt - ? `Renews on ${new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)}` - : "Subscription"; + : endsAtPeriodEnd + ? (formattedPeriodEnd ? `Ends on ${formattedPeriodEnd}` : "Ends at the end of the billing period") + : formattedPeriodEnd + ? `Renews on ${formattedPeriodEnd}` + : "Subscription"; return (
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 e1f150407..6d2df76ee 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 @@ -221,8 +221,13 @@ it("should allow a signed-in user to cancel their own subscription product", asy "stackable": false, }, "quantity": 1, - "subscription": null, - "type": "one_time", + "subscription": { + "cancel_at_period_end": true, + "current_period_end": , + "is_cancelable": false, + "subscription_id": "", + }, + "type": "subscription", }, ], "pagination": { "next_cursor": null }, @@ -232,6 +237,70 @@ it("should allow a signed-in user to cancel their own subscription product", asy `); }); +it("should report a canceled-at-period-end subscription as still in effect, and reject a second cancel", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await configureProduct({ + products: { + "pro-plan": { + displayName: "Pro Plan", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + monthly: { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { + product_id: "pro-plan", + }, + }); + + const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/pro-plan`, { + method: "DELETE", + accessType: "client", + }); + 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). + const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + }); + expect(listResponse.status).toBe(200); + const item = (listResponse.body as { items: Array<{ id: string, type: string, subscription: { cancel_at_period_end: boolean, is_cancelable: boolean } | null }> }).items.find((i) => i.id === "pro-plan"); + expect(item?.type).toBe("subscription"); + 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", + }); + expect(secondCancelResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": "This subscription is already canceled and ends at the end of the current billing period.", + "headers": Headers {