diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql new file mode 100644 index 000000000..d4e9c825c --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql @@ -0,0 +1,19 @@ +-- Terminal subscription rows written before endedAt was derived on every sync +-- (and terminal-on-first-sync rows, whose create path never set it) have +-- endedAt = NULL. The status-agnostic isSubscriptionInEffect predicate reads +-- NULL as "entitled forever", so these rows must be closed out. +-- +-- endedAt = LEAST(currentPeriodEnd, now) mirrors getEndedAtForSync's +-- fallback when Stripe omits ended_at: the paid-through boundary if it's +-- already past, otherwise now. NOW() AT TIME ZONE 'UTC' yields a naive UTC +-- timestamp matching the timezone-less columns (which the app always writes +-- as UTC); bare NOW() would be shifted by the session offset on non-UTC +-- sessions when cast into "endedAt". +-- +-- No batching/temp-index machinery: the affected population was measured in +-- production at 7 rows (2026-07-17), and a plain UPDATE only takes row locks +-- on matching rows, so the seq scan is safe regardless of table size. +UPDATE "Subscription" +SET "endedAt" = LEAST("currentPeriodEnd", NOW() AT TIME ZONE 'UTC') +WHERE "status" IN ('canceled', 'incomplete_expired', 'unpaid') + AND "endedAt" IS NULL; diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts new file mode 100644 index 000000000..4b06f3c72 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +const insertSubscription = async (sql: Sql, options: { + tenancyId: string, + status: string, + currentPeriodEnd: Date, + endedAt?: Date | null, +}) => { + const id = randomUUID(); + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", "product", "quantity", + "stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt" + ) VALUES ( + ${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM', + ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, + ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", + '2026-01-01', ${options.currentPeriodEnd}, + false, ${options.endedAt ?? null}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC' + ) + `; + return id; +}; + +export const preMigration = async (sql: Sql) => { + const tenancyId = randomUUID(); + const pastPeriodEnd = new Date('2026-02-01T00:00:00Z'); + + // One row per terminal status, all with null endedAt and a past period end. + const canceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: pastPeriodEnd }); + const incompleteExpiredId = await insertSubscription(sql, { tenancyId, status: 'incomplete_expired', currentPeriodEnd: pastPeriodEnd }); + const unpaidId = await insertSubscription(sql, { tenancyId, status: 'unpaid', currentPeriodEnd: pastPeriodEnd }); + + // Terminal row whose period end is in the future: endedAt must be capped at + // the migration's NOW() (matching getEndedAtForSync's last-resort fallback), + // not scheduled for the future. + const futurePeriodEnd = new Date('2100-01-01T00:00:00Z'); + const futureCanceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: futurePeriodEnd }); + + return { tenancyId, canceledId, incompleteExpiredId, unpaidId, futureCanceledId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + // All comparisons stay server-side in the naive-UTC domain (createdAt was + // inserted with NOW() AT TIME ZONE 'UTC', matching the migration's write) — + // values never round-trip through JS Dates, whose parse/serialize is + // tz-offset-asymmetric for timezone-less columns, and no JS clock is + // compared against the server clock. + const rows = await sql` + SELECT + "id", + ("endedAt" IS NOT NULL) AS "hasEndedAt", + ("endedAt" = "currentPeriodEnd") AS "matchesPeriodEnd", + ("endedAt" >= "createdAt" AND "endedAt" <= NOW() AT TIME ZONE 'UTC' AND "endedAt" < "currentPeriodEnd") AS "cappedAtNow" + FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid + `; + const byId = new Map(rows.map((row) => [row.id, row])); + + // Past period end: backfilled to exactly currentPeriodEnd. + for (const id of [ctx.canceledId, ctx.incompleteExpiredId, ctx.unpaidId]) { + expect(byId.get(id)?.hasEndedAt).toBe(true); + expect(byId.get(id)?.matchesPeriodEnd).toBe(true); + } + + // Future period end: capped at the migration's NOW(), not scheduled for 2100. + expect(byId.get(ctx.futureCanceledId)?.hasEndedAt).toBe(true); + expect(byId.get(ctx.futureCanceledId)?.cappedAtNow).toBe(true); +}; diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts new file mode 100644 index 000000000..1eb523ad0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +const insertSubscription = async (sql: Sql, options: { + tenancyId: string, + status: string, + endedAt: Date | null, +}) => { + const id = randomUUID(); + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", "product", "quantity", + "stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt" + ) VALUES ( + ${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM', + ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, + ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", + '2026-01-01', '2026-02-01', + false, ${options.endedAt}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC' + ) + `; + return id; +}; + +export const preMigration = async (sql: Sql) => { + const tenancyId = randomUUID(); + const existingEndedAt = new Date('2026-01-15T12:00:00Z'); + + // Non-terminal rows with null endedAt: live subscriptions, must stay null. + const activeId = await insertSubscription(sql, { tenancyId, status: 'active', endedAt: null }); + const trialingId = await insertSubscription(sql, { tenancyId, status: 'trialing', endedAt: null }); + // `incomplete` is non-terminal (Stripe expires it to incomplete_expired + // within ~24h, which arrives via webhook) — deliberately not backfilled. + const incompleteId = await insertSubscription(sql, { tenancyId, status: 'incomplete', endedAt: null }); + const pastDueId = await insertSubscription(sql, { tenancyId, status: 'past_due', endedAt: null }); + + // Terminal row whose endedAt is already set: must keep its original value. + const alreadyEndedId = await insertSubscription(sql, { tenancyId, status: 'canceled', endedAt: existingEndedAt }); + + return { tenancyId, activeId, trialingId, incompleteId, pastDueId, alreadyEndedId, existingEndedAt }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + // Compare the already-set endedAt in SQL against the same JS Date param used + // at insert time — both cross the driver's tz-naive conversion identically, + // so equality holds regardless of the session time zone. + const rows = await sql` + SELECT "id", "endedAt", ("endedAt" = ${ctx.existingEndedAt}) AS "matchesOriginal" + FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid + `; + const byId = new Map(rows.map((row) => [row.id, row])); + + for (const id of [ctx.activeId, ctx.trialingId, ctx.incompleteId, ctx.pastDueId]) { + expect(byId.get(id)?.endedAt).toBeNull(); + } + + expect(byId.get(ctx.alreadyEndedId)?.matchesOriginal).toBe(true); +}; diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx index 0195aa4f9..cad4abe49 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx @@ -7,7 +7,7 @@ import { REFUND_TXN_PREFIX } from "@/lib/payments/refund-txn-id"; import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; import { ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX } from "@/lib/payments/transaction-entry-indexes"; import type { ManualTransactionRow, TransactionEntryData } from "@/lib/payments/schema/types"; -import { getStripeForAccount } from "@/lib/stripe"; +import { getStripeForAccount, getStripeSubscriptionPeriodEnd } from "@/lib/stripe"; import type { Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -686,6 +686,7 @@ async function handleSubscriptionRefund(options: { } else if (endAtPeriodEnd) { // End at period end. Items follow natural lifecycle when sub-end fires // at period boundary. + let stripePeriodEnd: Date | null = null; if (!isTestMode && subscription.stripeSubscriptionId) { const stripe = await getStripeForAccount({ tenancy }); // Idempotent guard, mirroring the endNow branch. The end-at-period-end @@ -697,9 +698,12 @@ async function handleSubscriptionRefund(options: { // `bulldozerWriteManualTransaction` commits the ledger row, leaving the // customer refunded with no record. try { - await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + const updated = await stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true, }); + // Same as the cancel route: Stripe's response is the authority on + // the boundary; the local row can be stale around a renewal + stripePeriodEnd = getStripeSubscriptionPeriodEnd(updated, { tenancyId: tenancy.id }); } catch (e: unknown) { if (!isStripeSubscriptionAlreadyTerminalError(e)) { throw e; @@ -711,7 +715,7 @@ async function handleSubscriptionRefund(options: { data: { cancelAtPeriodEnd: true, canceledAt: subscription.canceledAt ?? now, - endedAt: subscription.endedAt ?? subscription.currentPeriodEnd, + endedAt: subscription.endedAt ?? stripePeriodEnd ?? subscription.currentPeriodEnd, }, }); } 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..30d022db3 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,10 +1,10 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; -import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments"; +import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isSubscriptionCancelable, 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"; import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; -import { getStripeForAccount } from "@/lib/stripe"; +import { getStripeForAccount, getStripeSubscriptionPeriodEnd } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@hexclave/shared"; @@ -80,13 +80,25 @@ export const DELETE = createSmartRouteHandler({ }); const allSubs = Object.values(subMap); + const nowMillis = Date.now(); + // Gives double-cancels and past_due-style states an honest error; no-op + // when no in-effect sub matches. + const throwIfUncancelableButInEffect = (candidates: typeof allSubs): void => { + const inEffect = candidates.find(s => isSubscriptionInEffect(s, nowMillis)); + if (!inEffect) return; + if (inEffect.status === "canceled" || inEffect.cancelAtPeriodEnd) { + throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period."); + } + throw new StatusError(400, "This subscription cannot be canceled in its current state."); + }; + let subscriptions; if (query.subscription_id) { // Cancel by subscription DB ID (used for inline products that have no product_id) - subscriptions = allSubs.filter(s => - s.id === query.subscription_id && isActiveSubscription(s) - ); + const matching = allSubs.filter(s => s.id === query.subscription_id); + subscriptions = matching.filter(s => isSubscriptionCancelable(s)); if (subscriptions.length === 0) { + throwIfUncancelableButInEffect(matching); throw new StatusError(400, "No active subscription found with this ID for the given customer."); } } else { @@ -111,12 +123,13 @@ export const DELETE = createSmartRouteHandler({ throw new StatusError(400, "Customer does not have this product."); } - // Find the active subscription to cancel - subscriptions = allSubs.filter(s => - s.productId === params.product_id && isActiveSubscription(s) - ); + // Find the cancelable subscriptions for this product + const matching = allSubs.filter(s => s.productId === params.product_id); + subscriptions = matching.filter(s => isSubscriptionCancelable(s)); if (subscriptions.length === 0) { - // Customer owns the product but via OTP, not subscription + // Owned but nothing cancelable: winding down / uncancelable state, + // or owned via OTP — don't claim the former is a one-time purchase. + throwIfUncancelableButInEffect(matching); throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); } } @@ -124,29 +137,49 @@ export const DELETE = createSmartRouteHandler({ const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId); const stripe = hasStripeSubscription ? await getStripeForAccount({ tenancy: auth.tenancy }) : undefined; for (const subscription of subscriptions) { + let updatedSub; if (subscription.stripeSubscriptionId) { const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation."); - await stripeClient.subscriptions.cancel(subscription.stripeSubscriptionId); - continue; - } - await prisma.subscription.update({ - where: { - tenancyId_id: { - tenancyId: auth.tenancy.id, - id: subscription.id, + // Cancel at period end, not `subscriptions.cancel()` (immediate) — + // matches the confirm dialog and the local-sub branch below. The + // eager local write (mirroring the refund route) shows the wind-down + // before the webhook sync arrives. + const updated = await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); + // Stripe's response is the authority on the boundary — the bulldozer + // snapshot can be a stale pre-renewal period end. Snapshot fallback + // only covers item-less mocked responses. + const endedAt = getStripeSubscriptionPeriodEnd(updated, { tenancyId: auth.tenancy.id }) + ?? new Date(subscription.currentPeriodEndMillis); + updatedSub = await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, }, - }, - data: { - status: SubscriptionStatus.canceled, - cancelAtPeriodEnd: true, - canceledAt: new Date(), - endedAt: new Date(subscription.currentPeriodEndMillis), - }, - }); + data: { + cancelAtPeriodEnd: true, + canceledAt: new Date(), + endedAt, + }, + }); + } else { + updatedSub = await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, + }, + data: { + status: SubscriptionStatus.canceled, + cancelAtPeriodEnd: true, + canceledAt: new Date(), + endedAt: new Date(subscription.currentPeriodEndMillis), + }, + }); + } // dual write - prisma and bulldozer - const updatedSub = await prisma.subscription.findUniqueOrThrow({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: subscription.id } }, - }); await bulldozerWriteSubscription(updatedSub); } 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..dbc33a763 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,5 +1,6 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isAddOnProduct, isSubscriptionCancelable, isSubscriptionInEffect, productToInlineProduct, subscriptionDisplayRank } from "@/lib/payments"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import type { SubscriptionRow } from "@/lib/payments/schema/types"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@hexclave/shared"; @@ -65,12 +66,22 @@ 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) - ); + // so we normalize subscription productIds to match. In-effect, not + // active-only: a winding-down sub still backs its product and must not + // fall through to the one_time branch below; productId ties pick the + // highest subscriptionDisplayRank. + const nowMillis = Date.now(); + const inEffectSubByProductId = new Map(); + for (const s of Object.values(subMap)) { + if (!isSubscriptionInEffect(s, nowMillis)) continue; + const key = s.productId ?? "__null__"; + const existing = inEffectSubByProductId.get(key); + if (existing == null || subscriptionDisplayRank(s) > subscriptionDisplayRank(existing)) { + inEffectSubByProductId.set(key, s); + } + } // Build switch options per product line (available plan upgrades/downgrades) const switchOptionsByProductLineId = new Map }>>(); @@ -108,7 +119,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 +139,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, - is_cancelable: true, + is_cancelable: isSubscriptionCancelable(sub), } : 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..927d6d410 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"; @@ -112,15 +112,17 @@ export const POST = createSmartRouteHandler({ customerId: params.customer_id, }); // 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__") + // 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__") ); 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"); @@ -265,6 +267,10 @@ export const POST = createSmartRouteHandler({ payment_behavior: "error_if_incomplete", payment_settings: { save_default_payment_method: "on_subscription" }, default_payment_method: resolvedPaymentMethodId, + // Paying to switch is "keep me subscribed" — Stripe persists a + // pending cancel across item updates unless reset, and the new plan + // would otherwise still die at the period boundary. + cancel_at_period_end: false, items: [{ id: existingItem.id, price_data: { @@ -308,6 +314,10 @@ export const POST = createSmartRouteHandler({ currentPeriodStart: sanitizedUpdateDates.start, currentPeriodEnd: sanitizedUpdateDates.end, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end, + // Clear the eagerly-written wind-down marks; the webhook sync + // reconciles these anyway, this just covers the pre-webhook window + canceledAt: null, + endedAt: null, }, }); // dual write - prisma and bulldozer diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 7eaa0b86e..c89717f9b 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -117,6 +117,31 @@ export const POST = createSmartRouteHandler({ if (conflictingSubscriptions.length > 0) { const conflicting = conflictingSubscriptions[0]; if (conflicting.stripeSubscriptionId) { + // Reject purchases whose conflicting Stripe sub is winding down + // (canceled-at-period-end but still paid through). The branches below + // reuse that same Stripe sub — either re-pricing it in place + // (`default_incomplete`, paid later via Elements) or canceling it + // immediately for a one-time replacement — and neither is safe here: + // - We can't clear `cancel_at_period_end` at session creation: + // that runs before payment and `default_incomplete` has no + // rollback, so a declined card would silently reactivate an + // explicitly-canceled sub while leaving it re-priced. + // - We can't leave the flag set either: Stripe would still end the + // sub at the period boundary AFTER the customer paid the re-price + // invoice — they'd pay and lose the product anyway. + // Future fix (deliberately deferred, see PR #1773): clear + // `cancel_at_period_end` from the `invoice.paid` webhook once the + // re-price invoice is actually paid. Pending-cancel subs generate no + // renewal invoices, so a paid invoice with `invoice.created` after + // the sub's `canceledAt` can only be the re-purchase (and a + // late-delivered `invoice.paid` for the original creation invoice + // predates the cancel, so it can't undo it). Until then, wind-down + // replacements only work for non-Stripe subs (test mode / free + // plans), which are fully ended and re-created below instead of + // reused. + if (conflicting.status === "canceled" || conflicting.cancelAtPeriodEnd) { + throw new StatusError(400, "The current subscription is already canceled and remains active until the end of the billing period. This product can be purchased after the current subscription ends."); + } const existingStripeSub = await stripe.subscriptions.retrieve(conflicting.stripeSubscriptionId); const existingItem = existingStripeSub.items.data[0]; const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" }); diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 35328a726..0cd57751d 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,7 +1,7 @@ import { KnownErrors } from '@hexclave/shared'; import { generateUuid } from '@hexclave/shared/dist/utils/uuids'; import { describe, expect, it } from 'vitest'; -import { validatePurchaseSession } from './payments'; +import { isSubscriptionCancelable, isSubscriptionInEffect, subscriptionDisplayRank, validatePurchaseSession } from './payments'; import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { globalPrismaClient } from "@/prisma-client"; @@ -233,5 +233,75 @@ describe.sequential('validatePurchaseSession - purchase guards (real DB)', () => { priceId: 'nonexistent' }, )).rejects.toThrowError('Price not found'); }); + + it('treats a canceled-at-period-end sub in the same line as a replaceable conflict, not an OTP', async () => { + const lineId = `line-winddown-${testId}`; + const subId = generateUuid(); + const now = new Date(); + const periodEnd = new Date(now.getTime() + 86400000); + const product = makeProduct({ productLineId: lineId }); + await prisma.subscription.create({ + data: { + id: subId, tenancyId, customerId, customerType: 'CUSTOM', + productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1, + stripeSubscriptionId: null, status: 'canceled', + currentPeriodStart: now, currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd, + creationSource: 'TEST_MODE', + }, + }); + await bulldozerWriteSubscription({ + id: subId, tenancyId, customerId, customerType: 'CUSTOM', + productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1, + stripeSubscriptionId: null, status: 'canceled', + currentPeriodStart: now, currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd, refundedAt: null, productRevokedAt: null, + creationSource: 'TEST_MODE', createdAt: now, + }); + const res = await callValidate(product, { productId: `prod-after-winddown-${testId}` }); + expect(res.conflictingSubscriptions.map(s => s.id)).toContain(subId); + }); +}); + +describe('isSubscriptionInEffect', () => { + const nowMillis = 1_800_000_000_000; + + it('treats endedAt=null as in effect regardless of status', () => { + expect(isSubscriptionInEffect({ endedAtMillis: null }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAt: null }, nowMillis)).toBe(true); + }); + + it('is in effect strictly before endedAt, not at or after it', () => { + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis + 1 }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis }, nowMillis)).toBe(false); + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis - 1 }, nowMillis)).toBe(false); + }); + + it('accepts the Prisma Date shape', () => { + expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis + 1) }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis) }, nowMillis)).toBe(false); + }); +}); + +describe('isSubscriptionCancelable', () => { + it('is true only for active/trialing subs not already winding down', () => { + expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: false })).toBe(true); + expect(isSubscriptionCancelable({ status: 'trialing', cancelAtPeriodEnd: false })).toBe(true); + expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: true })).toBe(false); + expect(isSubscriptionCancelable({ status: 'canceled', cancelAtPeriodEnd: true })).toBe(false); + expect(isSubscriptionCancelable({ status: 'past_due', cancelAtPeriodEnd: false })).toBe(false); + }); +}); + +describe('subscriptionDisplayRank', () => { + it('ranks cancelable above pending-cancel Stripe subs above wound-down subs', () => { + const cancelable = { status: 'active', cancelAtPeriodEnd: false }; + // Pending-cancel Stripe subs stay `active` — the tier an active-only + // preference gets wrong + const stripePendingCancel = { status: 'active', cancelAtPeriodEnd: true }; + const localWindingDown = { status: 'canceled', cancelAtPeriodEnd: true }; + expect(subscriptionDisplayRank(cancelable)).toBeGreaterThan(subscriptionDisplayRank(stripePendingCancel)); + expect(subscriptionDisplayRank(stripePendingCancel)).toBeGreaterThan(subscriptionDisplayRank(localWindingDown)); + }); }); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 813bc0492..c3448b56a 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -116,11 +116,51 @@ export async function ensureProductIdOrInlineProduct( // getCustomerPurchaseContext, OwnedProduct type, getOwnedProductsForCustomerLegacy // were removed. All reads now go through customer-data.ts backed by Bulldozer. +/** + * 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; return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing; } +/** + * Active and not already winding down. Used by both the product list's + * `is_cancelable` and the cancel route's filter — keep them in sync so + * `is_cancelable: false` always implies the DELETE returns 400. + */ +export function isSubscriptionCancelable(subscription: { status: string, cancelAtPeriodEnd: boolean }): boolean { + return isActiveSubscription(subscription) && !subscription.cancelAtPeriodEnd; +} + +/** + * Picks the representative sub when several in-effect subs share a productId + * (stackable products): cancelable > active > in-effect. The middle tier is + * for pending-cancel Stripe subs, which stay `active` and would otherwise + * shadow a cancelable sibling. + */ +export function subscriptionDisplayRank(subscription: { status: string, cancelAtPeriodEnd: boolean }): number { + return isSubscriptionCancelable(subscription) ? 2 : isActiveSubscription(subscription) ? 1 : 0; +} + +/** + * 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 }, + nowMillis: number, +): boolean { + const endedAtMillis = "endedAtMillis" in subscription + ? 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 @@ -385,7 +425,7 @@ export async function validatePurchaseSession(options: { quantity: number, }): Promise<{ selectedPrice: SelectedPrice | undefined, - conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null }>, + conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null, status: SubscriptionStatus, cancelAtPeriodEnd: boolean }>, }> { const { prisma, tenancyId, customerType, customerId, product, productId, priceId, quantity } = options; @@ -431,7 +471,7 @@ export async function validatePurchaseSession(options: { // conflict. // TODO: swap this for bulldozer read when it's consistent // Read subs from Prisma (not the bulldozer view) — the view can lag, so a duplicate request would otherwise miss the just-granted sub. - let conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null }> = []; + let conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null, status: SubscriptionStatus, cancelAtPeriodEnd: boolean }> = []; const productLineId = product.productLineId; const addOnBaseProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : []; const isStackableSelfMatch = (pid: string) => @@ -441,14 +481,21 @@ export async function validatePurchaseSession(options: { // a duplicate request during lag would slip past it and silently re-grant. // We query Prisma directly so the sub guard is symmetric with the OTP guard // and works for products with no productLineId. - const activeSubs = await prisma.subscription.findMany({ + // Includes canceled-at-period-end subs: they still back their product, so + // a same-line purchase must treat them as replaceable conflicts, not OTPs. + // Narrower than isSubscriptionInEffect so a payment retry of an incomplete + // sub isn't rejected as ProductAlreadyGranted below. + const replaceableSubs = await prisma.subscription.findMany({ where: { tenancyId, customerType: typedToUppercase(customerType), customerId, - status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + OR: [ + { status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] } }, + { status: SubscriptionStatus.canceled, endedAt: { gt: new Date() } }, + ], }, - select: { id: true, stripeSubscriptionId: true, productId: true, product: true }, + select: { id: true, stripeSubscriptionId: true, productId: true, product: true, status: true, cancelAtPeriodEnd: true }, }); if (productId != null && product.stackable !== true) { const activeOtp = await prisma.oneTimePurchase.findFirst({ @@ -462,32 +509,32 @@ export async function validatePurchaseSession(options: { }, select: { id: true }, }); - if (activeOtp || activeSubs.some(s => s.productId === productId)) { + if (activeOtp || replaceableSubs.some(s => s.productId === productId)) { throw new KnownErrors.ProductAlreadyGranted(productId, customerId); } } if (productLineId) { - conflictingSubscriptions = activeSubs + conflictingSubscriptions = replaceableSubs .filter(s => (s.product as Product).productLineId === productLineId && !addOnBaseProductIds.includes(s.productId ?? "") && !isStackableSelfMatch(s.productId ?? ""), ) - .map(s => ({ id: s.id, stripeSubscriptionId: s.stripeSubscriptionId })); + .map(s => ({ id: s.id, stripeSubscriptionId: s.stripeSubscriptionId, status: s.status, cancelAtPeriodEnd: s.cancelAtPeriodEnd })); // If ownedProducts shows a same-line holding but there's no active subscription // backing it, the customer owns via OTP — which can't be replaced by a switch. // (We still rely on bulldozer here because OTPs aren't part of the duplicate- // request race we're guarding above and OTP propagation lag is low-stakes.) - const activeSubProductIds = new Set(activeSubs.map(s => s.productId ?? "__null__")); + const subBackedProductIds = new Set(replaceableSubs.map(s => s.productId ?? "__null__")); const otpInProductLine = Object.entries(ownedProducts).some( ([pid, p]) => p.productLineId === productLineId && p.quantity > 0 && !addOnBaseProductIds.includes(pid) && !isStackableSelfMatch(pid) - && !activeSubProductIds.has(pid), + && !subBackedProductIds.has(pid), ); if (otpInProductLine && conflictingSubscriptions.length === 0) { // TODO: reconsider the coupling here between products and purchases. OTPs can diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 3ec756bc0..2254a0915 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,29 +146,15 @@ 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, - endedAtMillis?: number | null, - endedAt?: Date | null, - }): boolean => { + const productLineStillOccupiedBy = (sub: { product: ProductSnapshot } & ({ endedAtMillis: number | null } | { endedAt: Date | null })): 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.test.ts b/apps/backend/src/lib/plan-usage.test.ts index cc85aceaa..85950d244 100644 --- a/apps/backend/src/lib/plan-usage.test.ts +++ b/apps/backend/src/lib/plan-usage.test.ts @@ -1,6 +1,6 @@ import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans"; import type { SubscriptionRow } from "./payments/schema/types"; -import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip } from "./plan-usage"; +import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip, resolveInEffectPlanSubscription } from "./plan-usage"; import { describe, expect, it } from "vitest"; function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow { @@ -189,3 +189,46 @@ describe("billing period selection", () => { `); }); }); + +describe("resolveInEffectPlanSubscription", () => { + // Fully deterministic now that the resolver takes the clock as a parameter + const nowMillis = 1_800_000_000_000; + const futureMillis = nowMillis + 30 * 86400000; + const pastMillis = nowMillis - 86400000; + + it("keeps resolving a canceled-at-period-end plan sub until its endedAt passes", () => { + const windingDownTeam: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_team", + productId: "team", + status: "canceled", + cancelAtPeriodEnd: true, + canceledAtMillis: pastMillis, + endedAtMillis: futureMillis, + }; + const activeFree: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_free", + productId: "free", + }; + expect(resolveInEffectPlanSubscription({ sub_team: windingDownTeam, sub_free: activeFree }, nowMillis)?.id).toBe("sub_team"); + }); + + it("falls back to the free plan sub once the canceled sub has ended", () => { + const endedTeam: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis - 86400000, pastMillis), + id: "sub_team", + productId: "team", + status: "canceled", + cancelAtPeriodEnd: true, + canceledAtMillis: pastMillis - 86400000, + endedAtMillis: pastMillis, + }; + const activeFree: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_free", + productId: "free", + }; + expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree }, nowMillis)?.id).toBe("sub_free"); + }); +}); diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index bf5c39894..90420ae79 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, @@ -157,10 +157,12 @@ export async function readBillingSubscriptionMapOrSkip( } } -function resolveActivePlanSubscription(subscriptions: Record): SubscriptionRow | null { - const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription); +export function resolveInEffectPlanSubscription(subscriptions: Record, nowMillis: number): SubscriptionRow | null { + // In-effect (not active): a canceled-at-period-end plan sub keeps its item + // grants until `endedAt`, so usage must be judged against that plan. + 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; } @@ -421,9 +423,9 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D customerType: "team", customerId: ownerTeamId, })); - const activePlanSubscription = resolveActivePlanSubscription(subscriptions); - const planId = resolveActivePlanId(activePlanSubscription); - const period = getPlanUsagePeriod(activePlanSubscription, now); + const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions, now.getTime()); + const planId = resolveActivePlanId(inEffectPlanSubscription); + const period = getPlanUsagePeriod(inEffectPlanSubscription, now); const [ownerTeamDisplayName, ownedScope, dashboardAdmins] = await Promise.all([ getOwnerTeamDisplayName(internalTenancy, ownerTeamId), @@ -441,7 +443,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D owner_team_id: ownerTeamId, owner_team_display_name: ownerTeamDisplayName, plan_id: planId, - plan_display_name: activePlanSubscription?.product.displayName ?? getPlanLabel(planId), + plan_display_name: inEffectPlanSubscription?.product.displayName ?? getPlanLabel(planId), period_start_millis: period.start.getTime(), period_end_millis: period.end.getTime(), next_plan_id: getNextPlanId(planId), diff --git a/apps/backend/src/lib/stripe.test.ts b/apps/backend/src/lib/stripe.test.ts new file mode 100644 index 000000000..6bf47cc18 --- /dev/null +++ b/apps/backend/src/lib/stripe.test.ts @@ -0,0 +1,62 @@ +import type Stripe from "stripe"; +import { describe, expect, it } from "vitest"; +import { getCanceledAtForSync, getEndedAtForSync } from "./stripe"; + +// Minimal cast: the helpers only read status / cancel_at_period_end / +// ended_at / canceled_at, and a newly-relied-on field would surface as a +// failing assertion here rather than a silent wrong value. +function stripeSub(fields: { + status: Stripe.Subscription.Status, + cancelAtPeriodEnd?: boolean, + endedAtSeconds?: number | null, + canceledAtSeconds?: number | null, +}): Stripe.Subscription { + return { + status: fields.status, + cancel_at_period_end: fields.cancelAtPeriodEnd ?? false, + ended_at: fields.endedAtSeconds ?? null, + canceled_at: fields.canceledAtSeconds ?? null, + } as unknown as Stripe.Subscription; +} + +describe("getEndedAtForSync", () => { + const periodEnd = new Date("2026-08-16T00:00:00Z"); + + it("clears endedAt for a non-terminal sub without a pending cancel (reactivation path)", () => { + expect(getEndedAtForSync(stripeSub({ status: "active" }), periodEnd)).toEqual({ endedAt: null }); + }); + + it("schedules endedAt at Stripe's period end while a cancel is pending", () => { + expect(getEndedAtForSync(stripeSub({ status: "active", cancelAtPeriodEnd: true }), periodEnd)).toEqual({ endedAt: periodEnd }); + }); + + it("uses Stripe's ended_at for terminal subs", () => { + const endedAtSeconds = 1_800_000_000; + expect(getEndedAtForSync(stripeSub({ status: "canceled", endedAtSeconds }), periodEnd)).toEqual({ endedAt: new Date(endedAtSeconds * 1000) }); + }); + + it("falls back to the past period boundary for terminal subs without ended_at", () => { + const pastEnd = new Date(Date.now() - 86400000); + expect(getEndedAtForSync(stripeSub({ status: "incomplete_expired" }), pastEnd)).toEqual({ endedAt: pastEnd }); + }); + + it("falls back to now for terminal subs whose period end is still in the future", () => { + const futureEnd = new Date(Date.now() + 86400000); + const before = Date.now(); + const result = getEndedAtForSync(stripeSub({ status: "unpaid" }), futureEnd); + expect(result.endedAt).not.toBeNull(); + expect(result.endedAt!.getTime()).toBeGreaterThanOrEqual(before); + expect(result.endedAt!.getTime()).toBeLessThanOrEqual(Date.now()); + }); +}); + +describe("getCanceledAtForSync", () => { + it("mirrors Stripe's canceled_at", () => { + const canceledAtSeconds = 1_800_000_000; + expect(getCanceledAtForSync(stripeSub({ status: "active", canceledAtSeconds }))).toEqual({ canceledAt: new Date(canceledAtSeconds * 1000) }); + }); + + it("clears canceledAt when Stripe's is null (reactivation path)", () => { + expect(getCanceledAtForSync(stripeSub({ status: "active" }))).toEqual({ canceledAt: null }); + }); +}); diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 17dd78ea8..f0ae42ce8 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -236,9 +236,13 @@ const getTenancyFromStripeAccountIdOrThrow = async (stripe: Stripe, stripeAccoun const TERMINAL_STRIPE_STATUSES = ["canceled", "incomplete_expired", "unpaid"] as const; -function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date } | {} { +export function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date | null } { if (!TERMINAL_STRIPE_STATUSES.includes(subscription.status as typeof TERMINAL_STRIPE_STATUSES[number])) { - return {}; + // Non-terminal: reconcile the eagerly-written wind-down endedAt with + // Stripe's flag — a pending cancel can be reversed via Stripe's + // Dashboard/API, and clearing endedAt here is what reactivates + // entitlements. + return { endedAt: subscription.cancel_at_period_end ? sanitizedEnd : null }; } // Prefer Stripe's `ended_at` — real Stripe always sets it on transitions into // a terminal status. If the webhook payload omits it (mocks, older API @@ -254,11 +258,23 @@ function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date return { endedAt: new Date() }; } -function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date } | {} { - if (subscription.canceled_at) { - return { canceledAt: new Date(subscription.canceled_at * 1000) }; +export function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date | null } { + // Mirror Stripe including null, so reversing a pending cancel clears the local mark + return { canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null }; +} + +/** + * Sanitized period end of the subscription's first item, or null when the + * response has no items (mocked Stripe). Lets wind-down writers take the + * boundary from Stripe's authoritative response instead of a possibly stale + * (pre-renewal) local period end. + */ +export function getStripeSubscriptionPeriodEnd(subscription: Stripe.Subscription, context: { tenancyId: string }): Date | null { + if (subscription.items.data.length === 0) { + return null; } - return {}; + const item = subscription.items.data[0]; + return sanitizeStripePeriodDates(item.current_period_start, item.current_period_end, { subscriptionId: subscription.id, tenancyId: context.tenancyId }).end; } export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: string, stripeCustomerId: string) { @@ -333,6 +349,13 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s currentPeriodEnd: sanitizedDates.end, currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, + // Same derivation as the update branch: the first sync for a sub can + // already be terminal (out-of-order webhooks), and a terminal row + // created with endedAt = null reads as "entitled forever" to + // isSubscriptionInEffect. Pre-existing rows in that shape need a + // backfill (follow-up; also requires re-emitting bulldozer rows). + ...getEndedAtForSync(subscription, sanitizedDates.end), + ...getCanceledAtForSync(subscription), creationSource: "PURCHASE_PAGE" }, }); 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..48a9f3471 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,187 @@ 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); + + // 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", + }); + 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); + + 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 {