From 45ddd3877de46e325ddc4228cc1b98687608599c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 16 Jul 2026 17:46:56 -0700 Subject: [PATCH] Reconcile Stripe wind-down state in webhook sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from further review of the wind-down handling: - getEndedAtForSync now reconciles endedAt on NON-terminal syncs too: cancel_at_period_end true schedules Stripe's period end, false clears it. This repairs the eagerly-written endedAt when a pending cancel is reversed through Stripe's Dashboard/API (previously sticky — Stripe kept billing while bulldozer still ended the grants) and when the eager write used a stale pre-renewal boundary. getCanceledAtForSync now mirrors Stripe's canceled_at including null for the same reason. - Apply the endedAt/canceledAt derivation to the webhook upsert's create branch. The first sync for a sub can already be terminal (out-of-order webhooks, or purchase-session subs whose first local write is a webhook); such rows were created with endedAt null, which isSubscriptionInEffect reads as entitled forever. A backfill for pre-existing rows in that shape is noted as a follow-up (it needs the bulldozer rows re-emitted, so it isn't a plain SQL migration). - Source the cancel and refund routes' eager endedAt from the Stripe update response (new getStripeSubscriptionPeriodEnd helper) instead of the possibly stale local/bulldozer period end. - Clear cancel_at_period_end on invoice.paid when the paid invoice was issued after canceledAt — that can only be the purchase-session re-price of a winding-down sub, so paying it means "keep me subscribed". Deliberately NOT cleared at session creation: that runs before payment (default_incomplete has no rollback), and a declined card would silently reactivate an explicitly-canceled sub while leaving it re-priced. The created-after-cancel guard also makes a late-delivered invoice.paid for the original creation invoice unable to undo a cancel. - Rank representative subs cancelable > active > in-effect (subscriptionDisplayRank) in the products list: a pending-cancel Stripe sub stays active, so the previous active-only preference could let it shadow a cancelable sibling of a stackable product and report is_cancelable false while DELETE would succeed. - Thread the caller's clock through resolveInEffectPlanSubscription instead of calling Date.now() inside, keeping plan selection and usage queries on the same instant. - Unit tests for the sync helpers, the display rank, and deterministic clocks in the plan-usage tests. --- .../integrations/stripe/webhooks/route.tsx | 9 +- .../payments/transactions/refund/route.tsx | 11 ++- .../[customer_id]/[product_id]/route.ts | 14 ++- .../[customer_type]/[customer_id]/route.ts | 9 +- .../[customer_id]/switch/route.ts | 6 +- .../purchases/purchase-session/route.tsx | 8 ++ apps/backend/src/lib/payments.test.tsx | 15 ++- apps/backend/src/lib/payments.tsx | 12 +++ apps/backend/src/lib/plan-usage.test.ts | 10 +- apps/backend/src/lib/plan-usage.ts | 5 +- apps/backend/src/lib/stripe.test.ts | 67 +++++++++++++ apps/backend/src/lib/stripe.tsx | 98 +++++++++++++++++-- 12 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 apps/backend/src/lib/stripe.test.ts diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 5c0927881..85da109d9 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -3,7 +3,7 @@ import { bulldozerWriteOneTimePurchase } from "@/lib/payments/bulldozer-dual-wri import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "@/lib/stripe-webhook-events"; import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import { listPermissions } from "@/lib/permissions"; -import { getHexclaveStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; +import { getHexclaveStripe, getStripeForAccount, reactivateSubscriptionIfRepurchasePaid, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; import type { StripeOverridesMap } from "@/lib/stripe-proxy"; import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram"; import { getTenancy, type Tenancy } from "@/lib/tenancies"; @@ -342,6 +342,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { await upsertStripeInvoice(stripe, accountId, invoice); } + if (event.type === "invoice.paid") { + // A paid invoice issued after a cancel means the customer paid to keep + // the sub (purchase-session re-price) — clear the pending cancel. + const invoice = event.data.object as Stripe.Invoice; + await reactivateSubscriptionIfRepurchasePaid(stripe, accountId, invoice); + } + if (event.type === "invoice.payment_succeeded") { const invoice = event.data.object as Stripe.Invoice; 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..1b8135bc8 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,13 @@ 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 period boundary; the local row can be stale around a renewal + // whose webhook hasn't synced yet. + stripePeriodEnd = getStripeSubscriptionPeriodEnd(updated, { tenancyId: tenancy.id }); } catch (e: unknown) { if (!isStripeSubscriptionAlreadyTerminalError(e)) { throw e; @@ -711,7 +716,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 62ba296ad..973bddca3 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 @@ -4,7 +4,7 @@ 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"; @@ -147,7 +147,15 @@ export const DELETE = createSmartRouteHandler({ // list reflects the wind-down before the webhook sync arrives; the // Stripe sub stays `active` with cancel_at_period_end until Stripe // ends it at the period boundary. - await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); + const updated = await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); + // Take the boundary from Stripe's response, not the bulldozer + // snapshot read at request start — around a renewal whose webhook + // hasn't synced yet, the snapshot's period end is the previous + // (possibly already-past) one. The snapshot fallback only covers + // item-less mocked responses; the webhook sync reconciles endedAt + // either way. + const endedAt = getStripeSubscriptionPeriodEnd(updated, { tenancyId: auth.tenancy.id }) + ?? new Date(subscription.currentPeriodEndMillis); updatedSub = await prisma.subscription.update({ where: { tenancyId_id: { @@ -158,7 +166,7 @@ export const DELETE = createSmartRouteHandler({ data: { cancelAtPeriodEnd: true, canceledAt: new Date(), - endedAt: new Date(subscription.currentPeriodEndMillis), + endedAt, }, }); } else { 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 6aa360020..cd30cd3b0 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, isSubscriptionCancelable, isSubscriptionInEffect, 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"; @@ -72,15 +72,16 @@ export const GET = createSmartRouteHandler({ // active): a canceled-at-period-end sub still backs its owned product // and must not fall through to the one-time-purchase branch below. // When several in-effect subs share a productId (e.g. one quantity of a - // stackable product was canceled by subscription_id), prefer an active - // one so the winding-down sub can't shadow it and hide the cancel button. + // stackable product was canceled by subscription_id), prefer the highest + // subscriptionDisplayRank (cancelable > active > in-effect) so a + // winding-down sub can't shadow one the customer can still act on. 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 || (!isActiveSubscription(existing) && isActiveSubscription(s))) { + if (existing == null || subscriptionDisplayRank(s) > subscriptionDisplayRank(existing)) { inEffectSubByProductId.set(key, s); } } 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 882787686..ea4552a4a 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 @@ -315,8 +315,10 @@ export const POST = createSmartRouteHandler({ currentPeriodStart: sanitizedUpdateDates.start, currentPeriodEnd: sanitizedUpdateDates.end, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end, - // Clear the wind-down marks the cancel route wrote eagerly — - // without this the reactivated sub still ends at the old boundary. + // Clear the wind-down marks the cancel route wrote eagerly. The + // webhook sync now reconciles these from Stripe's state anyway; + // the manual clear only covers the pre-webhook window so the + // products list doesn't show "Ends on" right after a switch. canceledAt: null, endedAt: null, }, 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..d513d8139 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 @@ -131,6 +131,14 @@ export const POST = createSmartRouteHandler({ payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.confirmation_secret'], + // Note: we deliberately do NOT clear cancel_at_period_end here. + // This update runs at session creation, before the customer has + // paid (default_incomplete leaves the invoice open, and unlike + // the switch route's error_if_incomplete there is no rollback on + // payment failure) — clearing it now would reactivate an + // explicitly-canceled sub on a declined card. The webhook clears + // the flag on invoice.paid, once the re-purchase has actually + // been paid for. items: [{ id: existingItem.id, price_data: { diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 0e7dbdaeb..dd63abcc6 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 { isSubscriptionCancelable, isSubscriptionInEffect, validatePurchaseSession } from './payments'; +import { isSubscriptionCancelable, isSubscriptionInEffect, subscriptionDisplayRank, validatePurchaseSession } from './payments'; import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { globalPrismaClient } from "@/prisma-client"; @@ -293,3 +293,16 @@ describe('isSubscriptionCancelable', () => { }); }); +describe('subscriptionDisplayRank', () => { + it('ranks cancelable above pending-cancel Stripe subs above wound-down subs', () => { + const cancelable = { status: 'active', cancelAtPeriodEnd: false }; + // A pending-cancel Stripe sub stays `active` — this is the tier that an + // active-only preference gets wrong (it would tie with the cancelable + // sibling and shadow it depending on iteration order). + 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 8d3338124..acd35d637 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -136,6 +136,18 @@ export function isSubscriptionCancelable(subscription: { status: string, cancelA return isActiveSubscription(subscription) && !subscription.cancelAtPeriodEnd; } +/** + * Rank for picking the representative subscription when several in-effect + * subs share a productId (stackable products): cancelable > merely-active > + * merely-in-effect. Cancelable > active matters for Stripe subs — a + * pending-cancel Stripe sub stays `active` (with cancelAtPeriodEnd), so an + * active-only preference would let it shadow a cancelable sibling and report + * is_cancelable: false while a DELETE would in fact succeed. + */ +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 diff --git a/apps/backend/src/lib/plan-usage.test.ts b/apps/backend/src/lib/plan-usage.test.ts index 78c61b288..85950d244 100644 --- a/apps/backend/src/lib/plan-usage.test.ts +++ b/apps/backend/src/lib/plan-usage.test.ts @@ -191,8 +191,10 @@ describe("billing period selection", () => { }); describe("resolveInEffectPlanSubscription", () => { - const futureMillis = Date.now() + 30 * 86400000; - const pastMillis = Date.now() - 86400000; + // 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 = { @@ -209,7 +211,7 @@ describe("resolveInEffectPlanSubscription", () => { id: "sub_free", productId: "free", }; - expect(resolveInEffectPlanSubscription({ sub_team: windingDownTeam, sub_free: activeFree })?.id).toBe("sub_team"); + 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", () => { @@ -227,6 +229,6 @@ describe("resolveInEffectPlanSubscription", () => { id: "sub_free", productId: "free", }; - expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree })?.id).toBe("sub_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 756a74140..90420ae79 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -157,10 +157,9 @@ export async function readBillingSubscriptionMapOrSkip( } } -export function resolveInEffectPlanSubscription(subscriptions: Record): SubscriptionRow | null { +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 nowMillis = Date.now(); const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis)); for (const planId of BASE_PLAN_IDS_BY_TIER) { const subscription = inEffectSubscriptions.find((candidate) => candidate.productId === planId); @@ -424,7 +423,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D customerType: "team", customerId: ownerTeamId, })); - const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions); + const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions, now.getTime()); const planId = resolveActivePlanId(inEffectPlanSubscription); const period = getPlanUsagePeriod(inEffectPlanSubscription, now); diff --git a/apps/backend/src/lib/stripe.test.ts b/apps/backend/src/lib/stripe.test.ts new file mode 100644 index 000000000..39fff1ab1 --- /dev/null +++ b/apps/backend/src/lib/stripe.test.ts @@ -0,0 +1,67 @@ +import type Stripe from "stripe"; +import { describe, expect, it } from "vitest"; +import { getCanceledAtForSync, getEndedAtForSync } from "./stripe"; + +// The helpers only read status / cancel_at_period_end / ended_at / +// canceled_at, but are typed against the full Stripe.Subscription. Building a +// real Stripe.Subscription in a unit test is impractical, so we cast a +// minimal object; a missing field the helpers start relying on would surface +// as a failing assertion here, not as 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)", () => { + // Reversing a pending cancellation via Stripe's Dashboard/API must clear + // the endedAt the cancel route wrote eagerly — otherwise bulldozer still + // ends the grants at the old boundary while Stripe keeps billing. + 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..c2a4ef9ff 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -236,9 +236,16 @@ 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 locally scheduled end with Stripe's flag. + // The cancel route eagerly writes endedAt = period end; Stripe is the + // authority on both whether that end is still scheduled (the pending + // cancel can be reversed via Stripe's Dashboard/API) and on the exact + // boundary (the eager write may have used a stale pre-renewal period + // end). isSubscriptionInEffect treats endedAt == null as "no end + // scheduled", so clearing it 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 +261,26 @@ 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 exactly, including null: reversing a pending cancellation + // clears canceled_at on Stripe's side, and the local mark must follow. + 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 carries no items (mocked Stripe). Used to eagerly write the + * wind-down `endedAt` from Stripe's authoritative response instead of a + * possibly stale (pre-renewal) local or bulldozer period end. Items with + * invalid period values fall through to sanitizeStripePeriodDates' own + * now→now+1mo fallback (plus a captureError), not to the caller's fallback. + */ +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 +355,19 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s currentPeriodEnd: sanitizedDates.end, currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, + // Same endedAt/canceledAt derivation as the update branch: the first + // sync we see for a sub can already be terminal (out-of-order + // webhooks, or a purchase-session sub whose first local write is a + // late webhook). Without this, a terminal row is created with + // endedAt = null, which isSubscriptionInEffect reads as "entitled + // forever". Note: rows created before this derivation existed can + // still hold that bad shape (terminal status, endedAt = null) and + // only self-heal when another webhook for the same customer sweeps + // them — a backfill migration (status IN terminal AND endedAt IS + // NULL) is tracked as a follow-up, since it also needs the bulldozer + // rows re-emitted. + ...getEndedAtForSync(subscription, sanitizedDates.end), + ...getCanceledAtForSync(subscription), creationSource: "PURCHASE_PAGE" }, }); @@ -348,6 +383,57 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s } } +/** + * If a paid invoice belongs to a pending-cancel subscription and was issued + * AFTER the cancellation, the customer has just paid to keep the sub (the + * purchase-session conflict-reuse path re-prices the winding-down sub and + * lets the customer pay via Stripe Elements) — so clear cancel_at_period_end + * and re-sync, which also clears the local endedAt/canceledAt wind-down + * marks via getEndedAtForSync/getCanceledAtForSync. + * + * The invoice.created > canceledAt guard is what makes this safe: a + * late-delivered invoice.paid for the ORIGINAL creation invoice predates the + * cancel and must not undo it, and pending-cancel subs generate no renewal + * invoices, so a paid invoice issued after canceledAt can only be the + * re-purchase. We can't clear the flag at session creation instead — that + * runs before payment (default_incomplete has no rollback), and a declined + * card would silently reactivate an explicitly-canceled sub. + */ +export async function reactivateSubscriptionIfRepurchasePaid(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) { + if (invoice.status !== "paid") { + return; + } + const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? []; + const invoiceSubscriptionIds = invoiceLines + .map((line) => line.parent?.subscription_item_details?.subscription) + .filter((subscription): subscription is string => !!subscription); + if (invoiceSubscriptionIds.length !== 1) { + return; + } + const stripeSubscriptionId = invoiceSubscriptionIds[0]; + const tenancy = await getTenancyFromStripeAccountIdOrThrow(stripe, stripeAccountId); + const prisma = await getPrismaClientForTenancy(tenancy); + const localSub = await prisma.subscription.findUnique({ + where: { + tenancyId_stripeSubscriptionId: { + tenancyId: tenancy.id, + stripeSubscriptionId, + }, + }, + select: { cancelAtPeriodEnd: true, canceledAt: true }, + }); + if (localSub == null || !localSub.cancelAtPeriodEnd) { + return; + } + if (localSub.canceledAt == null || invoice.created * 1000 <= localSub.canceledAt.getTime()) { + return; + } + await stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: false }); + if (typeof invoice.customer === "string") { + await syncStripeSubscriptions(stripe, stripeAccountId, invoice.customer); + } +} + export async function upsertStripeInvoice(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) { const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? []; const invoiceSubscriptionIds = invoiceLines