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 85da109d9..5c0927881 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, reactivateSubscriptionIfRepurchasePaid, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; +import { getHexclaveStripe, getStripeForAccount, 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,13 +342,6 @@ 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/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index a041b3baf..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" }); @@ -131,10 +156,6 @@ export const POST = createSmartRouteHandler({ payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.confirmation_secret'], - // Deliberately NOT clearing cancel_at_period_end here: this runs - // before payment (default_incomplete has no rollback), so a - // declined card would reactivate an explicitly-canceled sub. The - // webhook clears the flag on invoice.paid instead. items: [{ id: existingItem.id, price_data: { diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 6a2c41e97..c3448b56a 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -425,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; @@ -471,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) => @@ -495,7 +495,7 @@ export async function validatePurchaseSession(options: { { 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({ @@ -521,7 +521,7 @@ export async function validatePurchaseSession(options: { && !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. diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 10913c3f3..f0ae42ce8 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -371,50 +371,6 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s } } -/** - * A paid invoice issued AFTER a sub's cancellation can only be the - * purchase-session re-price of a winding-down sub (pending-cancel subs - * generate no renewal invoices), so paying it means "keep me subscribed": - * clear cancel_at_period_end and re-sync. The created-after-cancel guard - * also keeps a late-delivered invoice.paid for the original creation invoice - * from undoing a cancel. Clearing at session creation instead would - * reactivate on declined cards (default_incomplete has no rollback). - */ -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