diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index f1e2793..0a6234f 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -33,6 +33,25 @@ import { cn } from '@/lib/utils'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import type { CancellationReason } from '@/lib/cancellation-reasons'; +/** Convert Stripe API units separately from the currency's display precision. */ +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; + // 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}`; + } +} + interface NotificationSettings { telegramChatId: string | null; telegramEnabled: boolean; @@ -51,6 +70,17 @@ interface BillingOverview { status: 'disabled' | 'ready' | 'misconfigured'; checkoutAvailable: boolean; portalAvailable: boolean; + cancelAvailable: boolean; + cancelIsImmediate: 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; @@ -260,12 +290,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(); @@ -333,9 +367,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo : null; showMessage( 'success', - endsOn - ? `Your subscription ends on ${endsOn}. You keep full access until then.` - : 'Your subscription ends at the close of the current period.' + data.data?.canceledImmediately + ? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.' + : endsOn + ? `Your subscription ends on ${endsOn}. You keep full access until then.` + : 'Your subscription ends at the close of the current period.' ); return true; } catch { @@ -458,13 +494,15 @@ 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.' : billing.subscription.hasActiveTrial ? 'Free trial, no card required.' - : 'Billing access has ended.'} + : billing.subscription.hasBillingAccess + ? 'Workspace access remains available while you resolve your payment.' + : 'Billing access has ended.'}

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 ? (

@@ -501,7 +539,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} @@ -527,10 +565,54 @@ 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 ? ( +

+ {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 ? ( + + View and pay this invoice + + ) : null} +
+ ) : null} +
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (

{getCancellationReasonLabel(row.reason)}

diff --git a/components/settings/cancel-subscription-dialog.tsx b/components/settings/cancel-subscription-dialog.tsx index aa4810d..4d96cea 100644 --- a/components/settings/cancel-subscription-dialog.tsx +++ b/components/settings/cancel-subscription-dialog.tsx @@ -27,6 +27,8 @@ interface CancelSubscriptionDialogProps { periodEnd: string | null; /** True for a subscription that is still inside its Stripe trial. */ isTrial: boolean; + /** Unpaid subscriptions end now; cancellation does not extend access. */ + canceledImmediately?: boolean; /** Resolves true once the cancellation went through; false keeps the dialog and its answer. */ onConfirm: (input: { reason: CancellationReason | null; @@ -48,6 +50,7 @@ export function CancelSubscriptionDialog({ onOpenChange, periodEnd, isTrial, + canceledImmediately = false, onConfirm, }: CancelSubscriptionDialogProps) { const [reason, setReason] = useState(null); @@ -96,9 +99,11 @@ export function CancelSubscriptionDialog({ Cancel your {isTrial ? 'trial' : 'subscription'}? - {endsOn - ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.` - : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'} + {canceledImmediately + ? 'This subscription ends immediately. Canceling does not extend access to your workspaces. Automatic collection stops for its open invoices. Eligible current-period subscription invoices are canceled; charges for prior service and other items may still be owed.' + : endsOn + ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.` + : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'} 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 d25ff35..4c06923 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -35,6 +35,36 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.INCOMPLETE_EXPIRED, ]); +// 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, +// 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']); + +// Application grace period, independent of the Stripe retry settings. An unpaid +// invoice's future period end does not extend this access window. +const UNPAID_ACCESS_GRACE_DAYS = 14; + export const DEFAULT_TRIAL_PERIOD_DAYS = 7; const STORAGE_CLEANUP_GRACE_DAYS = 15; @@ -95,7 +125,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()) { @@ -106,14 +139,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 the payment grace 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() ); @@ -132,21 +170,42 @@ 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 the payment grace 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() ); } export function getBillingAccessEndDate(subject: BillingAccessSubject) { - if (subject.billingAccessEndedAt) { - return subject.billingAccessEndedAt; - } - - if (subject.stripeCurrentPeriodEnd) { - return subject.stripeCurrentPeriodEnd; - } - - return subject.trialEndsAt; + const subscriptionEnd = + subject.billingAccessEndedAt ?? + (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus) + ? null + : subject.stripeCurrentPeriodEnd); + // A trial grants access independently of the subscription cutoff. Retention starts + // after the last legitimate entitlement, never from an unpaid invoice's period. + if (!subscriptionEnd) return subject.trialEndsAt; + if (!subject.trialEndsAt) return subscriptionEnd; + return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime())); } export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) { @@ -161,6 +220,9 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use return {}; } + // 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 { OR: [ { @@ -169,7 +231,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use }, }, { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, + { + stripeCurrentPeriodEnd: { gt: now }, + subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], + }, ], }; } @@ -185,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us return { id: { in: [] } }; } - // Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`. - // Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR - // "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than - // false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still - // NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber - // looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer - // reports current_period_end on the subscription), so the cleanup silently matched nobody. + // Match the same last entitlement date as getBillingAccessEndDate, with explicit + // null branches because SQL comparisons against null do not evaluate to false. return { AND: [ { @@ -199,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], }, }, - { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] }, - { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] }, + { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] }, { OR: [ { billingAccessEndedAt: { lte: cleanupCutoff } }, { - AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }], + billingAccessEndedAt: null, + subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] }, + stripeCurrentPeriodEnd: { lte: cleanupCutoff }, + }, + { + billingAccessEndedAt: null, + trialEndsAt: { lte: cleanupCutoff }, + OR: [ + { stripeCurrentPeriodEnd: null }, + { subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } }, + ], }, ], }, @@ -723,6 +793,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 +869,37 @@ 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: bound access to the application grace period, + // not the period end Stripe 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); + const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace; + + return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); + } + + // Preserve the existing period-based access policy for paused subscriptions. + // The paused status itself is not evidence that this period was paid. + 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 + // 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) { @@ -746,31 +910,18 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId: return subscription.items.data.some((item) => item.price.id === configuredPriceId); } -/** - * When the current billing period ends, as a Unix timestamp, or null. - * - * The API version this client pins (2026-02-25) reports the period on each - * subscription item rather than on the subscription itself, and every item of - * a single-price subscription carries the same dates. The top-level field is - * still read afterwards so an older fixture or a replayed event body from a - * previous version keeps working. - */ -export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null { - const fromItem = subscription.items?.data?.[0]?.current_period_end; - if (typeof fromItem === 'number') { - return fromItem; - } - - return 'current_period_end' in subscription && typeof subscription.current_period_end === 'number' - ? subscription.current_period_end - : null; +export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) { + return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db)); } -export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) { +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, @@ -813,13 +964,14 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip // subscription created after the cardless trial shipped, and this fallback is // 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())); + const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null; + // 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({ + const updated = await client.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, @@ -833,22 +985,15 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip hasEntitledPrice && trialEnd ? (user.billingTrialConsumedAt ?? new Date()) : user.billingTrialConsumedAt, - // A live trial means access has not ended, whatever the subscription says. - // Stamping an end date here while the trial runs would date the storage - // cleanup from today and tell the user their work dies before their trial - // does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial - // that has already elapsed is a reason to stamp the end date, not to skip it. - billingAccessEndedAt: - hasAccess || hasActiveTrial(preservedTrialEnd) - ? null - : getInactiveBillingAccessEndedAt( - subscription, - hasEntitledPrice ? currentPeriodEnd : null - ), + // Preserve the subscription cutoff even during a trial. The trial has its own + // access branch; clearing this cutoff would resurrect an unpaid period later. + billingAccessEndedAt: hasAccess + ? null + : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null), }, }); - await recordSubscriptionTransition({ + const transition: Parameters[0] = { userId: user.id, subscriptionId: subscription.id, before: { @@ -862,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 @@ -917,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, @@ -958,9 +1134,9 @@ export async function markSubscriptionCanceledByCustomerId( // Losing the subscription does not retract a trial that has not run out. The // account keeps the days it was given and lands back on the trial's own end // date, which is also what the cancellation copy in settings promises. - const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt); + 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, @@ -970,9 +1146,7 @@ export async function markSubscriptionCanceledByCustomerId( stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null, stripeCancelAtPeriodEnd: false, stripeCancelAt: null, - billingAccessEndedAt: preservedTrialEnd - ? null - : (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()), + billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(), }, }); @@ -980,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: { @@ -994,7 +1168,218 @@ export async function markSubscriptionCanceledByCustomerId( trialEndsAt: preservedTrialEnd, currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null, }, + }; + + return { updated, transition }; +} + +/** + * 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 updated; + 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) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null + ); +} + +export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) { + return UNPAID_STRIPE_STATUSES.has(subscription.status); +} + +/** Only a wholly unpaid, ordinary current-period invoice can be written off. */ +export function isCurrentSubscriptionInvoice( + invoice: Stripe.Invoice, + subscription: Stripe.Subscription +): boolean { + const latestId = + typeof subscription.latest_invoice === 'string' + ? subscription.latest_invoice + : subscription.latest_invoice?.id; + const start = getSubscriptionPeriodStart(subscription); + const end = getSubscriptionPeriodEnd(subscription); + if ( + invoice.id !== latestId || + invoice.status !== 'open' || + invoice.amount_paid !== 0 || + !['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') || + getInvoiceSubscriptionId(invoice) !== subscription.id || + start === null || + end === null || + !invoice.lines || + invoice.lines.has_more || + invoice.lines.data.length === 0 + ) + return false; + return invoice.lines.data.every((line) => { + const details = line.parent?.subscription_item_details; + return ( + line.parent?.type === 'subscription_item_details' && + details?.subscription === subscription.id && + details.proration === false && + line.pricing?.price_details?.price === getStripePriceId() && + line.period.start === start && + line.period.end === end + ); + }); +} + +async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) { + const invoices: Stripe.Invoice[] = []; + let startingAfter: string | undefined; + while (true) { + const page = await getStripe().invoices.list({ + customer: customerId, + status: 'open', + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + invoices.push( + ...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId) + ); + if (!page.has_more || page.data.length === 0) break; + startingAfter = page.data[page.data.length - 1].id; + } + return invoices; +} + +/** + * Stop automatic collection on all open invoices for this subscription. Older or + * mixed invoices remain receivables; only a complete current renewal is voided. + * Failures propagate so callers can report and retry unfinished cleanup. + */ +export async function voidOpenSubscriptionInvoices( + customerId: string, + subscriptionId: string, + subscriptionSnapshot?: Stripe.Subscription +) { + const stripe = getStripe(); + const subscription = + subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId)); + const customer = + typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; + if (customer !== customerId) throw new Error('Subscription customer mismatch'); + const voided: string[] = []; + for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) { + // Immediate cancellation normally pauses collection too. Explicitly keep retained + // receivables paused, including when retrying a partly completed cancellation. + if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false }); + if (isCurrentSubscriptionInvoice(invoice, subscription)) { + await stripe.invoices.voidInvoice(invoice.id); + voided.push(invoice.id); + } + } + return voided; +} + +/** Cancellation candidates differ from the subscription granting access. */ +export async function findCancelableStripeSubscription(customerId: string) { + const subscriptions: Stripe.Subscription[] = []; + let startingAfter: string | undefined; + while (true) { + const page = await getStripe().subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + subscriptions.push(...page.data); + if (!page.has_more || page.data.length === 0) break; + startingAfter = page.data[page.data.length - 1].id; + } + const candidate = selectAuthoritativeSubscription( + subscriptions.filter( + (subscription) => + hasEntitledPrice(subscription, getStripePriceId()) && + LIVE_STRIPE_STATUSES.has(subscription.status) && + (isUnpaidStripeSubscription(subscription) || + (!subscription.cancel_at && !subscription.cancel_at_period_end)) + ) + ); + if (candidate) return candidate; + // A failed invoice write must remain reachable after Stripe accepted cancellation. + for (const subscription of subscriptions) { + if ( + !['canceled', 'incomplete_expired'].includes(subscription.status) || + !hasEntitledPrice(subscription, getStripePriceId()) + ) + continue; + const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id); + if ( + invoices.some( + (invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription) + ) + ) { + return subscription; + } + } + return null; +} + +/** + * 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/cancellation.ts b/lib/cancellation.ts index 4e4d2d3..b3cc4bd 100644 --- a/lib/cancellation.ts +++ b/lib/cancellation.ts @@ -4,10 +4,13 @@ import { db } from '@/lib/db'; import { getStripe } from '@/lib/stripe'; import { getSubscriptionPeriodEnd, - hasActiveSubscription, - syncStripeSubscriptionToUser, + findCancelableStripeSubscription, + isUnpaidStripeSubscription, + syncStripeCustomerSubscriptions, + voidOpenSubscriptionInvoices, } from '@/lib/billing'; import { logError } from '@/lib/logger'; +import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events'; export { CANCELLATION_NOTE_MAX_LENGTH, @@ -33,7 +36,14 @@ const STRIPE_FEEDBACK: Record< }; export type CancelSubscriptionResult = - | { ok: true; periodEnd: Date | null } + | { + ok: true; + periodEnd: Date | null; + canceledImmediately: boolean; + voidedInvoices: string[]; + status: Stripe.Subscription.Status; + cancelAt: Date | null; + } | { ok: false; code: 'NO_SUBSCRIPTION' | 'ALREADY_CANCELING' | 'STRIPE_REJECTED' }; function isStripeInvalidRequest(error: unknown): boolean { @@ -45,67 +55,118 @@ function isStripeInvalidRequest(error: unknown): boolean { ); } +/** Expire every open Checkout session for this incomplete subscription, including later pages. */ +async function expireSubscriptionCheckout(customerId: string, subscriptionId: string) { + const stripe = getStripe(); + let startingAfter: string | undefined; + let expired = false; + do { + const sessions = await stripe.checkout.sessions.list({ + customer: customerId, + status: 'open', + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + const matching = sessions.data.filter((session) => { + const owner = typeof session.customer === 'string' ? session.customer : session.customer?.id; + const id = + typeof session.subscription === 'string' ? session.subscription : session.subscription?.id; + return owner === customerId && id === subscriptionId && session.status === 'open'; + }); + await Promise.all(matching.map((session) => stripe.checkout.sessions.expire(session.id))); + expired ||= matching.length > 0; + startingAfter = sessions.has_more ? sessions.data.at(-1)?.id : undefined; + } while (startingAfter); + return expired; +} + /** - * Schedules the account's subscription to end at the close of the current - * billing period and records why. - * - * The order of the writes is deliberate. The local flag is claimed first with - * a conditional update, so two requests racing for the same subscription (a - * double click, a retried request) cannot both reach Stripe and both write a - * reason row: the second one loses the claim and gets `ALREADY_CANCELING`. - * Stripe goes second because it is the only step that can refuse, and a - * refusal hands the claim back. The reason row goes third, straight after - * Stripe accepts, so it exists even if the sync below throws. The sync goes - * last and is best effort: the webhook for the same update is already on its - * way and will write the identical state, so a failure here only delays what - * the settings page shows, it never loses the cancellation. + * Paid subscriptions end at period end; unpaid subscriptions end immediately. + * Record the reason before invoice cleanup so a failed cleanup can be retried + * on the canceled subscription without losing or duplicating the answer. */ -export async function cancelSubscriptionAtPeriodEnd(params: { +export async function cancelSubscription(params: { userId: string; reason: CancellationReason | null; note: string | null; }): Promise { + const requestStartedAt = new Date(); const user = await db.user.findUnique({ where: { id: params.userId }, select: { - subscriptionStatus: true, + stripeCustomerId: true, stripeSubscriptionId: true, stripeCancelAtPeriodEnd: true, stripeCurrentPeriodEnd: true, }, }); + if (!user?.stripeCustomerId) return { ok: false, code: 'NO_SUBSCRIPTION' }; - if (!user?.stripeSubscriptionId || !hasActiveSubscription(user.subscriptionStatus)) { + const customerId = user.stripeCustomerId; + const original = await findCancelableStripeSubscription(customerId); + if (!original) return { ok: false, code: 'NO_SUBSCRIPTION' }; + const owner = typeof original.customer === 'string' ? original.customer : original.customer.id; + if (owner !== customerId) return { ok: false, code: 'NO_SUBSCRIPTION' }; + + const subscriptionId = original.id; + const cleanupRetry = original.status === 'canceled' || original.status === 'incomplete_expired'; + const canceledImmediately = cleanupRetry || isUnpaidStripeSubscription(original); + if (!canceledImmediately && original.status !== 'active' && original.status !== 'trialing') { return { ok: false, code: 'NO_SUBSCRIPTION' }; } - - const subscriptionId = user.stripeSubscriptionId; - const claimed = await db.user.updateMany({ - where: { - id: params.userId, - stripeSubscriptionId: subscriptionId, - stripeCancelAtPeriodEnd: false, - }, - data: { stripeCancelAtPeriodEnd: true }, - }); - if (claimed.count === 0) { + if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) { return { ok: false, code: 'ALREADY_CANCELING' }; } - let subscription: Stripe.Subscription; + // Retain the paid mirror's conditional claim for double-clicks. It cannot + // guard an unpaid cancellation, cleanup retry, or a different subscription. + const claimPaidMirror = !canceledImmediately && user.stripeSubscriptionId === subscriptionId; + if (claimPaidMirror) { + const claimed = await db.user.updateMany({ + where: { + id: params.userId, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + stripeCancelAtPeriodEnd: false, + }, + data: { stripeCancelAtPeriodEnd: true }, + }); + if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' }; + } + + let subscription = original; try { - subscription = await getStripe().subscriptions.update(subscriptionId, { - cancel_at_period_end: true, - cancellation_details: params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {}, - }); + const stripe = getStripe(); + const cancellationDetails = params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {}; + if (!cleanupRetry) { + if (!canceledImmediately) { + subscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + cancellation_details: cancellationDetails, + }); + } else if ( + original.status === 'incomplete' && + (await expireSubscriptionCheckout(customerId, subscriptionId)) + ) { + // Checkout owns incomplete subscriptions it created. Expiration cancels + // them; retrieving gives the response the actual resulting Stripe state. + subscription = await stripe.subscriptions.retrieve(subscriptionId); + if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') { + throw new Error('Checkout expiration did not end the subscription'); + } + } else { + subscription = await stripe.subscriptions.cancel(subscriptionId, { + cancellation_details: cancellationDetails, + }); + } + } } catch (error) { - await db.user.updateMany({ - where: { id: params.userId, stripeSubscriptionId: subscriptionId }, - data: { stripeCancelAtPeriodEnd: false }, - }); - // The subscription Stripe knows about is not the one we hold, most often - // because it already ended there and the webhook has not caught up. That - // is the customer's state, not a server fault, and the portal can show it. + if (claimPaidMirror) { + await db.user.updateMany({ + where: { id: params.userId, stripeSubscriptionId: subscriptionId }, + data: { stripeCancelAtPeriodEnd: false }, + }); + } if (isStripeInvalidRequest(error)) { logError('billing.cancel.rejected', error); return { ok: false, code: 'STRIPE_REJECTED' }; @@ -113,24 +174,75 @@ export async function cancelSubscriptionAtPeriodEnd(params: { throw error; } - const periodEndUnix = getSubscriptionPeriodEnd(subscription); + const periodEndUnix = getSubscriptionPeriodEnd(original); const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd; - - await db.subscriptionCancellation.create({ - data: { - userId: params.userId, - stripeSubscriptionId: subscriptionId, - reason: params.reason, - note: params.note, - periodEnd, - }, + // 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 + // subscription can record another cancellation without duplicating concurrent calls. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${subscriptionId}))`; + // Cleanup can be retried long after the request that canceled the subscription. + // Match that period and, when Stripe reports it, the terminal transition time. + // An incomplete expiration may have no ended_at, so its period is the fallback. + const existing = await tx.subscriptionCancellation.findFirst({ + where: { + userId: params.userId, + stripeSubscriptionId: subscriptionId, + ...(cleanupRetry + ? { + periodEnd, + ...(original.ended_at + ? { createdAt: { gte: new Date(original.ended_at * 1000) } } + : {}), + } + : { createdAt: { gte: requestStartedAt } }), + }, + orderBy: { createdAt: 'desc' }, + }); + if (!existing) { + await tx.subscriptionCancellation.create({ + data: { + userId: params.userId, + stripeSubscriptionId: subscriptionId, + reason: params.reason, + note: params.note, + periodEnd, + }, + }); + } + }); + + let voidedInvoices: string[] = []; try { - await syncStripeSubscriptionToUser(subscription); - } catch (error) { - logError('billing.cancel.sync', error); + if (canceledImmediately) { + // Eligibility must use the pre-cancellation period, not a shortened one. + // Failures propagate; the selector exposes canceled cleanup candidates. + voidedInvoices = await voidOpenSubscriptionInvoices(customerId, subscriptionId, original); + } + } finally { + // Reconcile the whole customer even if cleanup failed. Another subscription + // may still provide access. Webhooks can repair a failed local sync. + try { + await syncStripeCustomerSubscriptions(customerId); + } catch (error) { + logError('billing.cancel.sync', error); + } } - return { ok: true, periodEnd }; + return { + ok: true, + periodEnd, + canceledImmediately, + voidedInvoices, + status: subscription.status, + cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null, + }; } 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/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..d605ac7 --- /dev/null +++ b/scripts/resync-stripe-subscriptions.ts @@ -0,0 +1,90 @@ +/** + * Re-reads each Stripe customer's authoritative 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 { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; +import { getStripe, 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 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; + } + + 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'}, access ends ${updated.billingAccessEndedAt?.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 subscription: ${withoutSubscription}`); + console.log(`${TAG} Failed: ${failed}`); +} + +main() + .catch((error) => { + logError(`${TAG} Fatal error:`, error); + process.exitCode = 1; + }) + .finally(async () => { + await disconnectDb(); + }); diff --git a/tests/api/billing-cancel.test.ts b/tests/api/billing-cancel.test.ts index 9d550ec..1a1e42a 100644 --- a/tests/api/billing-cancel.test.ts +++ b/tests/api/billing-cancel.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; -import { BillingSubscriptionStatus } from '@prisma/client'; +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'; import { signedInAs, signedOut } from '../helpers/session'; import { createSubscribedUser, createUser } from '../factories'; @@ -10,64 +13,172 @@ import { createSubscribedUser, createUser } from '../factories'; const ORIGIN_HEADERS = { origin: 'http://localhost:3000' }; const ENTITLED_PRICE_ID = 'price_test_openframe_dummy'; const DAY = 24 * 60 * 60; +const unix = (offsetSeconds: number) => Math.floor(Date.now() / 1000) + offsetSeconds; -function unix(offsetSeconds: number): number { - return Math.floor(Date.now() / 1000) + offsetSeconds; -} - -function cancelRequest(body?: unknown) { +function cancelRequest(body: unknown = {}) { return apiRequest('/api/billing/cancel', { method: 'POST', headers: ORIGIN_HEADERS, - body: body ?? {}, + body, }); } -/** - * Stands in for `stripe.subscriptions.update`, echoing back the subscription - * the way Stripe does: same id, `cancel_at_period_end` flipped, period end - * intact. The echo matters because the route syncs that object into the user - * row without waiting for the webhook. - */ -function stubStripeUpdate( - options: { customer?: string | null; periodEnd?: number | null; status?: string } = {} -) { - const periodEnd = options.periodEnd === undefined ? unix(20 * DAY) : options.periodEnd; - const update = vi.fn(async (id: string, params: Record) => ({ - id, - // The sync looks the user up by this, so a stub that names the wrong - // customer leaves the user row untouched and the webhook to fix it later. - customer: options.customer ?? 'cus_test_cancel', - status: options.status ?? 'active', +function subscription(user: User, overrides: Partial = {}) { + return { + id: user.stripeSubscriptionId ?? 'sub_unmirrored', + customer: user.stripeCustomerId, + status: 'active', created: unix(-30 * DAY), - cancel_at_period_end: params.cancel_at_period_end === true, + cancel_at_period_end: user.stripeCancelAtPeriodEnd, cancel_at: null, trial_end: null, - // Where the pinned API version reports the period: on the item, not on the - // subscription. A stub that puts it at the top level hides a null date. - items: { data: [{ price: { id: ENTITLED_PRICE_ID }, current_period_end: periodEnd }] }, - })); + latest_invoice: 'in_renewal', + items: { + data: [ + { + id: 'si_plan', + price: { id: ENTITLED_PRICE_ID }, + current_period_start: unix(-10 * DAY), + current_period_end: unix(20 * DAY), + }, + ], + }, + ...overrides, + } as Stripe.Subscription; +} - vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({ - subscriptions: { update, list: vi.fn(async () => ({ data: [] })) }, +function renewal(sub: Stripe.Subscription, overrides: Partial = {}) { + return { + id: 'in_renewal', + customer: sub.customer, + status: 'open', + auto_advance: true, + amount_paid: 0, + billing_reason: 'subscription_cycle', + period_start: sub.items.data[0].current_period_start, + period_end: sub.items.data[0].current_period_end, + parent: { type: 'subscription_details', subscription_details: { subscription: sub.id } }, + lines: { + has_more: false, + data: [ + { + id: 'il_renewal', + amount: 2000, + period: { + start: sub.items.data[0].current_period_start, + end: sub.items.data[0].current_period_end, + }, + parent: { + type: 'subscription_item_details', + subscription_item_details: { + subscription: sub.id, + subscription_item: 'si_plan', + proration: false, + }, + }, + pricing: { type: 'price_details', price_details: { price: ENTITLED_PRICE_ID } }, + }, + ], + }, + ...overrides, + } as Stripe.Invoice; +} + +// Only Stripe is replaced. Selection, invoice eligibility, reason persistence, +// paid claims and customer-wide sync use their real implementation and test DB. +function stubStripe(initial: Stripe.Subscription[], invoices: Stripe.Invoice[] = []) { + const subscriptions = initial.map((sub) => structuredClone(sub)); + const replace = (id: string, patch: Partial) => { + const index = subscriptions.findIndex((sub) => sub.id === id); + if (index < 0) throw new Error(`Unknown fixture subscription ${id}`); + subscriptions[index] = { ...subscriptions[index], ...patch }; + return structuredClone(subscriptions[index]); + }; + const update = vi.fn(async (id: string, params: Stripe.SubscriptionUpdateParams) => + replace(id, { cancel_at_period_end: params.cancel_at_period_end }) + ); + const cancel = vi.fn(async (id: string, params: Stripe.SubscriptionCancelParams) => { + void params; + return replace(id, { + status: 'canceled', + cancel_at_period_end: false, + canceled_at: unix(0), + ended_at: unix(0), + }); }); - - return update; + const list = vi.fn(async (params: Stripe.SubscriptionListParams) => ({ + data: structuredClone(subscriptions.filter((sub) => sub.customer === params.customer)), + has_more: false, + })); + const sessions: Stripe.Checkout.Session[] = []; + const sessionList = vi.fn(async (params: Stripe.Checkout.SessionListParams) => ({ + data: sessions.filter( + (session) => session.status === 'open' && session.customer === params.customer + ), + has_more: false, + })); + const expire = vi.fn(async (id: string) => { + const session = sessions.find((item) => item.id === id)!; + session.status = 'expired'; + const subId = + typeof session.subscription === 'string' ? session.subscription : session.subscription!.id; + replace(subId, { status: 'incomplete_expired' }); + return session; + }); + const voidInvoice = vi.fn(async (id: string) => { + const invoice = invoices.find((item) => item.id === id)!; + invoice.status = 'void'; + return invoice; + }); + const invoiceUpdate = vi.fn(async (id: string, params: Stripe.InvoiceUpdateParams) => { + const invoice = invoices.find((item) => item.id === id)!; + invoice.auto_advance = params.auto_advance ?? invoice.auto_advance; + return invoice; + }); + vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({ + subscriptions: { + update, + cancel, + list, + retrieve: vi.fn(async (id: string) => + structuredClone(subscriptions.find((sub) => sub.id === id)) + ), + }, + checkout: { sessions: { list: sessionList, expire } }, + invoices: { + list: vi.fn(async (params: Stripe.InvoiceListParams) => ({ + data: invoices.filter( + (invoice) => invoice.customer === params.customer && invoice.status === 'open' + ), + has_more: false, + })), + listLineItems: vi.fn(async (id: string) => invoices.find((item) => item.id === id)!.lines), + voidInvoice, + update: invoiceUpdate, + }, + }); + return { update, cancel, list, sessionList, expire, sessions, voidInvoice, invoiceUpdate }; } describe('POST /api/billing/cancel', () => { - it('returns 401 without a session', async () => { + it('returns 401 without a session and leaves subscription and reasons untouched', async () => { + const user = await createSubscribedUser(); + const stripe = stubStripe([subscription(user)]); signedOut(); - const response = await callRoute(cancelRoute, cancelRequest()); - expect(response.status).toBe(401); + expect(stripe.update).not.toHaveBeenCalled(); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(false); }); - it('rejects a cross-origin request', async () => { + it('rejects a cross-origin request without changing billing state', async () => { const user = await createSubscribedUser(); signedInAs(user); - + const stripe = stubStripe([subscription(user)]); const response = await callRoute( cancelRoute, apiRequest('/api/billing/cancel', { @@ -76,234 +187,638 @@ describe('POST /api/billing/cancel', () => { body: {}, }) ); - expect(response.status).toBe(403); + expect(stripe.update).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(false); }); - it('refuses when there is no active subscription to cancel', async () => { - const trialUser = await createUser(); - signedInAs(trialUser); - const update = stubStripeUpdate(); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })); - - expect(response.status).toBe(409); - expect(update).not.toHaveBeenCalled(); + it('refuses an account with no Stripe subscription', async () => { + const user = await createUser(); + signedInAs(user); + const stripe = stubStripe([]); + expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(409); + expect(stripe.update).not.toHaveBeenCalled(); expect(await db.subscriptionCancellation.count()).toBe(0); }); - it('refuses a second cancellation of a subscription already set to end', async () => { + it('does not cancel another customer subscription referenced by a stale local mirror or request body', async () => { + const owner = await createSubscribedUser(); + const caller = await createSubscribedUser({ stripeSubscriptionId: 'sub_foreign_stale' }); + signedInAs(caller); + const stripe = stubStripe([subscription(owner, { id: 'sub_foreign_stale' })]); + const response = await callRoute( + cancelRoute, + cancelRequest({ + reason: 'OTHER', + customerId: owner.stripeCustomerId, + subscriptionId: owner.stripeSubscriptionId, + }) + ); + expect(response.status).toBe(409); + expect(stripe.list).toHaveBeenCalledWith( + expect.objectContaining({ customer: caller.stripeCustomerId }) + ); + expect(stripe.update).not.toHaveBeenCalled(); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd + ).toBe(false); + }); + + it('rejects a candidate whose Stripe customer does not match the signed-in account', async () => { + const user = await createSubscribedUser(); + const owner = await createSubscribedUser(); + signedInAs(user); + const foreign = subscription(owner); + const stripe = stubStripe([foreign]); + stripe.list.mockResolvedValueOnce({ data: [foreign], has_more: false }); + expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409); + expect(stripe.update).not.toHaveBeenCalled(); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd + ).toBe(false); + }); + + it('rejects an already scheduled paid subscription without a reason write', async () => { const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true }); signedInAs(user); - const update = stubStripeUpdate(); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })); - - expect(response.status).toBe(409); - expect(update).not.toHaveBeenCalled(); + const stripe = stubStripe([subscription(user)]); + expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409); + expect(stripe.update).not.toHaveBeenCalled(); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); }); - it('rejects an unknown reason without touching Stripe', async () => { + it.each([ + { reason: 'RAGE_QUIT' }, + { reason: 'OTHER', note: 'x'.repeat(501) }, + { reason: 'OTHER', note: 42 }, + ])('rejects malformed cancellation input %# before Stripe writes', async (body) => { const user = await createSubscribedUser(); signedInAs(user); - const update = stubStripeUpdate(); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'RAGE_QUIT' })); - - expect(response.status).toBe(400); - expect(await readError(response)).toMatch(/reason/i); - expect(update).not.toHaveBeenCalled(); + const stripe = stubStripe([subscription(user)]); + expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(400); + expect(stripe.update).not.toHaveBeenCalled(); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(await db.subscriptionCancellation.count()).toBe(0); }); - it('rejects a note longer than the column allows', async () => { + it.each(['active', 'trialing'] as const)( + 'schedules %s, persists the trimmed reason and syncs the user', + async (status) => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user, { status }); + const stripe = stubStripe([original]); + const response = await callRoute( + cancelRoute, + cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload. ' }) + ); + expect(response.status).toBe(200); + expect(await readData(response)).toMatchObject({ + cancelAtPeriodEnd: true, + canceledImmediately: false, + voidedInvoices: [], + status, + periodEnd: new Date(original.items.data[0].current_period_end * 1000).toISOString(), + }); + expect(stripe.update).toHaveBeenCalledExactlyOnceWith(user.stripeSubscriptionId, { + cancel_at_period_end: true, + cancellation_details: { feedback: 'missing_features' }, + }); + expect(stripe.cancel).not.toHaveBeenCalled(); + const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + stripeSubscriptionId: original.id, + reason: 'MISSING_FEATURE', + note: 'Bulk upload.', + }); + const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(after.stripeCancelAtPeriodEnd).toBe(true); + expect(after.subscriptionStatus).toBe( + status === 'active' ? BillingSubscriptionStatus.ACTIVE : BillingSubscriptionStatus.TRIALING + ); + expect(after.stripeCurrentPeriodEnd?.getTime()).toBe( + original.items.data[0].current_period_end * 1000 + ); + } + ); + + it('finds an unscheduled paid subscription behind an already scheduled authoritative one', async () => { + const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true }); + signedInAs(user); + const scheduled = subscription(user); + const other = subscription(user, { + id: 'sub_other_paid', + cancel_at_period_end: false, + created: unix(-60 * DAY), + }); + const stripe = stubStripe([scheduled, other]); + const overview = await billingRoute(); + expect(overview.status).toBe(200); + expect(await readData(overview)).toMatchObject({ + cancelAvailable: true, + cancelIsImmediate: false, + }); + const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PROJECT_ENDED' })); + expect(response.status).toBe(200); + expect(stripe.update).toHaveBeenCalledExactlyOnceWith( + other.id, + expect.objectContaining({ cancel_at_period_end: true }) + ); + expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe( + other.id + ); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe( + scheduled.id + ); + }); + + it.each(['past_due', 'unpaid', 'incomplete'] as const)( + 'cancels %s immediately, stops collection and records the reason', + async (status) => { + const user = await createSubscribedUser({ + subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, + }); + signedInAs(user); + const original = subscription(user, { status }); + const invoice = renewal(original); + const stripe = stubStripe([original], [invoice]); + const response = await callRoute( + cancelRoute, + cancelRequest({ reason: 'PRICE_OR_BILLING', note: ' Stop billing. ' }) + ); + expect(response.status).toBe(200); + expect(await readData(response)).toMatchObject({ + canceledImmediately: true, + cancelAtPeriodEnd: false, + voidedInvoices: [invoice.id], + status: 'canceled', + }); + expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(original.id, { + cancellation_details: { feedback: 'too_expensive' }, + }); + expect(stripe.update).not.toHaveBeenCalled(); + expect(invoice.status).toBe('void'); + expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({ + reason: 'PRICE_OR_BILLING', + note: 'Stop billing.', + stripeSubscriptionId: original.id, + }); + const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.CANCELED); + expect(after.stripeCancelAtPeriodEnd).toBe(false); + } + ); + + it('uses the original period to void the renewal when cancellation shortens the response period', async () => { const user = await createSubscribedUser(); signedInAs(user); - const update = stubStripeUpdate(); + const original = subscription(user, { status: 'past_due' }); + const invoice = renewal(original); + const stripe = stubStripe([original], [invoice]); + const cancel = stripe.cancel.getMockImplementation()!; + stripe.cancel.mockImplementationOnce(async (id, params) => ({ + ...(await cancel(id, params)), + items: { + ...original.items, + data: original.items.data.map((item) => ({ ...item, current_period_end: unix(0) })), + }, + })); + const response = await callRoute(cancelRoute, cancelRequest()); + expect(response.status).toBe(200); + expect(await readData(response)).toMatchObject({ voidedInvoices: [invoice.id] }); + expect(invoice.status).toBe('void'); + }); + it.each(['past_due', 'unpaid'] as const)( + 'offers cancellation for already scheduled %s and preserves another paid subscription', + async (status) => { + const user = await createSubscribedUser({ + stripeCancelAtPeriodEnd: true, + subscriptionStatus: + status === 'past_due' + ? BillingSubscriptionStatus.PAST_DUE + : BillingSubscriptionStatus.UNPAID, + }); + signedInAs(user); + const unpaid = subscription(user, { status }); + const paid = subscription(user, { id: 'sub_still_paid', cancel_at_period_end: true }); + const stripe = stubStripe([unpaid, paid]); + const overview = await billingRoute(); + expect(overview.status).toBe(200); + expect(await readData(overview)).toMatchObject({ + cancelAvailable: true, + cancelIsImmediate: true, + }); + expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe( + 200 + ); + expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(unpaid.id, expect.anything()); + expect(stripe.update).not.toHaveBeenCalled(); + const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE); + expect(after.stripeSubscriptionId).toBe(paid.id); + expect(after.stripeCancelAtPeriodEnd).toBe(true); + expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe( + unpaid.id + ); + } + ); + + it('expires only the incomplete subscription matching an owned open Checkout session', async () => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user, { status: 'incomplete' }); + const other = subscription(user, { id: 'sub_other_checkout', status: 'incomplete' }); + const stripe = stubStripe([original, other]); + stripe.sessions.push( + { + id: 'cs_other', + customer: user.stripeCustomerId, + subscription: other.id, + status: 'open', + } as Stripe.Checkout.Session, + { + id: 'cs_match', + customer: user.stripeCustomerId, + subscription: { id: original.id }, + status: 'open', + } as Stripe.Checkout.Session + ); const response = await callRoute( cancelRoute, - cancelRequest({ reason: 'OTHER', note: 'x'.repeat(501) }) + cancelRequest({ reason: 'OTHER', note: 'Checkout abandoned' }) ); - - expect(response.status).toBe(400); - expect(update).not.toHaveBeenCalled(); - }); - - // The whole point: one Stripe write with the answer attached, one row that - // keeps the answer on our side, and the user row updated before any webhook. - it('schedules the cancellation, records the reason and syncs the user row', async () => { - const user = await createSubscribedUser(); - signedInAs(user); - const periodEnd = unix(12 * DAY); - const update = stubStripeUpdate({ customer: user.stripeCustomerId, periodEnd }); - - const response = await callRoute( - cancelRoute, - cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload for 16x9 and 9x16. ' }) + expect(response.status).toBe(200); + expect(await readData(response)).toMatchObject({ + canceledImmediately: true, + status: 'incomplete_expired', + }); + expect(stripe.sessionList).toHaveBeenCalledWith( + expect.objectContaining({ customer: user.stripeCustomerId, status: 'open' }) ); - - expect(response.status).toBe(200); - const data = await readData<{ cancelAtPeriodEnd: boolean; periodEnd: string | null }>(response); - expect(data.cancelAtPeriodEnd).toBe(true); - expect(data.periodEnd).toBe(new Date(periodEnd * 1000).toISOString()); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, { - cancel_at_period_end: true, - cancellation_details: { feedback: 'missing_features' }, - }); - - const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } }); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ - stripeSubscriptionId: user.stripeSubscriptionId, - reason: 'MISSING_FEATURE', - note: 'Bulk upload for 16x9 and 9x16.', - }); - expect(rows[0].periodEnd?.getTime()).toBe(periodEnd * 1000); - - const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); - expect(after.stripeCancelAtPeriodEnd).toBe(true); - expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE); - expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000); + expect(stripe.expire).toHaveBeenCalledExactlyOnceWith('cs_match'); + expect(stripe.cancel).not.toHaveBeenCalled(); + expect(stripe.sessions[0].status).toBe('open'); + expect((await db.subscriptionCancellation.findFirstOrThrow()).note).toBe('Checkout abandoned'); }); - // Skipping the question is allowed and must still cancel. The row is kept - // with no reason so the admin tally can count how often the question is - // skipped rather than pretending those cancellations never happened. - it('cancels with no reason given and records the skip', async () => { + it.each(['past_due', 'incomplete'] as const)( + 'retries failed %s cleanup without recanceling or overwriting its reason', + async (status) => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user, { status }); + const invoice = renewal(original); + const stripe = stubStripe([original], [invoice]); + if (status === 'incomplete') { + stripe.sessions.push({ + id: 'cs_retry', + customer: user.stripeCustomerId, + subscription: original.id, + status: 'open', + } as Stripe.Checkout.Session); + } + stripe.voidInvoice.mockRejectedValueOnce(new Error('Invoice cleanup unavailable')); + const first = await callRoute( + cancelRoute, + cancelRequest({ reason: 'OTHER', note: 'Keep my answer' }) + ); + expect(first.status).toBe(500); + expect(invoice.status).toBe('open'); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe( + status === 'incomplete' + ? BillingSubscriptionStatus.INCOMPLETE_EXPIRED + : BillingSubscriptionStatus.CANCELED + ); + const overview = await billingRoute(); + expect(overview.status).toBe(200); + expect(await readData(overview)).toMatchObject({ + cancelAvailable: true, + cancelIsImmediate: true, + }); + const second = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })); + expect(second.status).toBe(200); + expect(await readData(second)).toMatchObject({ + canceledImmediately: true, + voidedInvoices: [invoice.id], + }); + expect(stripe.cancel).toHaveBeenCalledTimes(status === 'incomplete' ? 0 : 1); + expect(stripe.expire).toHaveBeenCalledTimes(status === 'incomplete' ? 1 : 0); + expect(stripe.voidInvoice).toHaveBeenCalledTimes(2); + expect(invoice.status).toBe('void'); + const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ reason: 'OTHER', note: 'Keep my answer' }); + } + ); + + it('stops retrying a retained receivable without voiding it', async () => { const user = await createSubscribedUser(); signedInAs(user); - const update = stubStripeUpdate(); - - const response = await callRoute(cancelRoute, cancelRequest({})); - + const original = subscription(user, { status: 'unpaid' }); + const invoice = renewal(original, { billing_reason: 'manual' }); + const stripe = stubStripe([original], [invoice]); + const response = await callRoute(cancelRoute, cancelRequest()); expect(response.status).toBe(200); - expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, { - cancel_at_period_end: true, - cancellation_details: {}, + expect(await readData(response)).toMatchObject({ + canceledImmediately: true, + voidedInvoices: [], }); - - const row = await db.subscriptionCancellation.findFirstOrThrow({ - where: { userId: user.id }, - }); - expect(row.reason).toBeNull(); - expect(row.note).toBeNull(); - }); - - // A note under an answer that does not ask for one is still accepted by the - // API; the dialog is what hides the box, and the route must not depend on it. - it('keeps a note on any reason and drops an empty one', async () => { - const user = await createSubscribedUser(); - signedInAs(user); - stubStripeUpdate(); - - const response = await callRoute( - cancelRoute, - cancelRequest({ reason: 'PROJECT_ENDED', note: ' ' }) + expect(stripe.voidInvoice).not.toHaveBeenCalled(); + expect(stripe.invoiceUpdate).toHaveBeenCalledWith( + invoice.id, + expect.objectContaining({ auto_advance: false }) ); - - expect(response.status).toBe(200); - const row = await db.subscriptionCancellation.findFirstOrThrow({ - where: { userId: user.id }, - }); - expect(row.reason).toBe('PROJECT_ENDED'); - expect(row.note).toBeNull(); + expect(invoice.status).toBe('open'); + expect(invoice.auto_advance).toBe(false); }); - it('rejects a note that is not text', async () => { + it.each([{}, { reason: 'PROJECT_ENDED', note: ' ' }])( + 'allows skipping feedback and trims empty notes %#', + async (body) => { + const user = await createSubscribedUser(); + signedInAs(user); + stubStripe([subscription(user)]); + expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(200); + expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({ + reason: 'reason' in body ? body.reason : null, + note: null, + }); + } + ); + + it('lets only one of two concurrent paid requests through', async () => { const user = await createSubscribedUser(); signedInAs(user); - const update = stubStripeUpdate(); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER', note: 42 })); - - expect(response.status).toBe(400); - expect(update).not.toHaveBeenCalled(); - }); - - // Two requests racing for the same subscription must produce one Stripe - // write and one reason row, or the admin tally counts a churn twice. - it('lets only one of two concurrent requests through', async () => { - const user = await createSubscribedUser(); - signedInAs(user); - const update = stubStripeUpdate({ customer: user.stripeCustomerId }); - - const [first, second] = await Promise.all([ + const stripe = stubStripe([subscription(user)]); + const results = await Promise.all([ callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })), callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })), ]); - - expect([first.status, second.status].sort()).toEqual([200, 409]); - expect(update).toHaveBeenCalledTimes(1); + expect(results.map((response) => response.status).sort()).toEqual([200, 409]); + expect(stripe.update).toHaveBeenCalledTimes(1); expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1); }); - // The reason row and the local flag must survive a sync that blows up: the - // webhook rewrites the same state later, the answer would be gone for good. - it('keeps the cancellation and the reason when the local sync fails', async () => { + it('keeps the cancellation and reason when customer-wide sync fails', async () => { const user = await createSubscribedUser(); signedInAs(user); - vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({ - subscriptions: { - // No `items` at all: the sync reads `items.data` and throws. - update: vi.fn(async (id: string) => ({ id, customer: user.stripeCustomerId })), - list: vi.fn(async () => ({ data: [] })), + const original = subscription(user); + const stripe = stubStripe([original]); + stripe.list + .mockResolvedValueOnce({ data: [original], has_more: false }) + .mockRejectedValueOnce(new Error('Sync unavailable')); + expect( + (await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }))).status + ).toBe(200); + expect((await db.subscriptionCancellation.findFirstOrThrow()).reason).toBe('PRICE_OR_BILLING'); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(true); + }); + + it.each([true, false])( + 'releases the paid claim when Stripe rejects the update (invalid request: %s)', + async (invalidRequest) => { + const user = await createSubscribedUser(); + signedInAs(user); + const stripe = stubStripe([subscription(user)]); + const error = invalidRequest + ? Object.assign(new Error('No such subscription'), { type: 'StripeInvalidRequestError' }) + : new Error('Stripe unavailable'); + stripe.update.mockRejectedValueOnce(error); + const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })); + expect(response.status).toBe(invalidRequest ? 409 : 500); + if (invalidRequest) expect(await readError(response)).toMatch(/Manage Subscription/); + expect(await db.subscriptionCancellation.count()).toBe(0); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd + ).toBe(false); + } + ); +}); + +describe('repeated and concurrent cancellation reasons', () => { + it('records a new immediate cancellation after an earlier scheduled cancellation was resumed', async () => { + const user = await createSubscribedUser(); + signedInAs(user); + const original = subscription(user, { status: 'past_due' }); + const stripe = stubStripe([original]); + await db.subscriptionCancellation.create({ + data: { + userId: user.id, + stripeSubscriptionId: original.id, + reason: 'PRICE_OR_BILLING', + note: 'Previous canceled cycle', + createdAt: new Date(Date.now() - 40 * DAY * 1000), + periodEnd: new Date(Date.now() - 30 * DAY * 1000), }, }); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' })); - + const response = await callRoute( + cancelRoute, + cancelRequest({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }) + ); expect(response.status).toBe(200); - const row = await db.subscriptionCancellation.findFirstOrThrow({ where: { userId: user.id } }); - expect(row.reason).toBe('PRICE_OR_BILLING'); - const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); - expect(after.stripeCancelAtPeriodEnd).toBe(true); + expect(stripe.cancel).toHaveBeenCalledTimes(1); + const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } }); + expect(rows).toHaveLength(2); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }), + ]) + ); }); - // A subscription Stripe no longer knows is the customer's state, not a - // server fault: a 409 with a pointer to the portal, and the claim handed back - // so the button still works once the webhook catches up. - it('answers 409 and releases the claim when Stripe rejects the subscription id', async () => { - const user = await createSubscribedUser(); + it('records only one reason when concurrent requests cancel a nonmirrored paid subscription', async () => { + const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true }); signedInAs(user); - vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({ - subscriptions: { - update: vi.fn(async () => { - throw Object.assign(new Error('No such subscription'), { - type: 'StripeInvalidRequestError', - }); - }), - list: vi.fn(async () => ({ data: [] })), - }, + const scheduled = subscription(user); + const other = subscription(user, { + id: 'sub_other_paid', + cancel_at_period_end: false, + created: unix(-60 * DAY), }); - - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })); - - expect(response.status).toBe(409); - expect(await readError(response)).toMatch(/Manage Subscription/); - expect(await db.subscriptionCancellation.count()).toBe(0); - const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); - expect(after.stripeCancelAtPeriodEnd).toBe(false); - }); - - it('does not record a reason and releases the claim when Stripe is down', async () => { - const user = await createSubscribedUser(); - signedInAs(user); - vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({ - subscriptions: { - update: vi.fn(async () => { - throw new Error('No such subscription'); - }), - list: vi.fn(async () => ({ data: [] })), - }, + const stripe = stubStripe([scheduled, other]); + const update = stripe.update.getMockImplementation()!; + let entered = 0; + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; }); + const timeout = setTimeout(release, 1000); + stripe.update.mockImplementation(async (id, params) => { + entered += 1; + if (entered === 2) release(); + await barrier; + return update(id, params); + }); + try { + const responses = await Promise.all([ + callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })), + callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })), + ]); + expect(entered).toBe(2); + expect(responses.map((response) => response.status)).toEqual([200, 200]); + expect( + await db.subscriptionCancellation.count({ + where: { userId: user.id, stripeSubscriptionId: other.id }, + }) + ).toBe(1); + } finally { + clearTimeout(timeout); + } + }); +}); - const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })); +describe('cancellation analytics through the route', () => { + beforeEach(() => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + }); - expect(response.status).toBe(500); - expect(await db.subscriptionCancellation.count()).toBe(0); - const after = await db.user.findUniqueOrThrow({ where: { id: user.id } }); - expect(after.stripeCancelAtPeriodEnd).toBe(false); + 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-entitlement.test.ts b/tests/api/billing-entitlement.test.ts new file mode 100644 index 0000000..43c050a --- /dev/null +++ b/tests/api/billing-entitlement.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { + buildBillingAccessWhereInput, + buildExpiredBillingWhereInput, + getBillingAccessEndDate, + getStorageCleanupEligibleAt, + hasBillingAccess, + isPaidTier, + startCardlessTrial, + syncStripeCustomerSubscriptions, +} from '@/lib/billing'; +import { getStripe } from '@/lib/stripe'; +import { db } from '../helpers/db'; +import { createUser } from '../factories'; + +// Uses the API project's real database and reset hooks. Run only when no other API suite uses it. +const CUSTOMER_ID = 'cus_entitlement_regression'; +const SUBSCRIPTION_ID = 'sub_entitlement_regression'; +const PRICE_ID = 'price_entitlement_regression'; +const TRIAL_START = new Date('2026-10-01T00:00:00.000Z'); +const TRIAL_END = new Date('2026-10-08T00:00:00.000Z'); +const CANCELED_AT = new Date('2026-10-02T00:00:00.000Z'); +const REPORTED_PERIOD_END = new Date('2026-11-01T00:00:00.000Z'); + +function subscription(overrides: Partial = {}): Stripe.Subscription { + return { + id: SUBSCRIPTION_ID, + customer: CUSTOMER_ID, + status: 'canceled', + created: Date.parse('2026-09-01T00:00:00.000Z') / 1000, + trial_end: null, + ended_at: CANCELED_AT.getTime() / 1000, + canceled_at: CANCELED_AT.getTime() / 1000, + cancel_at: null, + cancel_at_period_end: false, + // Deliberately no top-level period: the regression depends on the item-only payload. + items: { + data: [ + { + price: { id: PRICE_ID }, + current_period_start: TRIAL_START.getTime() / 1000, + current_period_end: REPORTED_PERIOD_END.getTime() / 1000, + }, + ], + }, + ...overrides, + } as Stripe.Subscription; +} + +function stubSubscription(value: Stripe.Subscription) { + const list = vi.fn(async () => ({ data: [value] })); + vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe); + return list; +} + +async function startDeferredTrial() { + const user = await createUser({ + subscriptionStatus: 'PAST_DUE', + stripeCustomerId: CUSTOMER_ID, + stripeSubscriptionId: SUBSCRIPTION_ID, + stripePriceId: PRICE_ID, + stripeCurrentPeriodEnd: REPORTED_PERIOD_END, + trialEndsAt: null, + billingTrialConsumedAt: null, + }); + expect(await startCardlessTrial(user.id, TRIAL_START)).toBe(true); + const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(stored.trialEndsAt).toEqual(TRIAL_END); + expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START); + return user.id; +} + +async function matchingAccessUsers(userId: string, now: Date) { + return db.user.findMany({ + where: { AND: [{ id: userId }, buildBillingAccessWhereInput(now)] }, + select: { id: true }, + }); +} + +async function matchingCleanupUsers(userId: string, now: Date) { + return db.user.findMany({ + where: { AND: [{ id: userId }, buildExpiredBillingWhereInput(now)] }, + select: { id: true }, + }); +} + +describe('billing entitlement and retention after subscription sync', () => { + beforeEach(() => { + vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); + vi.stubEnv('STRIPE_PRICE_ID', PRICE_ID); + // Mock only Date so PostgreSQL sockets and query timers keep running normally. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(TRIAL_START); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Catches restoring `hasAccess || hasActiveTrial(preservedTrialEnd)` when writing the cutoff. + it('preserves a deferred trial without granting paid access to the canceled unpaid period', async () => { + const userId = await startDeferredTrial(); + const list = stubSubscription(subscription()); + vi.setSystemTime(CANCELED_AT); + + await syncStripeCustomerSubscriptions(CUSTOMER_ID); + + expect(list).toHaveBeenCalledWith({ customer: CUSTOMER_ID, status: 'all', limit: 100 }); + const stored = await db.user.findUniqueOrThrow({ where: { id: userId } }); + expect(stored.subscriptionStatus).toBe('CANCELED'); + expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END); + expect(stored.trialEndsAt).toEqual(TRIAL_END); + expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START); + expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT); + + await Promise.all( + [ + { now: CANCELED_AT, expected: true }, + { now: new Date('2026-10-07T23:59:59.999Z'), expected: true }, + { now: TRIAL_END, expected: false }, + { now: new Date('2026-10-09T00:00:00.000Z'), expected: false }, + ].map(async ({ now, expected }) => { + expect(isPaidTier(stored, now)).toBe(false); + expect(hasBillingAccess(stored, now)).toBe(expected); + expect(await matchingAccessUsers(userId, now)).toEqual(expected ? [{ id: userId }] : []); + }) + ); + }); + + // Catches choosing the raw unpaid period, choosing the earlier expiry, or requiring that raw period to lapse in SQL. + it.each([ + { + label: 'trial outlasts the subscription', + subscriptionEnd: CANCELED_AT, + lastEntitlementEnd: TRIAL_END, + cleanupAt: new Date('2026-10-23T00:00:00.000Z'), + }, + { + label: 'subscription outlasts the trial', + subscriptionEnd: new Date('2026-10-12T00:00:00.000Z'), + lastEntitlementEnd: new Date('2026-10-12T00:00:00.000Z'), + cleanupAt: new Date('2026-10-27T00:00:00.000Z'), + }, + ])('retains storage until the last legitimate expiry plus 15 days: $label', async (scenario) => { + const userId = await startDeferredTrial(); + stubSubscription( + subscription({ + ended_at: scenario.subscriptionEnd.getTime() / 1000, + canceled_at: scenario.subscriptionEnd.getTime() / 1000, + }) + ); + vi.setSystemTime(scenario.subscriptionEnd); + + await syncStripeCustomerSubscriptions(CUSTOMER_ID); + + const stored = await db.user.findUniqueOrThrow({ where: { id: userId } }); + expect(stored.billingAccessEndedAt).toEqual(scenario.subscriptionEnd); + expect(stored.trialEndsAt).toEqual(TRIAL_END); + expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END); + expect(getBillingAccessEndDate(stored)).toEqual(scenario.lastEntitlementEnd); + expect(getStorageCleanupEligibleAt(stored)).toEqual(scenario.cleanupAt); + expect(hasBillingAccess(stored, scenario.cleanupAt)).toBe(false); + const [before, at] = await Promise.all([ + matchingCleanupUsers(userId, new Date(scenario.cleanupAt.getTime() - 1)), + matchingCleanupUsers(userId, scenario.cleanupAt), + ]); + expect(before).toEqual([]); + expect(at).toEqual([{ id: userId }]); + }); + + // Catches replacing persisted trial history with keepUnexpiredTrial on a terminal resync. + it('keeps expired trial history and the retention deadline across repeated terminal syncs', async () => { + const userId = await startDeferredTrial(); + stubSubscription(subscription()); + vi.setSystemTime(CANCELED_AT); + await syncStripeCustomerSubscriptions(CUSTOMER_ID); + + for (const now of ['2026-10-09T00:00:00.000Z', '2026-10-20T00:00:00.000Z']) { + vi.setSystemTime(new Date(now)); + await syncStripeCustomerSubscriptions(CUSTOMER_ID); + + const stored = await db.user.findUniqueOrThrow({ where: { id: userId } }); + expect(stored.trialEndsAt).toEqual(TRIAL_END); + expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START); + expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT); + expect(isPaidTier(stored)).toBe(false); + expect(hasBillingAccess(stored)).toBe(false); + expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-10-23T00:00:00.000Z')); + expect(await matchingCleanupUsers(userId, new Date(now))).toEqual([]); + } + + expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([ + { id: userId }, + ]); + }); + + // Catches treating scheduled cancellation as immediate termination of a paid subscription. + it('keeps a paid scheduled cancellation accessible after the cardless trial expires', async () => { + const userId = await startDeferredTrial(); + stubSubscription( + subscription({ + status: 'active', + ended_at: null, + cancel_at_period_end: true, + cancel_at: REPORTED_PERIOD_END.getTime() / 1000, + }) + ); + vi.setSystemTime(CANCELED_AT); + await syncStripeCustomerSubscriptions(CUSTOMER_ID); + + const stored = await db.user.findUniqueOrThrow({ where: { id: userId } }); + const afterTrial = new Date('2026-10-09T00:00:00.000Z'); + expect(stored.subscriptionStatus).toBe('ACTIVE'); + expect(stored.stripeCancelAtPeriodEnd).toBe(true); + expect(stored.billingAccessEndedAt).toBeNull(); + expect(stored.trialEndsAt).toEqual(TRIAL_END); + expect(isPaidTier(stored, afterTrial)).toBe(true); + expect(hasBillingAccess(stored, afterTrial)).toBe(true); + expect(await matchingAccessUsers(userId, afterTrial)).toEqual([{ id: userId }]); + expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([]); + expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-11-16T00:00:00.000Z')); + }); +}); 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/cancel-subscription-dialog.test.tsx b/tests/component/cancel-subscription-dialog.test.tsx index 795d317..837c15c 100644 --- a/tests/component/cancel-subscription-dialog.test.tsx +++ b/tests/component/cancel-subscription-dialog.test.tsx @@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; function renderDialog( - overrides: { periodEnd?: string | null; isTrial?: boolean; confirmResult?: boolean } = {} + overrides: { + periodEnd?: string | null; + isTrial?: boolean; + canceledImmediately?: boolean; + confirmResult?: boolean; + } = {} ) { const onConfirm = vi.fn(async () => overrides.confirmResult ?? true); const onOpenChange = vi.fn(); @@ -16,6 +21,7 @@ function renderDialog( overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd } isTrial={overrides.isTrial ?? false} + canceledImmediately={overrides.canceledImmediately} onConfirm={onConfirm} /> ); @@ -108,6 +114,29 @@ describe('CancelSubscriptionDialog', () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); + it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => { + renderDialog({ canceledImmediately: true }); + + expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument(); + expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent( + 'Canceling does not extend access to your workspaces.' + ); + expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent( + 'charges for prior service and other items may still be owed.' + ); + expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(5); + }); + + it('retains the scheduled period-end explanation', () => { + renderDialog(); + + expect(screen.getByText(/Everything stays on until/)).toHaveTextContent( + new Date('2026-10-01T00:00:00.000Z').toLocaleDateString() + ); + expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument(); + }); + it('names the trial instead of the subscription while still trialing', () => { renderDialog({ isTrial: true }); diff --git a/tests/component/settings-billing.test.tsx b/tests/component/settings-billing.test.tsx new file mode 100644 index 0000000..177afd1 --- /dev/null +++ b/tests/component/settings-billing.test.tsx @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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() + ); + }); +}); + +function renderPastDue(hasBillingAccess: boolean) { + 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: true, + cancelIsImmediate: true, + needsPaymentFix: true, + openInvoice: null, + workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false }, + subscription: { + status: 'PAST_DUE', + label: 'Past due', + hasActiveSubscription: false, + hasRecoverableSubscription: true, + hasActiveTrial: false, + hasBillingAccess, + currentPeriodEnd: '2026-10-08T12:00:00Z', + trialEndsAt: null, + cancelAtPeriodEnd: false, + cancelAt: null, + }, + }, + }), + }; + }) + ); + render(); +} + +describe('past-due access in billing settings', () => { + it('opens immediate unpaid cancellation copy and waits for confirmation', async () => { + const user = userEvent.setup(); + renderPastDue(true); + + await user.click(await screen.findByRole('button', { name: 'Cancel subscription' })); + + const dialog = within(screen.getByRole('dialog')); + expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent( + 'Canceling does not extend access to your workspaces.' + ); + expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent( + 'charges for prior service and other items may still be owed.' + ); + expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument(); + expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled(); + expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false); + + await user.click(dialog.getByRole('button', { name: 'Keep subscription' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false); + }); + + it('shows continued workspace access during payment grace without an active trial', async () => { + renderPastDue(true); + + expect( + await screen.findByText('Workspace access remains available while you resolve your payment.') + ).toBeInTheDocument(); + expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument(); + expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument(); + }); + + it('shows access has ended when payment grace has expired and no trial remains', async () => { + renderPastDue(false); + + expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument(); + expect( + screen.queryByText('Workspace access remains available while you resolve your payment.') + ).not.toBeInTheDocument(); + expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument(); + }); +}); 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-invoices.test.ts b/tests/unit/lib/billing-invoices.test.ts new file mode 100644 index 0000000..ab8ad14 --- /dev/null +++ b/tests/unit/lib/billing-invoices.test.ts @@ -0,0 +1,409 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { + findCancelableStripeSubscription, + isCurrentSubscriptionInvoice, + voidOpenSubscriptionInvoices, +} from '@/lib/billing'; + +const stripe = vi.hoisted(() => ({ + subscriptions: { list: vi.fn(), retrieve: vi.fn() }, + invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() }, +})); + +vi.mock('@/lib/db', () => ({ db: {} })); +vi.mock('@/lib/stripe', async (importOriginal) => ({ + ...(await importOriginal()), + getStripe: () => stripe, +})); + +const START = 1_800_000_000; +const END = 1_802_592_000; + +function subscription(overrides: Record = {}): Stripe.Subscription { + return { + id: 'sub_target', + customer: 'cus_target', + status: 'past_due', + created: 100, + latest_invoice: 'in_current', + cancel_at: null, + cancel_at_period_end: false, + items: { + data: [ + { + price: { id: 'price_plan' }, + current_period_start: START, + current_period_end: END, + }, + ], + }, + ...overrides, + } as unknown as Stripe.Subscription; +} + +function line(overrides: Record = {}) { + return { + id: 'il_plan', + amount: 1900, + period: { start: START, end: END }, + parent: { + type: 'subscription_item_details', + subscription_item_details: { subscription: 'sub_target', proration: false }, + }, + pricing: { price_details: { price: 'price_plan' } }, + ...overrides, + }; +} + +function invoice(overrides: Record = {}): Stripe.Invoice { + return { + id: 'in_current', + customer: 'cus_target', + status: 'open', + amount_paid: 0, + amount_due: 1900, + billing_reason: 'subscription_cycle', + auto_advance: true, + parent: { subscription_details: { subscription: 'sub_target' } }, + lines: { data: [line()], has_more: false }, + ...overrides, + } as unknown as Stripe.Invoice; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('STRIPE_PRICE_ID', 'price_plan'); + stripe.subscriptions.retrieve.mockResolvedValue(subscription()); + stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false }); + stripe.invoices.list.mockResolvedValue({ data: [], has_more: false }); + stripe.invoices.update.mockResolvedValue({}); + stripe.invoices.voidInvoice.mockResolvedValue({}); +}); + +describe('subscription invoice cleanup', () => { + it.each(['subscription_cycle', 'subscription_create'])( + 'voids a complete unpaid current %s invoice and returns its id', + async (billingReason) => { + const current = invoice({ billing_reason: billingReason }); + stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false }); + + expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true); + await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([ + 'in_current', + ]); + + expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target'); + expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', { + auto_advance: false, + }); + expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current'); + } + ); + + const retainedInvoices: [string, () => Stripe.Invoice][] = [ + [ + 'a different start with the current end', + () => + invoice({ + lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false }, + }), + ], + [ + 'a different end with the current start', + () => + invoice({ + lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false }, + }), + ], + ['an older invoice id', () => invoice({ id: 'in_old' })], + [ + 'an older service period', + () => + invoice({ + lines: { + data: [line({ period: { start: START - 2_592_000, end: START } })], + has_more: false, + }, + }), + ], + [ + 'a mixed invoice containing a manual charge', + () => + invoice({ + lines: { + data: [ + line(), + line({ + id: 'il_manual', + parent: { type: 'invoice_item_details', invoice_item_details: {} }, + }), + ], + has_more: false, + }, + }), + ], + [ + 'a proration', + () => + invoice({ + lines: { + data: [ + line({ + parent: { + type: 'subscription_item_details', + subscription_item_details: { subscription: 'sub_target', proration: true }, + }, + }), + ], + has_more: false, + }, + }), + ], + ['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })], + ['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })], + [ + 'a different price', + () => + invoice({ + lines: { + data: [line({ pricing: { price_details: { price: 'price_other' } } })], + has_more: false, + }, + }), + ], + [ + 'a line belonging to a different subscription', + () => + invoice({ + lines: { + data: [ + line({ + parent: { + type: 'subscription_item_details', + subscription_item_details: { subscription: 'sub_other', proration: false }, + }, + }), + ], + has_more: false, + }, + }), + ], + ['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })], + ['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })], + ]; + + it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => { + const retained = makeInvoice(); + stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false }); + + expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false); + await expect( + voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription()) + ).resolves.toEqual([]); + expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, { + auto_advance: false, + }); + expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled(); + expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled(); + }); + + it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => { + stripe.invoices.list + .mockResolvedValueOnce({ + data: [ + invoice({ id: 'in_old' }), + invoice({ + id: 'in_foreign', + parent: { subscription_details: { subscription: 'sub_other' } }, + }), + ], + has_more: true, + }) + .mockResolvedValueOnce({ data: [invoice()], has_more: false }); + + await expect( + voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription()) + ).resolves.toEqual(['in_current']); + expect(stripe.invoices.list.mock.calls).toEqual([ + [{ customer: 'cus_target', status: 'open', limit: 100 }], + [{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }], + ]); + expect(stripe.invoices.update.mock.calls).toEqual([ + ['in_old', { auto_advance: false }], + ['in_current', { auto_advance: false }], + ]); + expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current'); + }); + + it('voids a current invoice even when collection was already paused before a retry', async () => { + stripe.invoices.list.mockResolvedValue({ + data: [invoice({ auto_advance: false })], + has_more: false, + }); + + await expect( + voidOpenSubscriptionInvoices( + 'cus_target', + 'sub_target', + subscription({ + status: 'canceled', + latest_invoice: { id: 'in_current' }, + }) + ) + ).resolves.toEqual(['in_current']); + expect(stripe.invoices.update).not.toHaveBeenCalled(); + expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current'); + }); + + it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)( + 'propagates the Stripe %s failure rather than claiming successful cleanup', + async (operation) => { + const failure = new Error(`Stripe ${operation} failed`); + stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false }); + const failingCall = + operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation]; + failingCall.mockRejectedValueOnce(failure); + + await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure); + expect(failingCall).toHaveBeenCalledTimes(1); + if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled(); + } + ); + + it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => { + await expect( + voidOpenSubscriptionInvoices( + 'cus_target', + 'sub_target', + subscription({ + customer: { id: 'cus_other' }, + }) + ) + ).rejects.toThrow('Subscription customer mismatch'); + expect(stripe.invoices.list).not.toHaveBeenCalled(); + expect(stripe.invoices.update).not.toHaveBeenCalled(); + expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled(); + }); +}); + +describe('cancellation candidate selection', () => { + it.each([ + { cancel_at: END, cancel_at_period_end: false }, + { cancel_at: null, cancel_at_period_end: true }, + ])( + 'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one', + async (schedule) => { + stripe.subscriptions.list + .mockResolvedValueOnce({ + data: [ + subscription({ + id: 'sub_newer', + status: 'active', + created: 200, + ...schedule, + }), + ], + has_more: true, + }) + .mockResolvedValueOnce({ + data: [ + subscription({ + id: 'sub_older', + status: 'active', + created: 100, + }), + ], + has_more: false, + }); + + expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older'); + expect(stripe.subscriptions.list.mock.calls).toEqual([ + [{ customer: 'cus_target', status: 'all', limit: 100 }], + [{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }], + ]); + expect(stripe.invoices.list).not.toHaveBeenCalled(); + } + ); + + it.each(['past_due', 'unpaid', 'incomplete'] as const)( + 'still selects a scheduled %s subscription for immediate cancellation', + async (status) => { + stripe.subscriptions.list.mockResolvedValue({ + data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })], + has_more: false, + }); + + expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target'); + expect(stripe.invoices.list).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['a current invoice still awaiting void', { auto_advance: false }], + ['an older invoice still collecting', { id: 'in_old', auto_advance: true }], + ])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => { + stripe.subscriptions.list.mockResolvedValue({ + data: [subscription({ status: 'canceled' })], + has_more: false, + }); + stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false }); + + expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target'); + expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({ + customer: 'cus_target', + status: 'open', + limit: 100, + }); + expect(stripe.invoices.update).not.toHaveBeenCalled(); + expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled(); + }); + + it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => { + stripe.subscriptions.list.mockResolvedValue({ + data: [subscription({ status: 'canceled' })], + has_more: false, + }); + stripe.invoices.list.mockResolvedValue({ + data: [ + invoice({ id: 'in_old', auto_advance: false }), + invoice({ + id: 'in_foreign', + parent: { subscription_details: { subscription: 'sub_other' } }, + }), + ], + has_more: false, + }); + + await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull(); + }); + + it.each(['active', 'canceled'] as const)( + 'ignores a %s subscription for a different product', + async (status) => { + stripe.subscriptions.list.mockResolvedValue({ + data: [ + subscription({ + status, + items: { data: [{ price: { id: 'price_other' } }] }, + }), + ], + has_more: false, + }); + + await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull(); + expect(stripe.invoices.list).not.toHaveBeenCalled(); + } + ); + + it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => { + const failure = new Error('Stripe invoice lookup failed'); + stripe.subscriptions.list.mockResolvedValue({ + data: [subscription({ status: 'canceled' })], + has_more: false, + }); + stripe.invoices.list.mockRejectedValueOnce(failure); + + await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure); + }); +}); diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index ddaec7d..7de40a4 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, @@ -33,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() }, @@ -152,7 +157,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 +172,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 +188,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -185,7 +199,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 +218,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -212,6 +231,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -227,6 +247,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -239,6 +260,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -250,7 +272,11 @@ describe('isPaidTier', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.FREE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -366,7 +392,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 +406,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( @@ -393,7 +451,7 @@ describe('hasBillingAccess', () => { }); describe('getBillingAccessEndDate', () => { - it('prefers billingAccessEndedAt over every other date', () => { + it('keeps an independent trial beyond the subscription cutoff', () => { const ended = new Date('2026-01-10T00:00:00Z'); const result = getBillingAccessEndDate( subject({ @@ -402,10 +460,10 @@ describe('getBillingAccessEndDate', () => { trialEndsAt: new Date('2026-03-01T00:00:00Z'), }) ); - expect(result).toBe(ended); + expect(result).toEqual(new Date('2026-03-01T00:00:00Z')); }); - it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => { + it('keeps a longer trial when billing has not been marked ended', () => { const periodEnd = new Date('2026-02-01T00:00:00Z'); const result = getBillingAccessEndDate( subject({ @@ -413,7 +471,7 @@ describe('getBillingAccessEndDate', () => { trialEndsAt: new Date('2026-03-01T00:00:00Z'), }) ); - expect(result).toBe(periodEnd); + expect(result).toEqual(new Date('2026-03-01T00:00:00Z')); }); it('falls back to trialEndsAt when there is no paid period', () => { @@ -452,7 +510,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 } }], + }, ], }); }); @@ -472,36 +537,18 @@ describe('buildBillingAccessWhereInput', () => { }); describe('buildExpiredBillingWhereInput', () => { - it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => { - const cutoff = new Date('2025-12-31T00:00:00.000Z'); - - expect(buildExpiredBillingWhereInput(NOW)).toEqual({ - AND: [ - { subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } }, - { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] }, - { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] }, - { - OR: [ - { billingAccessEndedAt: { lte: cutoff } }, - { AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] }, - ], - }, - ], + it('requires the entire trial retention window before deleting an inactive account', () => { + const where = buildExpiredBillingWhereInput(NOW) as { + AND: Array>; + }; + expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } }); + expect(where.AND[1]).toEqual({ + OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }], }); }); - // The NOT form this replaced could not express "no access" for a row whose date columns are - // empty, because SQL turns a comparison against NULL into unknown rather than false. Every - // branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts - // proves it against a real database; this only guards the shape. - it('admits a null trial and a null period end as expired rather than skipping the row', () => { - const where = buildExpiredBillingWhereInput(NOW) as { - AND: Array<{ OR?: Array> }>; - }; - expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null }); - expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null }); - }); - + // Real SQL behavior with null dates and future unpaid periods is covered by the + // API cleanup and entitlement suites; the unit check guards the retention boundary. it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } }); @@ -808,12 +855,98 @@ 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(); 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(); @@ -1451,31 +1584,77 @@ 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 + ); + }); + + // 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 () => { @@ -1627,10 +1806,13 @@ describe('database backed billing helpers', () => { stripeSub({ status: 'incomplete', current_period_end: null }) ); - expect(updateData().billingAccessEndedAt).toBeNull(); + expect(updateData().billingAccessEndedAt).toEqual(NOW); + expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual( + new Date(NOW.getTime() + 19 * DAY_MS) + ); }); - it('clears a trial that has already run out', async () => { + it('preserves an expired trial for the storage retention calculation', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS), @@ -1641,7 +1823,7 @@ describe('database backed billing helpers', () => { stripeSub({ status: 'incomplete', current_period_end: null }) ); - expect(updateData().trialEndsAt).toBeNull(); + expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS)); expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date); }); @@ -1705,7 +1887,8 @@ describe('database backed billing helpers', () => { await markSubscriptionCanceledByCustomerId('cus_1'); expect(updateData().trialEndsAt).toBe(trialEndsAt); - expect(updateData().billingAccessEndedAt).toBeNull(); + expect(updateData().billingAccessEndedAt).toEqual(NOW); + expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true); }); it('still ends access when the trial has already run out', async () => { @@ -1716,7 +1899,7 @@ describe('database backed billing helpers', () => { await markSubscriptionCanceledByCustomerId('cus_1'); - expect(updateData().trialEndsAt).toBeNull(); + expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS)); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime()); });