Reject repurchase of winding-down Stripe subs instead of webhook reactivation

The invoice.paid reactivation path (clear cancel_at_period_end when a paid
invoice postdates canceledAt) was fragile: the created-after-cancel heuristic
can match unrelated collectible invoices, and the single-subscription guard
counted raw invoice lines, so re-price invoices with proration lines would
silently skip reactivation. Purchase sessions now return a 400 when the
conflicting Stripe sub is canceled or winding down; reactivation-on-repurchase
is deferred (see PR #1773 discussion).
This commit is contained in:
Bilal Godil 2026-07-17 09:42:33 -07:00
parent 7e1bad8c0d
commit eb4c07be32
4 changed files with 30 additions and 60 deletions

View File

@ -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<void> {
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;

View File

@ -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: {

View File

@ -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.

View File

@ -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