mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
7e1bad8c0d
commit
eb4c07be32
@ -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;
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user