From fe1faeced4200d23e1048f0d2bbfcb24d796483a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 13:30:42 +0300 Subject: [PATCH 1/6] fix(billing): pin the Stripe API version and stop unpaid periods granting access The Stripe client was built without an apiVersion, so the SDK followed whatever version it shipped with. Two fields moved in the Basil API version: the billing period went from the subscription onto its items, and the invoice link to its subscription went under parent.subscription_details. Both reads returned undefined without failing, which left stripeCurrentPeriodEnd null for every subscriber and left the app with no invoice handling at all. A customer whose card failed saw nothing about the invoice that was still retrying, and a cancellation did nothing to stop those retries. - Pin the API version, with `satisfies` so an SDK bump is a compile error here before it is a null read in production. - Read the period off subscription items and the subscription off invoice parents, keeping the legacy fields as a fallback for older payloads. - Handle invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible through the existing customer-wide resync, so the mirror reflects payment health during dunning rather than after it. - Add an in-app cancellation route: at period end when the subscription is paid, immediately plus voiding the open invoices when it is not, because cancelling alone does not stop collection on an invoice already issued. - Ask Stripe, not just the local mirror, before opening checkout. - Show the open invoice, the retry date and a payment-method-update shortcut in settings, and put a confirmation in front of cancellation. Access no longer rests on the reported period alone. Stripe advances the period when it issues the renewal invoice, paid or not, and the period survives cancellation, so once the period field started being read correctly that check would have handed a full free month to anyone whose renewal failed, and the new cancel route would have let them void the invoice and keep the month. Access now follows the subscription status, billingAccessEndedAt is enforced as a hard cutoff in both hasBillingAccess and the query that mirrors it, and a subscription behind on payment keeps access for Stripe's retry window rather than for the period it never paid for. --- .../settings/settings-page-client.tsx | 169 ++++++++++- app/api/billing/cancel/route.ts | 78 +++++ app/api/billing/checkout/route.ts | 17 +- app/api/billing/portal/route.ts | 45 ++- app/api/billing/route.ts | 44 ++- app/api/stripe/webhook/route.ts | 15 + lib/billing.ts | 285 +++++++++++++++++- lib/stripe.ts | 8 +- package.json | 4 +- scripts/resync-stripe-subscriptions.ts | 81 +++++ 10 files changed, 718 insertions(+), 28 deletions(-) create mode 100644 app/api/billing/cancel/route.ts create mode 100644 scripts/resync-stripe-subscriptions.ts diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 96b3744..e052a97 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -29,8 +29,39 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { cn } from '@/lib/utils'; +/** + * Stripe reports amounts in the currency's smallest unit, and how many of those make a + * whole unit differs per currency: two for USD, none for JPY. The formatter knows the + * exponent, so it decides the divisor instead of a hardcoded 100. + */ +function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { + const currencyCode = currency.toUpperCase(); + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + }); + const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2; + return formatter.format(amountInMinorUnits / 10 ** fractionDigits); + } catch { + return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`; + } +} + interface NotificationSettings { telegramChatId: string | null; telegramEnabled: boolean; @@ -49,6 +80,16 @@ interface BillingOverview { status: 'disabled' | 'ready' | 'misconfigured'; checkoutAvailable: boolean; portalAvailable: boolean; + cancelAvailable: boolean; + needsPaymentFix: boolean; + openInvoice: { + id: string | null; + hostedInvoiceUrl: string | null; + amountDue: number; + currency: string; + attemptCount: number; + nextPaymentAttempt: string | null; + } | null; subscription: { status: string; label: string; @@ -148,7 +189,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo const [testing, setTesting] = useState(null); const [billing, setBilling] = useState(null); const [billingLoading, setBillingLoading] = useState(true); - const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null); + const [billingAction, setBillingAction] = useState< + 'checkout' | 'portal' | 'trial' | 'cancel' | null + >(null); const [storageInfo, setStorageInfo] = useState(null); const [storageLoading, setStorageLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -255,12 +298,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo ); const handleBillingRedirect = useCallback( - async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => { + async ( + endpoint: '/api/billing/checkout' | '/api/billing/portal', + flow?: 'payment_method_update' + ) => { setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal'); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(flow ? { flow } : {}), }); const data = await res.json(); @@ -302,6 +349,38 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo } }, [showMessage]); + const handleCancelSubscription = useCallback(async () => { + setBillingAction('cancel'); + try { + const res = await fetch('/api/billing/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + + if (!res.ok) { + showMessage('error', data.error || 'Failed to cancel subscription'); + return; + } + + const billingRes = await fetch('/api/billing'); + if (billingRes.ok) { + setBilling((await billingRes.json()).data); + } + + showMessage( + 'success', + data.data.canceledImmediately + ? 'Subscription canceled. No further payment will be attempted.' + : 'Subscription canceled. Access remains until the end of the current billing period.' + ); + } catch { + showMessage('error', 'Failed to cancel subscription'); + } finally { + setBillingAction(null); + } + }, [showMessage]); + if (loading) { return (
@@ -481,10 +560,53 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
) : null} + {billing.openInvoice ? ( +
+

+ A payment of{' '} + {formatInvoiceAmount( + billing.openInvoice.amountDue, + billing.openInvoice.currency + )}{' '} + did not go through +

+

+ {billing.openInvoice.attemptCount} attempt + {billing.openInvoice.attemptCount === 1 ? '' : 's'} so far + {billing.openInvoice.nextPaymentAttempt + ? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}` + : ''} + . Update your payment method or pay the invoice to stop the retries, or cancel + to stop them for good. +

+ {billing.subscription.billingAccessEndedAt ? ( +

+ Access to your workspaces continues until{' '} + {new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. +

+ ) : null} + {billing.openInvoice.hostedInvoiceUrl ? ( + + View and pay this invoice + + ) : null} +
+ ) : null} +
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? ( )} + + {billing.cancelAvailable ? ( + + + + + + + Cancel your subscription? + + {billing.needsPaymentFix + ? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.' + : 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'} + + + + Keep Subscription + + Cancel Subscription + + + + + ) : null}
)} diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..f8e08ae --- /dev/null +++ b/app/api/billing/cancel/route.ts @@ -0,0 +1,78 @@ +import { NextRequest } from 'next/server'; +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { + findLiveStripeSubscription, + getBillingOverview, + isUnpaidStripeSubscription, + syncStripeSubscriptionToUser, + voidOpenSubscriptionInvoices, +} from '@/lib/billing'; +import { rateLimit } from '@/lib/rate-limit'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; +import { getStripe, isStripeConfigured } from '@/lib/stripe'; +import { isTrustedSameOriginRequest } from '@/lib/request-origin'; +import { logError } from '@/lib/logger'; + +export async function POST(request: NextRequest) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + if (!isTrustedSameOriginRequest(request)) { + return apiErrors.forbidden('Invalid request origin'); + } + + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + if (!isStripeFeatureEnabled()) { + return apiErrors.badRequest('Stripe billing is disabled by this host'); + } + + if (!isStripeConfigured()) { + return apiErrors.internalError('Stripe billing is not configured'); + } + + const billing = await getBillingOverview(session.user.id); + const customerId = billing.subscription.stripeCustomerId; + if (!customerId) { + return apiErrors.badRequest('No Stripe customer exists for this account'); + } + + const subscription = await findLiveStripeSubscription(customerId); + if (!subscription) { + return apiErrors.badRequest('No subscription to cancel'); + } + + const stripe = getStripe(); + const unpaid = isUnpaidStripeSubscription(subscription); + + // Scheduling an unpaid subscription to the end of its period leaves the customer + // owing money for a period they never paid for, while the already issued invoice + // keeps retrying their card on its own. Those cancel immediately instead, and the + // invoice for the unserved period is voided in the same pass. + const canceled = unpaid + ? await stripe.subscriptions.cancel(subscription.id) + : await stripe.subscriptions.update(subscription.id, { cancel_at_period_end: true }); + + const voidedInvoices = unpaid + ? await voidOpenSubscriptionInvoices(customerId, subscription.id) + : []; + + await syncStripeSubscriptionToUser(canceled); + + const response = successResponse({ + canceledImmediately: unpaid, + status: canceled.status, + cancelAt: canceled.cancel_at ? new Date(canceled.cancel_at * 1000).toISOString() : null, + voidedInvoices, + }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + logError('Error canceling Stripe subscription:', error); + return apiErrors.internalError('Failed to cancel subscription'); + } +} diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index d8d6b52..7d1f944 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -1,7 +1,11 @@ import { NextRequest } from 'next/server'; import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; -import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing'; +import { + findBlockingStripeSubscription, + getOrCreateStripeCustomerId, + getStripeCheckoutState, +} from '@/lib/billing'; import { rateLimit } from '@/lib/rate-limit'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe'; @@ -57,6 +61,17 @@ export async function POST(request: NextRequest) { const stripe = getStripe(); const priceId = getStripePriceId(); const customerId = await getOrCreateStripeCustomerId(session.user.id); + + // The guard above reads the local mirror, which can be stale or cleared: the incident + // that prompted this had a customer holding three subscriptions at once because the + // mirror said there were none. Stripe is the one that knows. + const blockingSubscription = await findBlockingStripeSubscription(customerId); + if (blockingSubscription) { + return apiErrors.badRequest( + 'A subscription already exists for this account. Manage it from the billing portal.' + ); + } + const appOrigin = getAppOrigin(request); const checkoutSession = await stripe.checkout.sessions.create({ diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index 7b9f791..f2f8893 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server'; +import type Stripe from 'stripe'; import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { getBillingOverview } from '@/lib/billing'; @@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) { return request.nextUrl.origin; } +async function readRequestedFlow(request: NextRequest) { + try { + const body = await request.json(); + return body?.flow === 'payment_method_update' ? 'payment_method_update' : null; + } catch { + return null; + } +} + +async function createPortalSession( + stripe: Stripe, + customer: string, + returnUrl: string, + flow: 'payment_method_update' | null +) { + if (flow === 'payment_method_update') { + try { + return await stripe.billingPortal.sessions.create({ + customer, + return_url: returnUrl, + flow_data: { type: 'payment_method_update' }, + }); + } catch (error) { + // The portal configuration may not expose this flow; the plain portal still works. + logError('Falling back to the default Stripe portal flow:', error); + } + } + + return stripe.billingPortal.sessions.create({ + customer, + return_url: returnUrl, + }); +} + export async function POST(request: NextRequest) { try { const limited = await rateLimit(request, 'mutate'); @@ -47,10 +82,12 @@ export async function POST(request: NextRequest) { } const stripe = getStripe(); - const portalSession = await stripe.billingPortal.sessions.create({ - customer: billing.subscription.stripeCustomerId, - return_url: `${getAppOrigin(request)}/settings`, - }); + const portalSession = await createPortalSession( + stripe, + billing.subscription.stripeCustomerId, + `${getAppOrigin(request)}/settings`, + await readRequestedFlow(request) + ); const response = successResponse({ url: portalSession.url }); return withCacheControl(response, 'private, no-store'); diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index 4720207..b85cae1 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -1,6 +1,7 @@ +import { BillingSubscriptionStatus } from '@prisma/client'; import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; -import { getBillingOverview } from '@/lib/billing'; +import { getBillingOverview, getOpenInvoiceForCustomer } from '@/lib/billing'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe'; import { logError } from '@/lib/logger'; @@ -15,12 +16,51 @@ export async function GET() { const billing = await getBillingOverview(session.user.id); const isEnabled = isStripeFeatureEnabled(); const isConfigured = hasStripeRuntimeConfig(); + + // Only looked up when the account actually owes something, so the common path does not + // pay for a Stripe round trip. + const needsPaymentFix = + billing.subscription.status === BillingSubscriptionStatus.PAST_DUE || + billing.subscription.status === BillingSubscriptionStatus.UNPAID; + const openInvoice = + isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId + ? await getOpenInvoiceForCustomer( + billing.subscription.stripeCustomerId, + billing.subscription.stripeSubscriptionId + ) + : null; + const response = successResponse({ isEnabled, isConfigured, status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured', checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription, - portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId), + // A customer id alone is not enough: it is created on the first checkout attempt, so + // someone who abandoned checkout would be sent to an empty portal. + portalAvailable: + isStripeConfigured() && + Boolean(billing.subscription.stripeCustomerId) && + (billing.subscription.hasRecoverableSubscription || + Boolean(billing.subscription.stripeSubscriptionId)), + // Gated on the status rather than on the mirrored subscription id: the id survives a + // cancellation until the deletion webhook arrives, and offering Cancel on an already + // canceled subscription just returns an error. + cancelAvailable: + isStripeConfigured() && + billing.subscription.hasRecoverableSubscription && + !billing.subscription.cancelAt && + !billing.subscription.cancelAtPeriodEnd, + needsPaymentFix, + openInvoice: openInvoice + ? { + id: openInvoice.id, + hostedInvoiceUrl: openInvoice.hostedInvoiceUrl, + amountDue: openInvoice.amountDue, + currency: openInvoice.currency, + attemptCount: openInvoice.attemptCount, + nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null, + } + : null, subscription: { status: billing.subscription.status, label: billing.subscription.label, diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 8fed380..52dbb4a 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -55,6 +55,21 @@ export async function POST(request: NextRequest) { } break; } + // Invoice events carry the payment health of a subscription earlier and more + // reliably than the subscription events alone. Without them a customer whose card + // failed keeps the mirror of a healthy subscription until Stripe eventually gives + // up, which is the whole dunning window spent showing them the wrong state. + case 'invoice.paid': + case 'invoice.payment_failed': + case 'invoice.voided': + case 'invoice.marked_uncollectible': { + const invoice = event.data.object as Stripe.Invoice; + const customerId = getCustomerId(invoice.customer); + if (customerId) { + await syncStripeCustomerSubscriptions(customerId); + } + break; + } default: break; } diff --git a/lib/billing.ts b/lib/billing.ts index 69b4bac..6eaa502 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -4,6 +4,7 @@ import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { getStripe, getStripePriceId } from '@/lib/stripe'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; +import { logError } from '@/lib/logger'; import { recordSubscriptionTransition } from '@/lib/analytics/billing-events'; import { eventKey, recordEvent } from '@/lib/analytics/record'; import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits'; @@ -35,6 +36,45 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.INCOMPLETE_EXPIRED, ]); +// Stripe-side counterparts of the sets above, used where a raw Stripe subscription is in +// hand rather than the mirrored status. + +// A customer holding one of these must not be sent through checkout again; they belong in +// the billing portal. `incomplete` is deliberately absent: an abandoned first payment +// leaves one behind for about a day, and blocking on it would lock the customer out of +// checkout with nothing to fix in the portal. +const BLOCKING_STRIPE_STATUSES = new Set([ + 'active', + 'trialing', + 'past_due', + 'unpaid', +]); + +// Subscriptions still worth mirroring onto the user. +const LIVE_STRIPE_STATUSES = new Set([ + 'active', + 'trialing', + 'past_due', + 'unpaid', +]); + +// Cancelling one of these takes effect immediately: the open period was never paid for, +// so there is nothing left to run out. +const UNPAID_STRIPE_STATUSES = new Set([ + 'past_due', + 'unpaid', + 'incomplete', +]); + +// A subscription that was running and then missed a payment. It keeps access while Stripe +// retries the card, so a customer whose card expired is not locked out before they have +// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it. +const RETRYING_STRIPE_STATUSES = new Set(['past_due', 'unpaid']); + +// Roughly Stripe's default Smart Retries window. Access follows the retry window rather +// than the period Stripe advanced when it issued the invoice that was never paid. +const UNPAID_ACCESS_GRACE_DAYS = 14; + export const DEFAULT_TRIAL_PERIOD_DAYS = 7; const STORAGE_CLEANUP_GRACE_DAYS = 15; @@ -124,6 +164,16 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new return true; } + // A recorded end date is a hard stop, checked before anything else. Stripe advances the + // billing period the moment it issues the renewal invoice, paid or not, and the period + // survives cancellation, so the period end below would otherwise hand a full free month + // to anyone whose renewal fails. `isPaidTier` guards the storage tier against the same + // thing; this guards access itself, without the lockout that worried it: a subscription + // behind on payment is stamped with the end of Stripe's retry window, not with today. + if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { + return false; + } + if (hasActiveSubscription(subject.subscriptionStatus)) { return true; } @@ -161,15 +211,24 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use return {}; } + // Mirrors `hasBillingAccess`, including its hard stop, so the query and the in-memory + // check cannot disagree about who still has access. return { - OR: [ + AND: [ { - subscriptionStatus: { - in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], - }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], + }, + { + OR: [ + { + subscriptionStatus: { + in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], + }, + }, + { trialEndsAt: { gt: now } }, + { stripeCurrentPeriodEnd: { gt: now } }, + ], }, - { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, ], }; } @@ -723,6 +782,72 @@ function getStripeTimestamp(value: unknown): number | null { return typeof value === 'number' ? value : null; } +/** + * The billing period moved off the subscription and onto its items in the Basil API + * version, so reading `subscription.current_period_end` yields undefined on every current + * version. Webhook payloads can still be rendered at an older version, so the legacy field + * is kept as a fallback rather than dropped. + */ +export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null { + const itemPeriodEnds = (subscription.items?.data ?? []) + .map((item) => + getStripeTimestamp( + (item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end + ) + ) + .filter((value): value is number => value !== null); + + if (itemPeriodEnds.length > 0) { + return Math.max(...itemPeriodEnds); + } + + return getStripeTimestamp( + (subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end + ); +} + +/** + * Same field move as the period end. Stripe opens the new period when it issues the + * renewal invoice, so for an unpaid subscription this is roughly when the first payment + * attempt failed, which is what the retry window is measured from. + */ +export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null { + const itemPeriodStarts = (subscription.items?.data ?? []) + .map((item) => + getStripeTimestamp( + (item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start + ) + ) + .filter((value): value is number => value !== null); + + if (itemPeriodStarts.length > 0) { + return Math.min(...itemPeriodStarts); + } + + return getStripeTimestamp( + (subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start + ); +} + +/** + * The invoice link to its subscription moved under `parent.subscription_details` in the + * Basil API version. Same fallback reasoning as the period above. + */ +export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null { + const fromParent = invoice.parent?.subscription_details?.subscription; + if (typeof fromParent === 'string') return fromParent; + if (fromParent && typeof fromParent === 'object') return fromParent.id; + + const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription; + if (typeof legacy === 'string') return legacy; + if (legacy && typeof legacy === 'object' && 'id' in legacy) { + const id = (legacy as { id: unknown }).id; + return typeof id === 'string' ? id : null; + } + + return null; +} + function getInactiveBillingAccessEndedAt( subscription: Stripe.Subscription, currentPeriodEnd: number | null @@ -733,9 +858,27 @@ function getInactiveBillingAccessEndedAt( const canceledAt = getStripeTimestamp( (subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at ); - const reference = currentPeriodEnd ?? endedAt ?? canceledAt; - return reference ? new Date(reference * 1000) : new Date(); + // `ended_at` wins over everything: a subscription killed mid-period for non-payment + // must not keep access until a period the customer never paid for. + if (endedAt) { + return new Date(endedAt * 1000); + } + + // Still running, just behind on payment: access ends when Stripe gives up retrying, not + // at the period end, which Stripe already advanced to cover the unpaid invoice. + if (RETRYING_STRIPE_STATUSES.has(subscription.status)) { + const periodStart = getSubscriptionPeriodStart(subscription); + if (periodStart) { + const graceEnd = periodStart + UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60; + return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); + } + } + + // Anything else that gets here never paid for the period Stripe is reporting, so that + // period is not a date access can run to. `incomplete` and `incomplete_expired` are the + // cases that matter: their very first payment never went through. + return canceledAt ? new Date(canceledAt * 1000) : new Date(); } function getEntitledStripePriceId(subscription: Stripe.Subscription) { @@ -768,10 +911,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip return null; } - const currentPeriodEnd = - 'current_period_end' in subscription && typeof subscription.current_period_end === 'number' - ? subscription.current_period_end - : null; + const currentPeriodEnd = getSubscriptionPeriodEnd(subscription); const cancelAt = 'cancel_at' in subscription && typeof subscription.cancel_at === 'number' ? subscription.cancel_at @@ -797,10 +937,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip // what stops an abandoned or failed checkout from erasing the days the account // still had. Legacy card-backed trials keep arriving through the branch above. const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt); - const hasAccess = - hasEntitledPrice && - (hasActiveSubscription(mappedStatus) || - Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now())); + // The reported period is not proof of payment: Stripe advances it when it issues the + // renewal invoice, paid or not, and it survives cancellation. Access therefore follows + // the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`, + // which is cleared again as soon as the subscription goes back to active. + const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus); const updated = await db.user.update({ where: { id: user.id }, @@ -981,3 +1122,115 @@ export async function markSubscriptionCanceledByCustomerId( return updated; } + +/** + * Returns a subscription of this customer that still grants access, if any. A customer can + * hold several at once, so the state of one says nothing about the others. + */ +export async function findLiveStripeSubscription(customerId: string) { + const stripe = getStripe(); + const { data: subscriptions } = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100, + }); + + return ( + selectAuthoritativeSubscription( + subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) + ) ?? null + ); +} + +/** + * Asked before opening checkout. Answered by Stripe rather than by the local mirror: the + * mirror can be stale or cleared, and a customer who slips past this ends up paying for two + * subscriptions at once. + */ +export async function findBlockingStripeSubscription(customerId: string) { + const stripe = getStripe(); + const { data: subscriptions } = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100, + }); + + return ( + subscriptions.find((subscription) => BLOCKING_STRIPE_STATUSES.has(subscription.status)) ?? null + ); +} + +export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) { + return UNPAID_STRIPE_STATUSES.has(subscription.status); +} + +/** + * Cancelling a subscription in Stripe does not stop collection on invoices that were + * already issued; they keep retrying on their own until they are paid or voided. Only + * invoices for a period the customer never paid for are voided here. + */ +export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) { + const stripe = getStripe(); + const { data: invoices } = await stripe.invoices.list({ + customer: customerId, + status: 'open', + limit: 100, + }); + + const voided: string[] = []; + + for (const invoice of invoices) { + if (!invoice.id) continue; + if (getInvoiceSubscriptionId(invoice) !== subscriptionId) continue; + + try { + await stripe.invoices.voidInvoice(invoice.id); + voided.push(invoice.id); + } catch (error) { + logError(`Failed to void Stripe invoice ${invoice.id}:`, error); + } + } + + return voided; +} + +/** + * Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices` + * is: a customer can carry an open invoice left behind by a subscription they no longer + * hold, and pointing them at that one does nothing about the retries they are seeing. + */ +export async function getOpenInvoiceForCustomer( + customerId: string, + subscriptionId?: string | null +) { + const stripe = getStripe(); + const { data: invoices } = await stripe.invoices.list({ + customer: customerId, + status: 'open', + limit: 100, + }); + + const candidates = subscriptionId + ? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId) + : invoices; + + const newest = candidates + .slice() + .sort((a, b) => (b.created ?? 0) - (a.created ?? 0)) + .at(0); + + if (!newest) { + return null; + } + + return { + id: newest.id ?? null, + hostedInvoiceUrl: newest.hosted_invoice_url ?? null, + amountDue: newest.amount_due ?? newest.total ?? 0, + currency: newest.currency ?? 'usd', + attemptCount: newest.attempt_count ?? 0, + nextPaymentAttempt: newest.next_payment_attempt + ? new Date(newest.next_payment_attempt * 1000) + : null, + }; +} diff --git a/lib/stripe.ts b/lib/stripe.ts index 8e1adda..afadb9b 100644 --- a/lib/stripe.ts +++ b/lib/stripe.ts @@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags'; let stripeClient: Stripe | null = null; +// Pinned on purpose. Without it the SDK silently follows whatever version it ships +// with, and field moves between versions (the subscription period moving onto items, +// the invoice subscription link moving under `parent`) turn into null reads instead +// of build failures. `satisfies` makes an SDK bump a compile error here first. +const STRIPE_API_VERSION = '2026-02-25.clover' satisfies Stripe.LatestApiVersion; + export function isStripeConfigured() { return isStripeBillingEnabled(); } @@ -18,7 +24,7 @@ export function getStripe() { } if (!stripeClient) { - stripeClient = new Stripe(secretKey); + stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION }); } return stripeClient; diff --git a/package.json b/package.json index e2742e1..6ff5811 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,9 @@ "r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run", "r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts", "bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run", - "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts" + "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts", + "stripe:resync:dry": "bun run scripts/resync-stripe-subscriptions.ts --dry-run", + "stripe:resync": "bun run scripts/resync-stripe-subscriptions.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts new file mode 100644 index 0000000..0a06235 --- /dev/null +++ b/scripts/resync-stripe-subscriptions.ts @@ -0,0 +1,81 @@ +/** + * Re-reads every Stripe customer's live subscription and writes it back onto the user + * through the normal sync path. + * + * Needed once after a Stripe API version change: mirrored fields that moved between + * versions stay wrong in the database until that customer happens to produce a webhook, + * which for a customer whose payment already failed may never happen on its own. + */ +import { db, disconnectDb } from '../lib/db'; +import { findLiveStripeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; +import { isStripeConfigured } from '../lib/stripe'; +import { logError } from '../lib/logger'; + +const TAG = '[resync-stripe-subscriptions]'; + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + + if (!isStripeConfigured()) { + console.log(`${TAG} Stripe is not configured, nothing to do`); + return; + } + + const users = await db.user.findMany({ + where: { stripeCustomerId: { not: null } }, + select: { id: true, email: true, stripeCustomerId: true, stripeCurrentPeriodEnd: true }, + }); + + let synced = 0; + let withoutSubscription = 0; + let failed = 0; + + for (const user of users) { + if (!user.stripeCustomerId) continue; + + try { + const subscription = await findLiveStripeSubscription(user.stripeCustomerId); + + if (!subscription) { + withoutSubscription += 1; + continue; + } + + const label = user.email ?? user.id; + + if (dryRun) { + console.log( + `${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` + ); + synced += 1; + continue; + } + + const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId); + if (updated) { + console.log( + `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` + ); + synced += 1; + } + } catch (error) { + failed += 1; + logError(`${TAG} Failed syncing ${user.email ?? user.id}:`, error); + } + } + + console.log(`${TAG} Summary${dryRun ? ' (dry run)' : ''}`); + console.log(`${TAG} Customers: ${users.length}`); + console.log(`${TAG} Synced: ${synced}`); + console.log(`${TAG} Without a live subscription: ${withoutSubscription}`); + console.log(`${TAG} Failed: ${failed}`); +} + +main() + .catch((error) => { + logError(`${TAG} Fatal error:`, error); + process.exitCode = 1; + }) + .finally(async () => { + await disconnectDb(); + }); From 85855a6d52792486810040efd0bb62fd4af64e42 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 13:51:59 +0300 Subject: [PATCH 2/6] fix(billing): close the gaps the code and security reviews found Follow-up on the same change, from a high-effort code review and security review run over the diff. Access gate: - Scope both period-end guards to the period-end branch of hasBillingAccess instead of the top of the function. A cutoff is only ever cleared by a Stripe sync, so checking it first meant a stale one from a lapsed subscription outranked a freshly started cardless trial: the account burned its once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors the same shape. - Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED subscription, the rejection isPaidTier already makes. The cutoff is deliberately left null while a trial is live, so a trial user who abandoned a checkout kept the failed subscription's period once the trial ran out. - Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period where hasBillingAccess says access is over. That split left a locked-out account with no banner explaining it and able to create workspaces it could not then see. Both callers now select the field. Lifecycle: - Cancel through syncStripeCustomerSubscriptions rather than writing the single cancelled subscription, so a customer holding a second live subscription is not locked out of an account they are still being billed for. - Ignore invoice events with no subscription. A one-off invoice against a customer record left by an abandoned checkout was marking the account canceled and booking a churn event for a subscription that never existed. - Fall back to a window measured from now when a subscription behind on payment reports no period start, rather than falling through to "access ended", which locked out the customer that branch exists to keep in. - Let a paused subscription run to its period end; it was being ended at once. - Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include incomplete. The two sets were identical, which offered a Cancel button that always returned "No subscription to cancel" and left the Stripe-side checkout guard weaker than the mirror check it backs up. UI and ops: - cancelIsImmediate from the API, so the confirmation says what will actually happen to an incomplete subscription instead of promising the period end. - The access banner reads "ended on" once the date has passed. - The resync script selects the way the write path selects, over the customer's whole set. Filtering to live subscriptions first made the dry run disagree with the real run and skipped canceled and incomplete customers entirely, who are exactly the stale mirrors the script exists for. Three existing tests asserted the behaviour this fixes: that a canceled subscription keeps access to its reported period end, and that the cutoff is ignored while that period runs. Both rest on the premise that a future period end means a paid period, which is what is not true. They now assert the bound, alongside new cases for the retry window, the trial-versus-stale-cutoff ordering, and a never-paid period. --- .../settings/settings-page-client.tsx | 8 +- app/api/billing/cancel/route.ts | 7 +- app/api/billing/route.ts | 5 + app/api/projects/route.ts | 6 +- app/api/stripe/webhook/route.ts | 8 +- lib/billing.ts | 125 +++++++++++------- lib/storage-quota.ts | 2 +- scripts/resync-stripe-subscriptions.ts | 21 ++- tests/unit/lib/billing.test.ts | 101 ++++++++++++-- 9 files changed, 205 insertions(+), 78 deletions(-) diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index e052a97..c3a6383 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -81,6 +81,7 @@ interface BillingOverview { checkoutAvailable: boolean; portalAvailable: boolean; cancelAvailable: boolean; + cancelIsImmediate: boolean; needsPaymentFix: boolean; openInvoice: { id: string | null; @@ -581,8 +582,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo

{billing.subscription.billingAccessEndedAt ? (

- Access to your workspaces continues until{' '} - {new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. + {new Date(billing.subscription.billingAccessEndedAt) > new Date() + ? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.` + : `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}

) : null} {billing.openInvoice.hostedInvoiceUrl ? ( @@ -673,7 +675,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo Cancel your subscription? - {billing.needsPaymentFix + {billing.cancelIsImmediate ? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.' : 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'} diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts index f8e08ae..a1b6a05 100644 --- a/app/api/billing/cancel/route.ts +++ b/app/api/billing/cancel/route.ts @@ -5,7 +5,7 @@ import { findLiveStripeSubscription, getBillingOverview, isUnpaidStripeSubscription, - syncStripeSubscriptionToUser, + syncStripeCustomerSubscriptions, voidOpenSubscriptionInvoices, } from '@/lib/billing'; import { rateLimit } from '@/lib/rate-limit'; @@ -62,7 +62,10 @@ export async function POST(request: NextRequest) { ? await voidOpenSubscriptionInvoices(customerId, subscription.id) : []; - await syncStripeSubscriptionToUser(canceled); + // Re-derived from the customer's whole set rather than written from `canceled` alone. + // A customer can hold more than one subscription, and mirroring just the one that was + // cancelled would lock out an account still being billed on another. + await syncStripeCustomerSubscriptions(customerId); const response = successResponse({ canceledImmediately: unpaid, diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index b85cae1..5c49907 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -51,6 +51,11 @@ export async function GET() { !billing.subscription.cancelAt && !billing.subscription.cancelAtPeriodEnd, needsPaymentFix, + // Whether cancelling ends the subscription there and then rather than at the period + // end, which is what the confirmation copy has to say. Mirrors the branch the cancel + // route takes: nothing was paid for the open period, so there is nothing to run out. + cancelIsImmediate: + needsPaymentFix || billing.subscription.status === BillingSubscriptionStatus.INCOMPLETE, openInvoice: openInvoice ? { id: openInvoice.id, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 746d55a..180ddca 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -167,7 +167,11 @@ export async function POST(request: NextRequest) { // owner too. A workspace admin on somebody else's trial hits the same ceiling. const owner = await db.user.findUnique({ where: { id: workspace.ownerId }, - select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, + select: { + subscriptionStatus: true, + stripeCurrentPeriodEnd: true, + billingAccessEndedAt: true, + }, }); if (owner && !isPaidTier(owner)) { diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 52dbb4a..9815204 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import type Stripe from 'stripe'; -import { syncStripeCustomerSubscriptions } from '@/lib/billing'; +import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing'; import { getStripe, getStripeWebhookSecret } from '@/lib/stripe'; import { logError } from '@/lib/logger'; @@ -65,7 +65,11 @@ export async function POST(request: NextRequest) { case 'invoice.marked_uncollectible': { const invoice = event.data.object as Stripe.Invoice; const customerId = getCustomerId(invoice.customer); - if (customerId) { + // Only subscription invoices. A one-off invoice against a customer record left + // behind by an abandoned checkout has no subscription, and syncing on it would + // find an empty list, mark the account canceled and book a churn event for a + // subscription that never existed. + if (customerId && getInvoiceSubscriptionId(invoice)) { await syncStripeCustomerSubscriptions(customerId); } break; diff --git a/lib/billing.ts b/lib/billing.ts index 6eaa502..2807bb4 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -36,26 +36,17 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.INCOMPLETE_EXPIRED, ]); -// Stripe-side counterparts of the sets above, used where a raw Stripe subscription is in -// hand rather than the mirrored status. - -// A customer holding one of these must not be sent through checkout again; they belong in -// the billing portal. `incomplete` is deliberately absent: an abandoned first payment -// leaves one behind for about a day, and blocking on it would lock the customer out of -// checkout with nothing to fix in the portal. -const BLOCKING_STRIPE_STATUSES = new Set([ - 'active', - 'trialing', - 'past_due', - 'unpaid', -]); - -// Subscriptions still worth mirroring onto the user. +// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that +// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same +// membership: a subscription worth cancelling is a subscription worth blocking a second +// checkout over, and two sets that disagreed only produced a Cancel button that always +// failed and a checkout guard weaker than the mirror it was backing up. const LIVE_STRIPE_STATUSES = new Set([ 'active', 'trialing', 'past_due', 'unpaid', + 'incomplete', ]); // Cancelling one of these takes effect immediately: the open period was never paid for, @@ -135,7 +126,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n * A legacy Stripe trial counts as paid because a card was handed over for it. */ export function isPaidTier( - subject: Pick, + subject: Pick< + BillingAccessSubject, + 'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt' + >, now: Date = new Date() ) { if (!isStripeFeatureEnabled()) { @@ -146,14 +140,19 @@ export function isPaidTier( return true; } - // The period end alone is not proof of payment. Checked here and not in - // `hasBillingAccess`, which keeps granting access on a period end it did not - // question before: the cost of being wrong there is a customer locked out, - // while the cost of being wrong here is a free account holding 200 GB. + // The period end alone is not proof of payment. if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { return false; } + // Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer + // behind on payment. They did once: access stopped at the end of Stripe's retry window + // while this kept saying "paid" for the rest of the period, which left the account with + // no banner explaining the lockout and able to create workspaces it could not then see. + if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { + return false; + } + return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); @@ -164,16 +163,6 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new return true; } - // A recorded end date is a hard stop, checked before anything else. Stripe advances the - // billing period the moment it issues the renewal invoice, paid or not, and the period - // survives cancellation, so the period end below would otherwise hand a full free month - // to anyone whose renewal fails. `isPaidTier` guards the storage tier against the same - // thing; this guards access itself, without the lockout that worried it: a subscription - // behind on payment is stamped with the end of Stripe's retry window, not with today. - if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { - return false; - } - if (hasActiveSubscription(subject.subscriptionStatus)) { return true; } @@ -182,6 +171,26 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new return true; } + // Everything below decides whether the reported period still stands in for access, and + // the two guards exist because it very often does not. Both are scoped to this branch + // rather than applied at the top of the function: `billingAccessEndedAt` is only ever + // cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise + // outrank a freshly started cardless trial and burn the account's one trial for nothing. + + // Stripe stamps a period on a subscription whose first charge never went through, so + // that period is not evidence of payment. The same rejection `isPaidTier` makes. + if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { + return false; + } + + // Stripe advances the period the moment it issues the renewal invoice, paid or not, and + // the period survives cancellation, so on its own it would hand a full free month to + // anyone whose renewal fails. This is the bound: a subscription behind on payment is + // stamped with the end of Stripe's retry window, a cancelled one with `ended_at`. + if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { + return false; + } + return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); @@ -211,23 +220,21 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use return {}; } - // Mirrors `hasBillingAccess`, including its hard stop, so the query and the in-memory - // check cannot disagree about who still has access. + // Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its + // period-end arm, so the query and the in-memory check cannot disagree about who still + // has access. return { - AND: [ + OR: [ { - OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], + subscriptionStatus: { + in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], + }, }, + { trialEndsAt: { gt: now } }, { - OR: [ - { - subscriptionStatus: { - in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], - }, - }, - { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, - ], + stripeCurrentPeriodEnd: { gt: now }, + subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], }, ], }; @@ -866,13 +873,23 @@ function getInactiveBillingAccessEndedAt( } // Still running, just behind on payment: access ends when Stripe gives up retrying, not - // at the period end, which Stripe already advanced to cover the unpaid invoice. + // at the period end, which Stripe already advanced to cover the unpaid invoice. The + // period start is when that invoice was issued, so it is what the window runs from; when + // it is missing (a paginated item list, an older payload shape) the window runs from now + // instead. Falling through to "ended" here would lock out the customer this branch + // exists to keep in, which is the wrong way to fail on missing data. if (RETRYING_STRIPE_STATUSES.has(subscription.status)) { + const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60; const periodStart = getSubscriptionPeriodStart(subscription); - if (periodStart) { - const graceEnd = periodStart + UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60; - return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); - } + const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace; + + return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); + } + + // A pause is not a non-payment: the period behind it was paid for, so it runs out + // normally. Stripe's portal pauses keep the status `active`, but the API can set this. + if (subscription.status === 'paused' && currentPeriodEnd) { + return new Date(currentPeriodEnd * 1000); } // Anything else that gets here never paid for the period Stripe is reporting, so that @@ -1156,7 +1173,7 @@ export async function findBlockingStripeSubscription(customerId: string) { }); return ( - subscriptions.find((subscription) => BLOCKING_STRIPE_STATUSES.has(subscription.status)) ?? null + subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null ); } @@ -1166,8 +1183,14 @@ export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) { /** * Cancelling a subscription in Stripe does not stop collection on invoices that were - * already issued; they keep retrying on their own until they are paid or voided. Only - * invoices for a period the customer never paid for are voided here. + * already issued; they keep retrying on their own until they are paid or voided. Voiding + * them is what actually stops the card being charged after someone has cancelled. + * + * Note this writes off a real receivable, not only an unserved one: a `past_due` customer + * has had access for up to `UNPAID_ACCESS_GRACE_DAYS` before they get here. That is a + * deliberate trade, on the grounds that chasing a single month of a small subscription + * costs more than it recovers and that the customer is leaving anyway. `markUncollectible` + * is the one-line change if the receivable should be kept on the books instead. */ export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) { const stripe = getStripe(); diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index f9bbba3..64438fe 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -43,7 +43,7 @@ export interface StorageContext { export async function getStorageContextForUser(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, - select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, + select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true }, }); const isPaid = user ? isPaidTier(user) : false; diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts index 0a06235..bec7f00 100644 --- a/scripts/resync-stripe-subscriptions.ts +++ b/scripts/resync-stripe-subscriptions.ts @@ -7,8 +7,8 @@ * which for a customer whose payment already failed may never happen on its own. */ import { db, disconnectDb } from '../lib/db'; -import { findLiveStripeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; -import { isStripeConfigured } from '../lib/stripe'; +import { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; +import { getStripe, isStripeConfigured } from '../lib/stripe'; import { logError } from '../lib/logger'; const TAG = '[resync-stripe-subscriptions]'; @@ -34,15 +34,24 @@ async function main() { if (!user.stripeCustomerId) continue; try { - const subscription = await findLiveStripeSubscription(user.stripeCustomerId); + const label = user.email ?? user.id; + + // Selected exactly the way the write path selects, over the customer's whole set + // rather than the live ones only. A mirror left wrong by the version change is most + // likely on a customer whose subscription is already canceled or incomplete, which + // is precisely who a live-only filter would skip. + const { data: subscriptions } = await getStripe().subscriptions.list({ + customer: user.stripeCustomerId, + status: 'all', + limit: 100, + }); + const subscription = selectAuthoritativeSubscription(subscriptions); if (!subscription) { withoutSubscription += 1; continue; } - const label = user.email ?? user.id; - if (dryRun) { console.log( `${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` @@ -54,7 +63,7 @@ async function main() { const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId); if (updated) { console.log( - `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` + `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}, access ends ${updated.billingAccessEndedAt?.toISOString() ?? 'null'}` ); synced += 1; } diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index ddaec7d..432faf6 100644 --- a/tests/unit/lib/billing.test.ts +++ b/tests/unit/lib/billing.test.ts @@ -152,7 +152,11 @@ describe('isPaidTier', () => { it('counts an active subscription as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.ACTIVE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -163,7 +167,11 @@ describe('isPaidTier', () => { it('counts a Stripe trial as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.TRIALING, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -175,6 +183,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -185,7 +194,11 @@ describe('isPaidTier', () => { it('does not count a cardless trial as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.FREE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(false); @@ -200,6 +213,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -212,6 +226,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -227,6 +242,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -239,6 +255,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -250,7 +267,11 @@ describe('isPaidTier', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.FREE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -366,7 +387,12 @@ describe('hasBillingAccess', () => { ).toBe(true); }); - it('ignores billingAccessEndedAt while the paid period is still running', () => { + // Was the opposite assertion, on the premise that a future period end means a paid + // period. It does not: Stripe advances the period when it issues the renewal invoice, + // paid or not, and the period survives cancellation, so this exact shape (cutoff in the + // past, period end in the future) is what a subscription cancelled while behind on + // payment looks like. Honouring the period here handed out a free month. + it('honours billingAccessEndedAt even while the reported period is still running', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'CANCELED', @@ -375,9 +401,36 @@ describe('hasBillingAccess', () => { }), NOW ); + expect(result).toBe(false); + }); + + // The other half of that: a stale cutoff must not outrank a live trial, or starting a + // cardless trial on a lapsed account would consume the account's one trial and grant + // nothing, since only a Stripe sync ever clears the cutoff. + it('lets an unexpired trial win over a cutoff already in the past', () => { + const result = hasBillingAccess( + subject({ + subscriptionStatus: 'CANCELED', + trialEndsAt: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS), + }), + NOW + ); expect(result).toBe(true); }); + // Stripe stamps a period on a subscription whose first charge never went through. + it('refuses a period end carried by a subscription that never paid', () => { + const result = hasBillingAccess( + subject({ + subscriptionStatus: 'INCOMPLETE_EXPIRED', + stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + }), + NOW + ); + expect(result).toBe(false); + }); + it('grants access to everyone when Stripe is disabled', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); const result = hasBillingAccess( @@ -452,7 +505,14 @@ describe('buildBillingAccessWhereInput', () => { OR: [ { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, { trialEndsAt: { gt: NOW } }, - { stripeCurrentPeriodEnd: { gt: NOW } }, + // Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end + // is only evidence of access when a payment stands behind it and no cutoff has + // passed. Scoped to this arm, not the whole query, so a live trial still wins. + { + stripeCurrentPeriodEnd: { gt: NOW }, + subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }], + }, ], }); }); @@ -1451,31 +1511,48 @@ describe('database backed billing helpers', () => { expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date); }); - it('keeps access while a canceled subscription is still inside its paid period', async () => { + // Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription + // keeps access to the reported period end. That is only right if the period was paid + // for, and a canceled subscription cannot tell you that it was: the period Stripe + // reports advances when the renewal invoice is issued and survives the cancellation, + // so this is also exactly the shape of "cancelled while behind on payment". A cutoff + // is stamped instead, and `ended_at` is what it comes from. + it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); + const endedAt = Math.floor(NOW.getTime() / 1000); await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', + ended_at: endedAt, current_period_end: Math.floor(NOW.getTime() / 1000) + 3600, }) ); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, - billingAccessEndedAt: null, }); + expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000); }); - it('ends access at the period end once the paid period has passed', async () => { + // Behind on payment but still being retried: access runs to the end of Stripe's retry + // window, measured from the period start, not to the period end Stripe advanced to + // cover the invoice that was never paid. + it('bounds a past_due subscription to the retry window', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); - const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600; + const periodStart = Math.floor(NOW.getTime() / 1000); await syncStripeSubscriptionToUser( - stripeSub({ status: 'canceled', current_period_end: periodEnd }) + stripeSub({ + status: 'past_due', + current_period_start: periodStart, + current_period_end: periodStart + 30 * 24 * 60 * 60, + }) ); - expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000); + expect((updateData().billingAccessEndedAt as Date).getTime()).toBe( + (periodStart + 14 * 24 * 60 * 60) * 1000 + ); }); it('falls back to ended_at when there is no period end', async () => { From d5cb288719d3c3095cc272d9852954187c68e2f3 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 13:58:54 +0300 Subject: [PATCH 3/6] test(api): register the billing cancel route in the auth matrix The api suite enumerates every route module under app/api and requires each one to be classified as session-guarded or deliberately public. The new cancel route was neither, so the suite failed on an unclassified module and on the module count. It takes the same shape as the other billing routes: a session plus a same-origin header. --- tests/api/auth-matrix.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts index 3ecb072..91d9ffd 100644 --- a/tests/api/auth-matrix.test.ts +++ b/tests/api/auth-matrix.test.ts @@ -48,6 +48,7 @@ import * as adminGrowthRoute from '@/app/api/admin/growth/route'; import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route'; import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route'; import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route'; +import * as billingCancelRoute from '@/app/api/billing/cancel/route'; import * as billingCheckoutRoute from '@/app/api/billing/checkout/route'; import * as billingPortalRoute from '@/app/api/billing/portal/route'; import * as billingTrialRoute from '@/app/api/billing/trial/route'; @@ -149,7 +150,7 @@ vi.mock('@/lib/r2', async (importOriginal) => { // The count guard // --------------------------------------------------------------------------- // Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. -const EXPECTED_ROUTE_MODULE_COUNT = 67; +const EXPECTED_ROUTE_MODULE_COUNT = 68; /** * Routes that are public by design, and why. Everything else must reject an @@ -405,6 +406,12 @@ const ROUTE_CASES: readonly RouteCase[] = [ params: (f) => ({ requestId: f.approvalRequestId }), body: { decision: 'APPROVED' }, }, + { + file: 'billing/cancel/route.ts', + module: billingCancelRoute, + url: () => '/api/billing/cancel', + headers: { origin: 'http://localhost:3000' }, + }, { file: 'billing/checkout/route.ts', module: billingCheckoutRoute, From c0809e23bd489bd3ff6093e0cee91b03dc6e45e9 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 14:07:48 +0300 Subject: [PATCH 4/6] test(billing): cover the Stripe field locations this change depends on Every subscription fixture in the suite carries current_period_end at the top level, which is the location the pinned API version no longer uses. So the item level read, the reason this code exists, had no test at all and every other case passed through the legacy fallback instead. Covers both locations for the period and for the invoice's subscription link, the null case the webhook relies on to leave a one-off invoice alone, and the retry-window bound through the payload shape production actually sends. --- tests/unit/lib/billing.test.ts | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index 432faf6..6f1b4db 100644 --- a/tests/unit/lib/billing.test.ts +++ b/tests/unit/lib/billing.test.ts @@ -15,6 +15,9 @@ import { getOrCreateStripeCustomerId, getStorageCleanupEligibleAt, getStripeCheckoutState, + getInvoiceSubscriptionId, + getSubscriptionPeriodEnd, + getSubscriptionPeriodStart, getTrialNotice, getWorkspaceCreationEligibility, hasActiveSubscription, @@ -868,6 +871,88 @@ function updateData(): Record { return dbMock.user.update.mock.calls[0][0].data as Record; } +// The shape Stripe actually sends on the pinned API version: the period lives on the +// subscription's items, not on the subscription. `stripeSub` above still uses the older +// top-level shape, so without these the whole reason this code exists goes untested and +// every other test in this file passes through the legacy fallback instead. +describe('Stripe field locations', () => { + const periodStart = 1_800_000_000; + const periodEnd = periodStart + 30 * 86_400; + + function itemPeriodSub(overrides: Record = {}) { + return { + id: 'sub_1', + customer: 'cus_1', + status: 'past_due', + items: { + data: [ + { + price: { id: ENTITLED_PRICE }, + current_period_start: periodStart, + current_period_end: periodEnd, + }, + ], + }, + ...overrides, + } as unknown as Stripe.Subscription; + } + + it('reads the period off the subscription items', () => { + expect(getSubscriptionPeriodEnd(itemPeriodSub())).toBe(periodEnd); + expect(getSubscriptionPeriodStart(itemPeriodSub())).toBe(periodStart); + }); + + // A webhook body can still be rendered at the version that was current when the + // endpoint was created, so the old location has to keep working. + it('falls back to the legacy top-level period', () => { + const legacy = { + items: { data: [{ price: { id: ENTITLED_PRICE } }] }, + current_period_start: periodStart, + current_period_end: periodEnd, + } as unknown as Stripe.Subscription; + + expect(getSubscriptionPeriodEnd(legacy)).toBe(periodEnd); + expect(getSubscriptionPeriodStart(legacy)).toBe(periodStart); + }); + + it('returns null when neither location carries a period', () => { + const bare = { + items: { data: [{ price: { id: ENTITLED_PRICE } }] }, + } as unknown as Stripe.Subscription; + + expect(getSubscriptionPeriodEnd(bare)).toBeNull(); + expect(getSubscriptionPeriodStart(bare)).toBeNull(); + }); + + it('reads the invoice subscription off parent.subscription_details', () => { + const invoice = { + parent: { subscription_details: { subscription: 'sub_9' } }, + } as unknown as Stripe.Invoice; + + expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9'); + }); + + it('accepts an expanded subscription object on the invoice parent', () => { + const invoice = { + parent: { subscription_details: { subscription: { id: 'sub_9' } } }, + } as unknown as Stripe.Invoice; + + expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9'); + }); + + it('falls back to the legacy top-level invoice subscription', () => { + expect(getInvoiceSubscriptionId({ subscription: 'sub_9' } as unknown as Stripe.Invoice)).toBe( + 'sub_9' + ); + }); + + // A one-off invoice belongs to no subscription, and the webhook relies on this to leave + // the account alone rather than marking it canceled. + it('returns null for an invoice with no subscription', () => { + expect(getInvoiceSubscriptionId({} as unknown as Stripe.Invoice)).toBeNull(); + }); +}); + describe('database backed billing helpers', () => { beforeEach(() => { vi.useFakeTimers(); @@ -1555,6 +1640,35 @@ describe('database backed billing helpers', () => { ); }); + // The same thing through the payload shape production actually sends, where the period + // sits on the items rather than on the subscription. Every other fixture in this file + // uses the older top-level shape and so never exercises the read this change is for. + it('bounds a past_due subscription whose period is on its items', async () => { + dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); + const periodStart = Math.floor(NOW.getTime() / 1000); + const periodEnd = periodStart + 30 * 24 * 60 * 60; + + await syncStripeSubscriptionToUser({ + id: 'sub_1', + customer: 'cus_1', + status: 'past_due', + items: { + data: [ + { + price: { id: ENTITLED_PRICE }, + current_period_start: periodStart, + current_period_end: periodEnd, + }, + ], + }, + } as unknown as Stripe.Subscription); + + expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000); + expect((updateData().billingAccessEndedAt as Date).getTime()).toBe( + (periodStart + 14 * 24 * 60 * 60) * 1000 + ); + }); + it('falls back to ended_at when there is no period end', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const endedAt = Math.floor(NOW.getTime() / 1000) - 7200; From 2c890314c1622f4104231e38bac5f575d34f9e75 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 15:17:19 +0300 Subject: [PATCH 5/6] fix(billing): show paid cancellation dates instead of leftover trial --- .../settings/settings-page-client.tsx | 8 +-- tests/component/settings-billing.test.tsx | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/component/settings-billing.test.tsx diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index c3a6383..149ae91 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -492,7 +492,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo

{billing.subscription.hasActiveSubscription ? hasScheduledCancellation - ? billing.subscription.hasActiveTrial + ? billing.subscription.status === 'TRIALING' ? 'Trial canceled. Access remains active until the trial ends.' : 'Subscription canceled. Access remains active until the end of the current billing period.' : 'Paid account with workspace creation unlocked.' @@ -512,11 +512,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo !billing.subscription.hasActiveSubscription ? (

Your latest payment didn't go through. Update your payment method to keep - your subscription — starting a new one would create a duplicate. + your subscription. Starting a new one would create a duplicate.

) : null} - {billing.subscription.hasActiveTrial && + {billing.subscription.status === 'TRIALING' && billing.subscription.trialEndsAt && hasScheduledCancellation ? (

@@ -535,7 +535,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo {hasScheduledCancellation && billing.subscription.cancelAt ? (

- Cancellation was scheduled on{' '} + Cancellation takes effect on{' '} {new Date(billing.subscription.cancelAt).toLocaleDateString()}.

) : null} diff --git a/tests/component/settings-billing.test.tsx b/tests/component/settings-billing.test.tsx new file mode 100644 index 0000000..4f1c4a9 --- /dev/null +++ b/tests/component/settings-billing.test.tsx @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SettingsPage from '@/app/(dashboard)/settings/settings-page-client'; + +function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url !== '/api/billing') return { ok: false }; + return { + ok: true, + json: async () => ({ + data: { + isEnabled: true, + isConfigured: true, + checkoutAvailable: false, + portalAvailable: true, + cancelAvailable: false, + needsPaymentFix: false, + openInvoice: null, + workspaceCreation: { canCreateWorkspace: true, canStartTrial: false }, + subscription: { + status, + label: status === 'ACTIVE' ? 'Active' : 'Trialing', + hasActiveSubscription: true, + hasRecoverableSubscription: true, + hasActiveTrial: true, + hasBillingAccess: true, + currentPeriodEnd: '2026-10-08T12:00:00Z', + trialEndsAt: '2026-09-15T12:00:00Z', + cancelAtPeriodEnd: true, + cancelAt: '2026-10-08T12:00:00Z', + }, + }, + }), + }; + }) + ); + render(); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('scheduled cancellation in billing settings', () => { + it('keeps a paid subscription distinct from its remaining cardless trial', async () => { + renderScheduledCancellation('ACTIVE'); + + expect( + await screen.findByText( + 'Subscription canceled. Access remains active until the end of the current billing period.' + ) + ).toBeInTheDocument(); + expect(screen.queryByText(/Trial canceled/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Access ends on/)).not.toBeInTheDocument(); + expect(screen.getByText(/Your subscription ends on/)).toHaveTextContent( + new Date('2026-10-08T12:00:00Z').toLocaleDateString() + ); + expect(screen.getByText(/Cancellation takes effect on/)).toBeInTheDocument(); + expect(screen.queryByText(/Cancellation was scheduled on/)).not.toBeInTheDocument(); + }); + + it('still explains the trial end for a Stripe trial subscription', async () => { + renderScheduledCancellation('TRIALING'); + + expect( + await screen.findByText('Trial canceled. Access remains active until the trial ends.') + ).toBeInTheDocument(); + expect(screen.getByText(/Access ends on/)).toHaveTextContent( + new Date('2026-09-15T12:00:00Z').toLocaleDateString() + ); + }); +}); From 1c24336b6af7a7dabf9a6d7330a35cfc88b31994 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 16:32:11 +0300 Subject: [PATCH 6/6] fix(billing): serialize reconciliation and record accepted cancellations --- .../settings/settings-page-client.tsx | 11 +- .../admin/cancellation-reasons-card.tsx | 4 +- lib/analytics/billing-events.ts | 31 +- lib/billing.ts | 84 +++-- lib/cancellation.ts | 9 + tests/api/billing-cancel.test.ts | 147 +++++++- tests/api/billing-sync-concurrency.test.ts | 339 ++++++++++++++++++ tests/component/settings-currency.test.tsx | 87 +++++ tests/unit/lib/billing.test.ts | 6 + 9 files changed, 676 insertions(+), 42 deletions(-) create mode 100644 tests/api/billing-sync-concurrency.test.ts create mode 100644 tests/component/settings-currency.test.tsx diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 96cf3ff..0a6234f 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -33,11 +33,7 @@ import { cn } from '@/lib/utils'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import type { CancellationReason } from '@/lib/cancellation-reasons'; -/** - * Stripe reports amounts in the currency's smallest unit, and how many of those make a - * whole unit differs per currency: two for USD, none for JPY. The formatter knows the - * exponent, so it decides the divisor instead of a hardcoded 100. - */ +/** Convert Stripe API units separately from the currency's display precision. */ function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { const currencyCode = currency.toUpperCase(); @@ -47,7 +43,10 @@ function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { currency: currencyCode, }); const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2; - return formatter.format(amountInMinorUnits / 10 ** fractionDigits); + // Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display. + // https://docs.stripe.com/currencies#special-cases + const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits; + return formatter.format(amountInMinorUnits / 10 ** apiExponent); } catch { return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`; } diff --git a/components/admin/cancellation-reasons-card.tsx b/components/admin/cancellation-reasons-card.tsx index 7b77966..1978beb 100644 --- a/components/admin/cancellation-reasons-card.tsx +++ b/components/admin/cancellation-reasons-card.tsx @@ -78,7 +78,9 @@ export async function CancellationReasonsCard() { {format(row.createdAt, 'MMM dd, yyyy')} - {row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''} + {row.periodEnd + ? ` · billing period ends ${format(row.periodEnd, 'MMM dd')}` + : ''}

{getCancellationReasonLabel(row.reason)}

diff --git a/lib/analytics/billing-events.ts b/lib/analytics/billing-events.ts index e7fc5f9..b5b9435 100644 --- a/lib/analytics/billing-events.ts +++ b/lib/analytics/billing-events.ts @@ -1,11 +1,7 @@ -// Turning Stripe state into funnel events. -// -// These four events are derived from a before/after comparison inside the sync -// that already re-reads every subscription a customer has, rather than from the -// webhook event types. That is deliberate: webhooks arrive out of order and get -// replayed, and `customer.subscription.updated` fires for changes that mean -// nothing here. Comparing the row we are about to overwrite with the row we are -// writing is order-independent, and the dedupe keys make a replay a no-op. +// Turning Stripe state and accepted cancellations into funnel events. +// Sync compares before/after state; in-app cancellation also records acceptance +// because its local claim can hide that transition. Shared cycle keys make both +// paths and replayed webhooks count the same cancellation once. import type { BillingSubscriptionStatus } from '@prisma/client'; import { eventKey, recordEvent } from '@/lib/analytics/record'; @@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string { return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0); } +/** Shared by accepted in-app cancellations and sync; recordEvent logs write failures. */ +export async function recordSubscriptionCancellation(params: { + userId: string; + subscriptionId: string; + currentPeriodEnd: Date | null; +}): Promise { + await recordEvent({ + name: 'SUBSCRIPTION_CANCELED', + dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`, + userId: params.userId, + }); +} + export async function recordSubscriptionTransition(params: { userId: string; subscriptionId: string; @@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: { const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd; const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED'; if (startedCanceling || becameCanceled) { - await recordEvent({ - name: 'SUBSCRIPTION_CANCELED', - dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`, + await recordSubscriptionCancellation({ userId, + subscriptionId, + currentPeriodEnd: after.currentPeriodEnd, }); } diff --git a/lib/billing.ts b/lib/billing.ts index 1183abb..4c06923 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -911,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId: } export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) { + return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db)); +} + +async function writeStripeSubscriptionToUser( + subscription: Stripe.Subscription, + client: Prisma.TransactionClient +) { const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; - const user = await db.user.findUnique({ + const user = await client.user.findUnique({ where: { stripeCustomerId: customerId }, select: { id: true, @@ -964,7 +971,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip // which is cleared again as soon as the subscription goes back to active. const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus); - const updated = await db.user.update({ + const updated = await client.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, @@ -986,7 +993,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip }, }); - await recordSubscriptionTransition({ + const transition: Parameters[0] = { userId: user.id, subscriptionId: subscription.id, before: { @@ -1000,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip trialEndsAt: preservedTrialEnd, currentPeriodEnd: effectiveCurrentPeriodEnd, }, - }); + }; - return updated; + return { updated, transition }; +} + +async function recordSyncedSubscription( + result: Awaited> +) { + if (!result) return null; + // Analytics uses its own connection. Run it after commit, not while a billing + // transaction holds a connection and other syncs are queued on its advisory lock. + await recordSubscriptionTransition(result.transition); + return result.updated; } // A single Stripe customer can own several subscriptions at once (e.g. after @@ -1055,28 +1072,49 @@ export function selectAuthoritativeSubscription( // Source-of-truth sync: instead of trusting a single subscription from a webhook // event body (which may be an OLD subscription being deleted while a NEWER one is // active), re-list ALL of the customer's subscriptions from Stripe and sync the -// authoritative one. This is order-independent and self-healing. +// authoritative one. The customer lock covers the Stripe read as well as the mirror +// write: locking only after the read would still let a delayed older response win. export async function syncStripeCustomerSubscriptions(customerId: string) { - const stripe = getStripe(); - const { data: subscriptions } = await stripe.subscriptions.list({ - customer: customerId, - status: 'all', - limit: 100, - }); + const result = await db.$transaction( + async (tx) => { + // Two-key advisory locks occupy a separate namespace from the one-key + // cancellation locks. Cancellation releases its lock before calling sync. + await tx.$executeRaw` + SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId})) + `; + const { data: subscriptions } = await getStripe().subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100, + }); - const authoritative = selectAuthoritativeSubscription(subscriptions); - if (!authoritative) { - return markSubscriptionCanceledByCustomerId(customerId); - } - - return syncStripeSubscriptionToUser(authoritative); + const authoritative = selectAuthoritativeSubscription(subscriptions); + return authoritative + ? writeStripeSubscriptionToUser(authoritative, tx) + : writeSubscriptionCanceledByCustomerId(customerId, undefined, tx); + }, + // Bound lock and connection occupancy. A slow Stripe call or lock wait fails + // this sync; writes through the expired transaction cannot overwrite a newer sync. + { maxWait: 10_000, timeout: 30_000 } + ); + return recordSyncedSubscription(result); } export async function markSubscriptionCanceledByCustomerId( customerId: string, options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null } ) { - const user = await db.user.findUnique({ + return recordSyncedSubscription( + await writeSubscriptionCanceledByCustomerId(customerId, options, db) + ); +} + +async function writeSubscriptionCanceledByCustomerId( + customerId: string, + options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined, + client: Prisma.TransactionClient +) { + const user = await client.user.findUnique({ where: { stripeCustomerId: customerId }, select: { id: true, @@ -1098,7 +1136,7 @@ export async function markSubscriptionCanceledByCustomerId( // date, which is also what the cancellation copy in settings promises. const preservedTrialEnd = user.trialEndsAt ?? null; - const updated = await db.user.update({ + const updated = await client.user.update({ where: { id: user.id }, data: { subscriptionStatus: BillingSubscriptionStatus.CANCELED, @@ -1116,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId( // uses the period end being cleared here, which is the same one the earlier // "cancel at period end" write carried, so a customer who cancelled through the // portal and then reached the end of their term produces one cancellation, not two. - await recordSubscriptionTransition({ + const transition: Parameters[0] = { userId: user.id, subscriptionId: user.stripeSubscriptionId ?? user.id, before: { @@ -1130,9 +1168,9 @@ export async function markSubscriptionCanceledByCustomerId( trialEndsAt: preservedTrialEnd, currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null, }, - }); + }; - return updated; + return { updated, transition }; } /** diff --git a/lib/cancellation.ts b/lib/cancellation.ts index ecd8d3a..b3cc4bd 100644 --- a/lib/cancellation.ts +++ b/lib/cancellation.ts @@ -10,6 +10,7 @@ import { voidOpenSubscriptionInvoices, } from '@/lib/billing'; import { logError } from '@/lib/logger'; +import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events'; export { CANCELLATION_NOTE_MAX_LENGTH, @@ -175,6 +176,14 @@ export async function cancelSubscription(params: { const periodEndUnix = getSubscriptionPeriodEnd(original); const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd; + // The paid claim already set the local flag, and another subscription may + // drive customer sync. Record acceptance directly with the same cycle key. + await recordSubscriptionCancellation({ + userId: params.userId, + subscriptionId, + currentPeriodEnd: periodEnd, + }); + await db.$transaction(async (tx) => { // The paid mirror's claim does not cover other subscriptions. Serialize every // reason write and reuse only a row written during this request, so a resumed diff --git a/tests/api/billing-cancel.test.ts b/tests/api/billing-cancel.test.ts index 0884198..1a1e42a 100644 --- a/tests/api/billing-cancel.test.ts +++ b/tests/api/billing-cancel.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type Stripe from 'stripe'; import { BillingSubscriptionStatus, type User } from '@prisma/client'; import { db } from '@/lib/db'; import { getStripe } from '@/lib/stripe'; +import { syncStripeCustomerSubscriptions } from '@/lib/billing'; import { POST as cancelRoute } from '@/app/api/billing/cancel/route'; import { GET as billingRoute } from '@/app/api/billing/route'; import { apiRequest, callRoute, readData, readError } from '../helpers/request'; @@ -677,3 +678,147 @@ describe('repeated and concurrent cancellation reasons', () => { } }); }); + +describe('cancellation analytics through the route', () => { + beforeEach(() => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + }); + + it.each(['canceled subscription', 'empty customer'] as const)( + 'records paid cancellation before sync and deduplicates %s deletion in the same cycle', + async (deletion) => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user); + const stripe = stubStripe([original]); + const response = await callRoute( + cancelRoute, + cancelRequest({ reason: 'OTHER', note: 'Leaving after this project' }) + ); + expect(response.status).toBe(200); + expect(stripe.update).toHaveBeenCalledExactlyOnceWith(original.id, { + cancel_at_period_end: true, + cancellation_details: { feedback: 'other' }, + }); + expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({ + userId: user.id, + stripeSubscriptionId: original.id, + reason: 'OTHER', + note: 'Leaving after this project', + }); + const where = { userId: user.id, name: 'SUBSCRIPTION_CANCELED' as const }; + // This must exist before any later transition can conceal the missing event. + const accepted = await db.analyticsEvent.findMany({ where }); + expect(accepted).toHaveLength(1); + expect(accepted[0].dedupeKey).toBe( + `SUBSCRIPTION_CANCELED:${original.id}:${original.items.data[0].current_period_end * 1000}` + ); + + await syncStripeCustomerSubscriptions(user.stripeCustomerId!); + await syncStripeCustomerSubscriptions(user.stripeCustomerId!); + stripe.list.mockResolvedValue({ + data: + deletion === 'empty customer' + ? [] + : [{ ...original, status: 'canceled', cancel_at_period_end: false }], + has_more: false, + }); + await syncStripeCustomerSubscriptions(user.stripeCustomerId!); + await syncStripeCustomerSubscriptions(user.stripeCustomerId!); + const replayed = await db.analyticsEvent.findMany({ where }); + expect(replayed.map((event) => event.id)).toEqual([accepted[0].id]); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe( + BillingSubscriptionStatus.CANCELED + ); + } + ); + + it('records cancellation of a paid subscription that does not drive the customer mirror', async () => { + const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true }); + signedInAs(user); + const authoritative = subscription(user); + const other = subscription(user, { + id: 'sub_analytics_other', + cancel_at_period_end: false, + created: unix(-60 * DAY), + }); + const stripe = stubStripe([authoritative, other]); + const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })); + expect(response.status).toBe(200); + expect(stripe.update).toHaveBeenCalledExactlyOnceWith(other.id, expect.anything()); + const events = await db.analyticsEvent.findMany({ + where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' }, + }); + expect(events).toHaveLength(1); + expect(events[0].dedupeKey).toBe( + `SUBSCRIPTION_CANCELED:sub_analytics_other:${other.items.data[0].current_period_end * 1000}` + ); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe( + authoritative.id + ); + }); + + it('records no cancellation event when Stripe rejects the paid cancellation', async () => { + const user = await createSubscribedUser(); + signedInAs(user); + const stripe = stubStripe([subscription(user)]); + stripe.update.mockRejectedValueOnce( + Object.assign(new Error('Stripe rejected cancellation'), { + type: 'StripeInvalidRequestError', + }) + ); + const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })); + expect(response.status).toBe(409); + expect(stripe.update).toHaveBeenCalledTimes(1); + expect( + await db.analyticsEvent.count({ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' } }) + ).toBe(0); + expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(false); + }); + + it('keeps the cancellation and reason when analytics recording fails', async () => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user); + const stripe = stubStripe([original]); + const recording = vi + .spyOn(db.analyticsEvent, 'createMany') + .mockRejectedValue(new Error('Analytics unavailable')); + try { + const response = await callRoute( + cancelRoute, + cancelRequest({ reason: 'OTHER', note: 'Keep this answer' }) + ); + expect(response.status).toBe(200); + expect(stripe.update).toHaveBeenCalledTimes(1); + expect(recording).toHaveBeenCalledWith( + expect.objectContaining({ + data: [expect.objectContaining({ name: 'SUBSCRIPTION_CANCELED', userId: user.id })], + }) + ); + expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({ + reason: 'OTHER', + note: 'Keep this answer', + }); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(true); + } finally { + recording.mockRestore(); + } + }); + + it('still cancels without recording analytics when the feature is disabled', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + const user = await createSubscribedUser(); + signedInAs(user); + const stripe = stubStripe([subscription(user)]); + expect((await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }))).status).toBe(200); + expect(stripe.update).toHaveBeenCalledTimes(1); + expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1); + expect(await db.analyticsEvent.count({ where: { userId: user.id } })).toBe(0); + }); +}); diff --git a/tests/api/billing-sync-concurrency.test.ts b/tests/api/billing-sync-concurrency.test.ts new file mode 100644 index 0000000..d118ade --- /dev/null +++ b/tests/api/billing-sync-concurrency.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { Pool } from 'pg'; +import { + buildBillingAccessWhereInput, + hasBillingAccess, + syncStripeCustomerSubscriptions, +} from '@/lib/billing'; +import { getStripe } from '@/lib/stripe'; +import { db } from '../helpers/db'; +import { createUser } from '../factories'; + +// Real PostgreSQL persistence and advisory locks; only Stripe responses are emulated. +// The held response models transport delay, not Stripe's actual webhook scheduling. +const CUSTOMER = 'cus_sync_concurrency'; +const PRICE = 'price_sync_concurrency'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function subscription( + status: 'active' | 'canceled', + id = 'sub_paid', + customer = CUSTOMER +): Stripe.Subscription { + const now = Math.floor(Date.now() / 1000); + return { + id, + customer, + status, + created: now - 86_400, + trial_end: null, + cancel_at: null, + cancel_at_period_end: false, + ended_at: status === 'canceled' ? now - 60 : null, + canceled_at: status === 'canceled' ? now - 60 : null, + items: { + data: [ + { + price: { id: PRICE }, + current_period_start: now - 86_400, + current_period_end: now + 30 * 86_400, + }, + ], + }, + } as Stripe.Subscription; +} + +async function seed(status: 'ACTIVE' | 'CANCELED' = 'CANCELED', customer = CUSTOMER) { + return createUser({ + stripeCustomerId: customer, + stripeSubscriptionId: `sub_seed_${customer}`, + stripePriceId: PRICE, + subscriptionStatus: status, + stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 86_400_000), + trialEndsAt: null, + billingTrialConsumedAt: new Date(Date.now() - 60 * 86_400_000), + billingAccessEndedAt: status === 'CANCELED' ? new Date(Date.now() - 60_000) : null, + }); +} + +function installStripe() { + const list = vi.fn< + (params: Stripe.SubscriptionListParams) => Promise<{ + data: Stripe.Subscription[]; + has_more: boolean; + }> + >(); + vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe); + return list; +} + +async function waitForQueuedSync(customer = CUSTOMER) { + // Observe a real waiter rather than sleeping and assuming the other request ran. + // Replacing the database lock with a process-local mutex fails this assertion. + await vi.waitFor( + async () => { + const rows = await db.$queryRaw<{ waiting: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM pg_locks + WHERE locktype = 'advisory' AND NOT granted + AND classid = hashtext('stripe-subscription-sync')::oid + AND objid = hashtext(${customer})::oid AND objsubid = 2 + ) AS waiting + `; + expect(rows).toEqual([{ waiting: true }]); + }, + { timeout: 2_000, interval: 20 } + ); +} + +async function assertAccess(userId: string, expected: boolean) { + const stored = await db.user.findUniqueOrThrow({ where: { id: userId } }); + expect(hasBillingAccess(stored)).toBe(expected); + expect( + await db.user.findMany({ + where: { AND: [{ id: userId }, buildBillingAccessWhereInput()] }, + select: { id: true }, + }) + ).toEqual(expected ? [{ id: userId }] : []); + return stored; +} + +beforeEach(() => { + vi.stubEnv('STRIPE_PRICE_ID', PRICE); + vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); +}); + +describe('customer-wide Stripe sync serialization', () => { + it.each(['canceled-then-paid', 'paid-then-canceled', 'empty-then-paid'] as const)( + 'keeps the newer snapshot for overlapping %s reads', + async (order) => { + const paidFirst = order === 'paid-then-canceled'; + const user = await seed(paidFirst ? 'ACTIVE' : 'CANCELED'); + const stale = + order === 'empty-then-paid' + ? [] + : [subscription(paidFirst ? 'active' : 'canceled', paidFirst ? 'sub_paid' : 'sub_old')]; + const fresh = subscription(paidFirst ? 'canceled' : 'active'); + const list = installStripe(); + const entered = deferred(); + const release = deferred(); + list.mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return { data: stale, has_more: false }; + }); + list.mockImplementationOnce(async () => { + // Reading Stripe for the next sync must wait for the previous mirror commit. + const previous = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(previous.stripeSubscriptionId).toBe(stale[0]?.id ?? null); + return { data: [fresh], has_more: false }; + }); + const first = syncStripeCustomerSubscriptions(CUSTOMER); + let second: ReturnType | undefined; + // Attach handlers immediately so assertion failures still drain both requests. + void first.catch(() => {}); + try { + await entered.promise; + second = syncStripeCustomerSubscriptions(CUSTOMER); + void second.catch(() => {}); + await waitForQueuedSync(); + expect(list).toHaveBeenCalledTimes(1); + const unchanged = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(unchanged.stripeSubscriptionId).toBe(user.stripeSubscriptionId); + } finally { + release.resolve(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + } + await expect(first).resolves.not.toBeNull(); + await expect(second!).resolves.not.toBeNull(); + expect(list).toHaveBeenCalledTimes(2); + const stored = await assertAccess(user.id, !paidFirst); + expect(stored.subscriptionStatus).toBe(paidFirst ? 'CANCELED' : 'ACTIVE'); + expect(stored.stripeSubscriptionId).toBe('sub_paid'); + expect( + await db.analyticsEvent.findMany({ + where: { userId: user.id }, + select: { name: true }, + }) + ).toEqual([{ name: paidFirst ? 'SUBSCRIPTION_CANCELED' : 'SUBSCRIPTION_STARTED' }]); + } + ); + + it('honors the customer lock held by an independent database connection before reading Stripe', async () => { + const user = await seed(); + const list = installStripe().mockResolvedValue({ + data: [subscription('active')], + has_more: false, + }); + const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 }); + const connection = await pool.connect(); + let pending: ReturnType | undefined; + try { + await connection.query('BEGIN'); + await connection.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [ + 'stripe-subscription-sync', + CUSTOMER, + ]); + pending = syncStripeCustomerSubscriptions(CUSTOMER); + void pending.catch(() => {}); + await waitForQueuedSync(); + expect(list).not.toHaveBeenCalled(); + } finally { + await connection.query('ROLLBACK'); + connection.release(); + await pool.end(); + if (pending) await Promise.allSettled([pending]); + } + await expect(pending!).resolves.not.toBeNull(); + expect(list).toHaveBeenCalledTimes(1); + await assertAccess(user.id, true); + }); + + it('lets a different customer sync while the first customer waits on Stripe', async () => { + await seed(); + const otherCustomer = 'cus_sync_independent'; + const otherUser = await seed('CANCELED', otherCustomer); + const entered = deferred(); + const release = deferred(); + const list = installStripe().mockImplementation(async ({ customer }) => { + if (customer === CUSTOMER) { + entered.resolve(); + await release.promise; + } + return { data: [subscription('active', `sub_${customer}`, customer)], has_more: false }; + }); + const first = syncStripeCustomerSubscriptions(CUSTOMER); + void first.catch(() => {}); + let second: ReturnType | undefined; + let secondFinished = false; + try { + await entered.promise; + second = syncStripeCustomerSubscriptions(otherCustomer); + void second.then( + () => { + secondFinished = true; + }, + () => { + secondFinished = true; + } + ); + await vi.waitFor(() => expect(secondFinished).toBe(true), { timeout: 2_000 }); + await expect(second).resolves.not.toBeNull(); + await assertAccess(otherUser.id, true); + expect(list).toHaveBeenCalledTimes(2); + } finally { + release.resolve(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + } + await expect(first).resolves.not.toBeNull(); + }); + + it('releases a failed sync for its queued successor without changing the original mirror', async () => { + const user = await seed(); + const entered = deferred(); + const release = deferred(); + const failure = new Error('Emulated Stripe read failure'); + const list = installStripe(); + list.mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + throw failure; + }); + list.mockImplementationOnce(async () => { + expect(await db.user.findUniqueOrThrow({ where: { id: user.id } })).toEqual(user); + return { data: [subscription('active')], has_more: false }; + }); + const first = syncStripeCustomerSubscriptions(CUSTOMER); + void first.catch(() => {}); + let second: ReturnType | undefined; + try { + await entered.promise; + second = syncStripeCustomerSubscriptions(CUSTOMER); + void second.catch(() => {}); + await waitForQueuedSync(); + } finally { + release.resolve(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + } + await expect(first).rejects.toBe(failure); + await expect(second!).resolves.not.toBeNull(); + expect(list).toHaveBeenCalledTimes(2); + await assertAccess(user.id, true); + }); + + it('cannot overwrite a newer mirror when a Stripe response arrives after transaction expiry', async () => { + const user = await seed(); + const entered = deferred(); + const release = deferred(); + const callbackFinished = deferred(); + const list = installStripe(); + list.mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return { data: [subscription('canceled', 'sub_stale')], has_more: false }; + }); + list.mockResolvedValueOnce({ data: [subscription('active', 'sub_new')], has_more: false }); + + // Keep the real transaction and expiry machinery, shortening only the first + // request's deadline. Its callback can outlive rollback while Stripe is held. + const transact = db.$transaction.bind(db); + const transaction = vi.spyOn(db, '$transaction').mockImplementationOnce((callback, options) => + transact( + async (tx) => { + try { + return await callback(tx); + } finally { + callbackFinished.resolve(); + } + }, + { ...options, timeout: 200 } + ) + ); + const first = syncStripeCustomerSubscriptions(CUSTOMER); + void first.catch(() => {}); + let second: ReturnType | undefined; + let committed: Awaited> | undefined; + try { + await entered.promise; + // Wait for PostgreSQL to release A's lock, not an assumed sleep duration. + await vi.waitFor( + async () => { + const rows = await db.$queryRaw<{ held: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM pg_locks + WHERE locktype = 'advisory' AND granted + AND classid = hashtext('stripe-subscription-sync')::oid + AND objid = hashtext(${CUSTOMER})::oid AND objsubid = 2 + ) AS held + `; + expect(rows).toEqual([{ held: false }]); + }, + { timeout: 3_000, interval: 20 } + ); + second = syncStripeCustomerSubscriptions(CUSTOMER); + void second.catch(() => {}); + await expect(second).resolves.not.toBeNull(); + committed = await assertAccess(user.id, true); + expect(committed.stripeSubscriptionId).toBe('sub_new'); + } finally { + release.resolve(); + // The outer promise may reject on expiry before its callback finishes. + // Drain both so a late global-client write cannot escape the assertions. + await Promise.allSettled([first, ...(second ? [second] : [])]); + await callbackFinished.promise; + transaction.mockRestore(); + } + await expect(first).rejects.toMatchObject({ code: 'P2028' }); + expect(list).toHaveBeenCalledTimes(2); + expect(await assertAccess(user.id, true)).toEqual(committed); + }); +}); diff --git a/tests/component/settings-currency.test.tsx b/tests/component/settings-currency.test.tsx new file mode 100644 index 0000000..60eeb31 --- /dev/null +++ b/tests/component/settings-currency.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SettingsPage from '@/app/(dashboard)/settings/settings-page-client'; + +// API scaling expectations are literal, independent of production Intl logic. +// USD, JPY, KRW: https://docs.stripe.com/currencies#zero-decimal +// ISK, UGX: https://docs.stripe.com/currencies#special-cases +// KWD: https://support.stripe.com/questions/which-payments-methods-and-products-are-available-in-the-uae?locale=en-GB +// KWD support is account/region dependent. This is a synthetic component fixture, +// not evidence that the configured billing account accepts KWD invoices. +const cases = [ + { currency: 'usd', amountDue: 1099, expected: '$10.99' }, + { currency: 'jpy', amountDue: 500, expected: '¥500' }, + { currency: 'krw', amountDue: 500, expected: '₩500' }, + { currency: 'kwd', amountDue: 12340, expected: 'KWD 12.340' }, + { currency: 'isk', amountDue: 500, expected: 'ISK 5' }, + { currency: 'ugx', amountDue: 500, expected: 'UGX 5' }, +]; + +const NumberFormat = Intl.NumberFormat; + +beforeEach(() => { + // Pin the locale while retaining the real currency precision and formatting. + vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (locales, options) { + return new NumberFormat(locales ?? 'en-US', options); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('actual Settings invoice display against Stripe currency contract', () => { + it.each(cases)('$currency amount_due=$amountDue displays $expected', async (fixture) => { + expect(new Intl.NumberFormat().resolvedOptions().locale).toBe('en-US'); + const fetchMock = vi.fn(async (url: string) => { + if (url !== '/api/billing') return { ok: false }; + return { + ok: true, + json: async () => ({ + data: { + isEnabled: true, + isConfigured: true, + status: 'ready', + checkoutAvailable: false, + portalAvailable: false, + cancelAvailable: false, + cancelIsImmediate: true, + needsPaymentFix: true, + openInvoice: { + id: 'in_currency_fixture', + hostedInvoiceUrl: null, + amountDue: fixture.amountDue, + currency: fixture.currency, + attemptCount: 1, + nextPaymentAttempt: null, + }, + workspaceCreation: { canCreateWorkspace: true, canStartTrial: false }, + subscription: { + status: 'PAST_DUE', + label: 'Past due', + hasActiveSubscription: false, + hasRecoverableSubscription: true, + hasActiveTrial: false, + hasBillingAccess: true, + isPaid: false, + priceId: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + cancelAt: null, + trialEndsAt: null, + billingAccessEndedAt: null, + storageCleanupEligibleAt: null, + }, + }, + }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + render(); + const banner = await screen.findByText(/^A payment of .* did not go through$/); + const actual = banner.textContent!.replace(/\s+/g, ' '); + expect(fetchMock.mock.calls.some(([url]) => url === '/api/billing')).toBe(true); + expect(actual).toBe(`A payment of ${fixture.expected} did not go through`); + }); +}); diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index 31450f4..7de40a4 100644 --- a/tests/unit/lib/billing.test.ts +++ b/tests/unit/lib/billing.test.ts @@ -36,6 +36,8 @@ import { } from '@/lib/billing'; const dbMock = vi.hoisted(() => ({ + $transaction: vi.fn(), + $executeRaw: vi.fn(), user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, workspace: { count: vi.fn() }, workspaceMember: { count: vi.fn() }, @@ -941,6 +943,10 @@ describe('database backed billing helpers', () => { vi.setSystemTime(NOW); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE); + dbMock.$transaction + .mockReset() + .mockImplementation(async (work: (tx: typeof dbMock) => Promise) => work(dbMock)); + dbMock.$executeRaw.mockReset().mockResolvedValue(0); dbMock.user.findUnique.mockReset(); dbMock.user.update.mockReset(); dbMock.user.updateMany.mockReset();