Merge pull request #77 from yusufipk/fix/stripe-billing-lifecycle

fix(billing): stop collection after unpaid cancellation and preserve paid access
This commit is contained in:
Yusuf İpek
2026-09-08 16:56:06 +03:00
committed by GitHub
24 changed files with 3218 additions and 456 deletions
@@ -33,6 +33,25 @@ import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons'; 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 { interface NotificationSettings {
telegramChatId: string | null; telegramChatId: string | null;
telegramEnabled: boolean; telegramEnabled: boolean;
@@ -51,6 +70,17 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured'; status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean; checkoutAvailable: boolean;
portalAvailable: 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: { subscription: {
status: string; status: string;
label: string; label: string;
@@ -260,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
); );
const handleBillingRedirect = useCallback( 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'); setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try { try {
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow ? { flow } : {}),
}); });
const data = await res.json(); const data = await res.json();
@@ -333,7 +367,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: null; : null;
showMessage( showMessage(
'success', 'success',
endsOn 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 on ${endsOn}. You keep full access until then.`
: 'Your subscription ends at the close of the current period.' : 'Your subscription ends at the close of the current period.'
); );
@@ -458,12 +494,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{billing.subscription.hasActiveSubscription {billing.subscription.hasActiveSubscription
? hasScheduledCancellation ? hasScheduledCancellation
? billing.subscription.hasActiveTrial ? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.' ? 'Trial canceled. Access remains active until the trial ends.'
: 'Subscription canceled. Access remains active until the end of the current billing period.' : 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.' : 'Paid account with workspace creation unlocked.'
: billing.subscription.hasActiveTrial : billing.subscription.hasActiveTrial
? 'Free trial, no card required.' ? 'Free trial, no card required.'
: billing.subscription.hasBillingAccess
? 'Workspace access remains available while you resolve your payment.'
: 'Billing access has ended.'} : 'Billing access has ended.'}
</p> </p>
</div> </div>
@@ -478,11 +516,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
!billing.subscription.hasActiveSubscription ? ( !billing.subscription.hasActiveSubscription ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"> <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
Your latest payment didn&apos;t go through. Update your payment method to keep Your latest payment didn&apos;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.
</p> </p>
) : null} ) : null}
{billing.subscription.hasActiveTrial && {billing.subscription.status === 'TRIALING' &&
billing.subscription.trialEndsAt && billing.subscription.trialEndsAt &&
hasScheduledCancellation ? ( hasScheduledCancellation ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive"> <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
@@ -501,7 +539,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{hasScheduledCancellation && billing.subscription.cancelAt ? ( {hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Cancellation was scheduled on{' '} Cancellation takes effect on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}. {new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p> </p>
) : null} ) : null}
@@ -527,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div> </div>
) : null} ) : null}
{billing.openInvoice ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
<p className="text-sm font-semibold text-destructive">
A payment of{' '}
{formatInvoiceAmount(
billing.openInvoice.amountDue,
billing.openInvoice.currency
)}{' '}
did not go through
</p>
<p className="text-sm text-muted-foreground">
{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.
</p>
{billing.subscription.billingAccessEndedAt ? (
<p className="text-sm text-muted-foreground">
{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.`}
</p>
) : null}
{billing.openInvoice.hostedInvoiceUrl ? (
<a
href={billing.openInvoice.hostedInvoiceUrl}
target="_blank"
rel="noreferrer"
className="inline-block text-sm font-medium text-primary hover:underline"
>
View and pay this invoice
</a>
) : null}
</div>
) : null}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? ( {billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
<Button <Button
onClick={() => handleBillingRedirect('/api/billing/portal')} onClick={() =>
handleBillingRedirect(
'/api/billing/portal',
billing.needsPaymentFix ? 'payment_method_update' : undefined
)
}
disabled={billingAction !== null} disabled={billingAction !== null}
> >
{billingAction === 'portal' ? ( {billingAction === 'portal' ? (
@@ -548,9 +630,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{/* Beside the portal button, not inside it. Someone who came to {/* Beside the portal button, not inside it. Someone who came to
cancel should not have to guess that "Manage" is the way, and cancel should not have to guess that "Manage" is the way, and
the portal cannot ask why they are leaving. */} the portal cannot ask why they are leaving. */}
{billing.subscription.hasActiveSubscription && {billing.cancelAvailable ? (
billing.portalAvailable &&
!hasScheduledCancellation ? (
<Button <Button
variant="ghost" variant="ghost"
className="text-muted-foreground" className="text-muted-foreground"
@@ -603,6 +683,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
onOpenChange={setCancelDialogOpen} onOpenChange={setCancelDialogOpen}
periodEnd={billing.subscription.currentPeriodEnd} periodEnd={billing.subscription.currentPeriodEnd}
isTrial={billing.subscription.status === 'TRIALING'} isTrial={billing.subscription.status === 'TRIALING'}
canceledImmediately={billing.cancelIsImmediate}
onConfirm={handleCancelSubscription} onConfirm={handleCancelSubscription}
/> />
) : null} ) : null}
+9 -5
View File
@@ -3,7 +3,7 @@ import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { import {
CANCELLATION_NOTE_MAX_LENGTH, CANCELLATION_NOTE_MAX_LENGTH,
cancelSubscriptionAtPeriodEnd, cancelSubscription,
isCancellationReason, isCancellationReason,
} from '@/lib/cancellation'; } from '@/lib/cancellation';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit'; import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
@@ -13,8 +13,8 @@ import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
/** /**
* In-app cancellation: end the subscription at the close of the current * In-app cancellation: end unpaid subscriptions immediately, schedule paid
* period and keep the one answer the customer gave about why. * subscriptions for period end, and record the optional reason.
* *
* This exists beside the Stripe portal rather than instead of it. The portal * This exists beside the Stripe portal rather than instead of it. The portal
* cannot ask a question of our own, and by the time its webhook arrives the * cannot ask a question of our own, and by the time its webhook arrives the
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
); );
} }
const result = await cancelSubscriptionAtPeriodEnd({ const result = await cancelSubscription({
userId: session.user.id, userId: session.user.id,
reason: rawReason, reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null, note: trimmedNote.length > 0 ? trimmedNote : null,
@@ -98,7 +98,11 @@ export async function POST(request: NextRequest) {
} }
const response = successResponse({ const response = successResponse({
cancelAtPeriodEnd: true, cancelAtPeriodEnd: !result.canceledImmediately,
canceledImmediately: result.canceledImmediately,
status: result.status,
cancelAt: result.cancelAt?.toISOString() ?? null,
voidedInvoices: result.voidedInvoices,
periodEnd: result.periodEnd?.toISOString() ?? null, periodEnd: result.periodEnd?.toISOString() ?? null,
}); });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
+16 -1
View File
@@ -1,7 +1,11 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing'; import {
findBlockingStripeSubscription,
getOrCreateStripeCustomerId,
getStripeCheckoutState,
} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe'; import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
@@ -57,6 +61,17 @@ export async function POST(request: NextRequest) {
const stripe = getStripe(); const stripe = getStripe();
const priceId = getStripePriceId(); const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id); const customerId = await getOrCreateStripeCustomerId(session.user.id);
// The guard above reads the local mirror, which can be stale or cleared: the incident
// that prompted this had a customer holding three subscriptions at once because the
// mirror said there were none. Stripe is the one that knows.
const blockingSubscription = await findBlockingStripeSubscription(customerId);
if (blockingSubscription) {
return apiErrors.badRequest(
'A subscription already exists for this account. Manage it from the billing portal.'
);
}
const appOrigin = getAppOrigin(request); const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({ const checkoutSession = await stripe.checkout.sessions.create({
+41 -4
View File
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing'; import { getBillingOverview } from '@/lib/billing';
@@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) {
return request.nextUrl.origin; return request.nextUrl.origin;
} }
async function readRequestedFlow(request: NextRequest) {
try {
const body = await request.json();
return body?.flow === 'payment_method_update' ? 'payment_method_update' : null;
} catch {
return null;
}
}
async function createPortalSession(
stripe: Stripe,
customer: string,
returnUrl: string,
flow: 'payment_method_update' | null
) {
if (flow === 'payment_method_update') {
try {
return await stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
flow_data: { type: 'payment_method_update' },
});
} catch (error) {
// The portal configuration may not expose this flow; the plain portal still works.
logError('Falling back to the default Stripe portal flow:', error);
}
}
return stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
});
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const limited = await rateLimit(request, 'mutate'); const limited = await rateLimit(request, 'mutate');
@@ -47,10 +82,12 @@ export async function POST(request: NextRequest) {
} }
const stripe = getStripe(); const stripe = getStripe();
const portalSession = await stripe.billingPortal.sessions.create({ const portalSession = await createPortalSession(
customer: billing.subscription.stripeCustomerId, stripe,
return_url: `${getAppOrigin(request)}/settings`, billing.subscription.stripeCustomerId,
}); `${getAppOrigin(request)}/settings`,
await readRequestedFlow(request)
);
const response = successResponse({ url: portalSession.url }); const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
+51 -2
View File
@@ -1,6 +1,12 @@
import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing'; import {
findCancelableStripeSubscription,
isUnpaidStripeSubscription,
getBillingOverview,
getOpenInvoiceForCustomer,
} from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe'; import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -15,12 +21,55 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id); const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled(); const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig(); const isConfigured = hasStripeRuntimeConfig();
// Invoice details are only needed when the current subscription is behind on payment.
const needsPaymentFix =
billing.subscription.status === BillingSubscriptionStatus.PAST_DUE ||
billing.subscription.status === BillingSubscriptionStatus.UNPAID;
const openInvoice =
isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId
? await getOpenInvoiceForCustomer(
billing.subscription.stripeCustomerId,
billing.subscription.stripeSubscriptionId
)
: null;
const cancelable =
isStripeConfigured() && billing.subscription.stripeCustomerId
? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId)
: null;
const response = successResponse({ const response = successResponse({
isEnabled, isEnabled,
isConfigured, isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured', status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription, checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId), // A customer id alone is not enough: it is created on the first checkout attempt, so
// someone who abandoned checkout would be sent to an empty portal.
portalAvailable:
isStripeConfigured() &&
Boolean(billing.subscription.stripeCustomerId) &&
(billing.subscription.hasRecoverableSubscription ||
Boolean(billing.subscription.stripeSubscriptionId)),
// An already scheduled unpaid subscription still needs immediate cancellation.
// A different unscheduled subscription may also remain after an earlier cancel.
cancelAvailable: Boolean(cancelable),
needsPaymentFix,
cancelIsImmediate: Boolean(
cancelable &&
(isUnpaidStripeSubscription(cancelable) ||
['canceled', 'incomplete_expired'].includes(cancelable.status))
),
openInvoice: openInvoice
? {
id: openInvoice.id,
hostedInvoiceUrl: openInvoice.hostedInvoiceUrl,
amountDue: openInvoice.amountDue,
currency: openInvoice.currency,
attemptCount: openInvoice.attemptCount,
nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null,
}
: null,
subscription: { subscription: {
status: billing.subscription.status, status: billing.subscription.status,
label: billing.subscription.label, label: billing.subscription.label,
+5 -1
View File
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
// owner too. A workspace admin on somebody else's trial hits the same ceiling. // owner too. A workspace admin on somebody else's trial hits the same ceiling.
const owner = await db.user.findUnique({ const owner = await db.user.findUnique({
where: { id: workspace.ownerId }, where: { id: workspace.ownerId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, select: {
subscriptionStatus: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
}); });
if (owner && !isPaidTier(owner)) { if (owner && !isPaidTier(owner)) {
+20 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { syncStripeCustomerSubscriptions } from '@/lib/billing'; import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe'; import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -55,6 +55,25 @@ export async function POST(request: NextRequest) {
} }
break; break;
} }
// Invoice events carry the payment health of a subscription earlier and more
// reliably than the subscription events alone. Without them a customer whose card
// failed keeps the mirror of a healthy subscription until Stripe eventually gives
// up, which is the whole dunning window spent showing them the wrong state.
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = getCustomerId(invoice.customer);
// Only subscription invoices. A one-off invoice against a customer record left
// behind by an abandoned checkout has no subscription, and syncing on it would
// find an empty list, mark the account canceled and book a churn event for a
// subscription that never existed.
if (customerId && getInvoiceSubscriptionId(invoice)) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
}
default: default:
break; break;
} }
@@ -78,7 +78,9 @@ export async function CancellationReasonsCard() {
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{format(row.createdAt, 'MMM dd, yyyy')} {format(row.createdAt, 'MMM dd, yyyy')}
{row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''} {row.periodEnd
? ` · billing period ends ${format(row.periodEnd, 'MMM dd')}`
: ''}
</span> </span>
</div> </div>
<p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p> <p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p>
@@ -27,6 +27,8 @@ interface CancelSubscriptionDialogProps {
periodEnd: string | null; periodEnd: string | null;
/** True for a subscription that is still inside its Stripe trial. */ /** True for a subscription that is still inside its Stripe trial. */
isTrial: boolean; 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. */ /** Resolves true once the cancellation went through; false keeps the dialog and its answer. */
onConfirm: (input: { onConfirm: (input: {
reason: CancellationReason | null; reason: CancellationReason | null;
@@ -48,6 +50,7 @@ export function CancelSubscriptionDialog({
onOpenChange, onOpenChange,
periodEnd, periodEnd,
isTrial, isTrial,
canceledImmediately = false,
onConfirm, onConfirm,
}: CancelSubscriptionDialogProps) { }: CancelSubscriptionDialogProps) {
const [reason, setReason] = useState<CancellationReason | null>(null); const [reason, setReason] = useState<CancellationReason | null>(null);
@@ -96,7 +99,9 @@ export function CancelSubscriptionDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Cancel your {isTrial ? 'trial' : 'subscription'}?</DialogTitle> <DialogTitle>Cancel your {isTrial ? 'trial' : 'subscription'}?</DialogTitle>
<DialogDescription> <DialogDescription>
{endsOn {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 ${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.'} : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'}
</DialogDescription> </DialogDescription>
+20 -11
View File
@@ -1,11 +1,7 @@
// Turning Stripe state into funnel events. // Turning Stripe state and accepted cancellations into funnel events.
// // Sync compares before/after state; in-app cancellation also records acceptance
// These four events are derived from a before/after comparison inside the sync // because its local claim can hide that transition. Shared cycle keys make both
// that already re-reads every subscription a customer has, rather than from the // paths and replayed webhooks count the same cancellation once.
// 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.
import type { BillingSubscriptionStatus } from '@prisma/client'; import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record'; import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0); 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<void> {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
userId: params.userId,
});
}
export async function recordSubscriptionTransition(params: { export async function recordSubscriptionTransition(params: {
userId: string; userId: string;
subscriptionId: string; subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd; const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED'; const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) { if (startedCanceling || becameCanceled) {
await recordEvent({ await recordSubscriptionCancellation({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
userId, userId,
subscriptionId,
currentPeriodEnd: after.currentPeriodEnd,
}); });
} }
+468 -83
View File
@@ -35,6 +35,36 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.INCOMPLETE_EXPIRED, 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<Stripe.Subscription.Status>([
'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<Stripe.Subscription.Status>([
'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<Stripe.Subscription.Status>(['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; export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15; 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. * A legacy Stripe trial counts as paid because a card was handed over for it.
*/ */
export function isPaidTier( export function isPaidTier(
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>, subject: Pick<
BillingAccessSubject,
'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
>,
now: Date = new Date() now: Date = new Date()
) { ) {
if (!isStripeFeatureEnabled()) { if (!isStripeFeatureEnabled()) {
@@ -106,14 +139,19 @@ export function isPaidTier(
return true; return true;
} }
// The period end alone is not proof of payment. Checked here and not in // The period end alone is not proof of payment.
// `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.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false; 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( return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
); );
@@ -132,21 +170,42 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true; 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( return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
); );
} }
export function getBillingAccessEndDate(subject: BillingAccessSubject) { export function getBillingAccessEndDate(subject: BillingAccessSubject) {
if (subject.billingAccessEndedAt) { const subscriptionEnd =
return subject.billingAccessEndedAt; subject.billingAccessEndedAt ??
} (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
? null
if (subject.stripeCurrentPeriodEnd) { : subject.stripeCurrentPeriodEnd);
return 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;
return subject.trialEndsAt; if (!subject.trialEndsAt) return subscriptionEnd;
return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
} }
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) { export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
@@ -161,6 +220,9 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {}; 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 { return {
OR: [ OR: [
{ {
@@ -169,7 +231,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
}, },
}, },
{ trialEndsAt: { gt: now } }, { 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: [] } }; return { id: { in: [] } };
} }
// Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`. // Match the same last entitlement date as getBillingAccessEndDate, with explicit
// Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR // null branches because SQL comparisons against null do not evaluate to false.
// "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.
return { return {
AND: [ AND: [
{ {
@@ -199,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
}, },
}, },
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] }, { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
{ {
OR: [ OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } }, { 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; 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( function getInactiveBillingAccessEndedAt(
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
currentPeriodEnd: number | null currentPeriodEnd: number | null
@@ -733,9 +869,37 @@ function getInactiveBillingAccessEndedAt(
const canceledAt = getStripeTimestamp( const canceledAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at (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) { 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); return subscription.items.data.some((item) => item.price.id === configuredPriceId);
} }
/** export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
* When the current billing period ends, as a Unix timestamp, or null. return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
*
* 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) { async function writeStripeSubscriptionToUser(
subscription: Stripe.Subscription,
client: Prisma.TransactionClient
) {
const customerId = const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await db.user.findUnique({ const user = await client.user.findUnique({
where: { stripeCustomerId: customerId }, where: { stripeCustomerId: customerId },
select: { select: {
id: true, id: true,
@@ -813,13 +964,14 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// subscription created after the cardless trial shipped, and this fallback is // subscription created after the cardless trial shipped, and this fallback is
// what stops an abandoned or failed checkout from erasing the days the account // 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. // still had. Legacy card-backed trials keep arriving through the branch above.
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt); const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
const hasAccess = // The reported period is not proof of payment: Stripe advances it when it issues the
hasEntitledPrice && // renewal invoice, paid or not, and it survives cancellation. Access therefore follows
(hasActiveSubscription(mappedStatus) || // the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now())); // 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 }, where: { id: user.id },
data: { data: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
@@ -833,22 +985,15 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
hasEntitledPrice && trialEnd hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date()) ? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt, : user.billingTrialConsumedAt,
// A live trial means access has not ended, whatever the subscription says. // Preserve the subscription cutoff even during a trial. The trial has its own
// Stamping an end date here while the trial runs would date the storage // access branch; clearing this cutoff would resurrect an unpaid period later.
// cleanup from today and tell the user their work dies before their trial billingAccessEndedAt: hasAccess
// 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 ? null
: getInactiveBillingAccessEndedAt( : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
subscription,
hasEntitledPrice ? currentPeriodEnd : null
),
}, },
}); });
await recordSubscriptionTransition({ const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id, userId: user.id,
subscriptionId: subscription.id, subscriptionId: subscription.id,
before: { before: {
@@ -862,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd, trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd, currentPeriodEnd: effectiveCurrentPeriodEnd,
}, },
}); };
return updated; return { updated, transition };
}
async function recordSyncedSubscription(
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
) {
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 // 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 // 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 // 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 // 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) { export async function syncStripeCustomerSubscriptions(customerId: string) {
const stripe = getStripe(); const result = await db.$transaction(
const { data: subscriptions } = await stripe.subscriptions.list({ 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, customer: customerId,
status: 'all', status: 'all',
limit: 100, limit: 100,
}); });
const authoritative = selectAuthoritativeSubscription(subscriptions); const authoritative = selectAuthoritativeSubscription(subscriptions);
if (!authoritative) { return authoritative
return markSubscriptionCanceledByCustomerId(customerId); ? writeStripeSubscriptionToUser(authoritative, tx)
} : writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
},
return syncStripeSubscriptionToUser(authoritative); // 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( export async function markSubscriptionCanceledByCustomerId(
customerId: string, customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null } 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 }, where: { stripeCustomerId: customerId },
select: { select: {
id: true, id: true,
@@ -958,9 +1134,9 @@ export async function markSubscriptionCanceledByCustomerId(
// Losing the subscription does not retract a trial that has not run out. The // 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 // 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. // 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 }, where: { id: user.id },
data: { data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED, subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -970,9 +1146,7 @@ export async function markSubscriptionCanceledByCustomerId(
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null, stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false, stripeCancelAtPeriodEnd: false,
stripeCancelAt: null, stripeCancelAt: null,
billingAccessEndedAt: preservedTrialEnd billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
? null
: (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 // 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 // "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. // portal and then reached the end of their term produces one cancellation, not two.
await recordSubscriptionTransition({ const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id, userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id, subscriptionId: user.stripeSubscriptionId ?? user.id,
before: { before: {
@@ -994,7 +1168,218 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd, trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null, 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,
};
} }
+144 -32
View File
@@ -4,10 +4,13 @@ import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe'; import { getStripe } from '@/lib/stripe';
import { import {
getSubscriptionPeriodEnd, getSubscriptionPeriodEnd,
hasActiveSubscription, findCancelableStripeSubscription,
syncStripeSubscriptionToUser, isUnpaidStripeSubscription,
syncStripeCustomerSubscriptions,
voidOpenSubscriptionInvoices,
} from '@/lib/billing'; } from '@/lib/billing';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
export { export {
CANCELLATION_NOTE_MAX_LENGTH, CANCELLATION_NOTE_MAX_LENGTH,
@@ -33,7 +36,14 @@ const STRIPE_FEEDBACK: Record<
}; };
export type CancelSubscriptionResult = 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' }; | { ok: false; code: 'NO_SUBSCRIPTION' | 'ALREADY_CANCELING' | 'STRIPE_REJECTED' };
function isStripeInvalidRequest(error: unknown): boolean { 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 * Paid subscriptions end at period end; unpaid subscriptions end immediately.
* billing period and records why. * Record the reason before invoice cleanup so a failed cleanup can be retried
* * on the canceled subscription without losing or duplicating the answer.
* 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.
*/ */
export async function cancelSubscriptionAtPeriodEnd(params: { export async function cancelSubscription(params: {
userId: string; userId: string;
reason: CancellationReason | null; reason: CancellationReason | null;
note: string | null; note: string | null;
}): Promise<CancelSubscriptionResult> { }): Promise<CancelSubscriptionResult> {
const requestStartedAt = new Date();
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id: params.userId }, where: { id: params.userId },
select: { select: {
subscriptionStatus: true, stripeCustomerId: true,
stripeSubscriptionId: true, stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true, stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: 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' }; return { ok: false, code: 'NO_SUBSCRIPTION' };
} }
if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) {
return { ok: false, code: 'ALREADY_CANCELING' };
}
const subscriptionId = user.stripeSubscriptionId; // 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({ const claimed = await db.user.updateMany({
where: { where: {
id: params.userId, id: params.userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId, stripeSubscriptionId: subscriptionId,
stripeCancelAtPeriodEnd: false, stripeCancelAtPeriodEnd: false,
}, },
data: { stripeCancelAtPeriodEnd: true }, data: { stripeCancelAtPeriodEnd: true },
}); });
if (claimed.count === 0) { if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' };
return { ok: false, code: 'ALREADY_CANCELING' };
} }
let subscription: Stripe.Subscription; let subscription = original;
try { try {
subscription = await getStripe().subscriptions.update(subscriptionId, { 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, cancel_at_period_end: true,
cancellation_details: params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {}, 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) { } catch (error) {
if (claimPaidMirror) {
await db.user.updateMany({ await db.user.updateMany({
where: { id: params.userId, stripeSubscriptionId: subscriptionId }, where: { id: params.userId, stripeSubscriptionId: subscriptionId },
data: { stripeCancelAtPeriodEnd: false }, 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 (isStripeInvalidRequest(error)) { if (isStripeInvalidRequest(error)) {
logError('billing.cancel.rejected', error); logError('billing.cancel.rejected', error);
return { ok: false, code: 'STRIPE_REJECTED' }; return { ok: false, code: 'STRIPE_REJECTED' };
@@ -113,10 +174,41 @@ export async function cancelSubscriptionAtPeriodEnd(params: {
throw error; throw error;
} }
const periodEndUnix = getSubscriptionPeriodEnd(subscription); const periodEndUnix = getSubscriptionPeriodEnd(original);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd; const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
// The paid claim already set the local flag, and another subscription may
// drive customer sync. Record acceptance directly with the same cycle key.
await recordSubscriptionCancellation({
userId: params.userId,
subscriptionId,
currentPeriodEnd: periodEnd,
});
await db.subscriptionCancellation.create({ 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: { data: {
userId: params.userId, userId: params.userId,
stripeSubscriptionId: subscriptionId, stripeSubscriptionId: subscriptionId,
@@ -125,12 +217,32 @@ export async function cancelSubscriptionAtPeriodEnd(params: {
periodEnd, periodEnd,
}, },
}); });
}
});
let voidedInvoices: string[] = [];
try { try {
await syncStripeSubscriptionToUser(subscription); 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) { } catch (error) {
logError('billing.cancel.sync', 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,
};
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export interface StorageContext {
export async function getStorageContextForUser(userId: string): Promise<StorageContext> { export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true },
}); });
const isPaid = user ? isPaidTier(user) : false; const isPaid = user ? isPaidTier(user) : false;
+7 -1
View File
@@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
let stripeClient: Stripe | null = null; 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() { export function isStripeConfigured() {
return isStripeBillingEnabled(); return isStripeBillingEnabled();
} }
@@ -18,7 +24,7 @@ export function getStripe() {
} }
if (!stripeClient) { if (!stripeClient) {
stripeClient = new Stripe(secretKey); stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION });
} }
return stripeClient; return stripeClient;
+3 -1
View File
@@ -36,7 +36,9 @@
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run", "r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts", "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: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": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
+90
View File
@@ -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();
});
File diff suppressed because it is too large Load Diff
+224
View File
@@ -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> = {}): 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'));
});
});
+339
View File
@@ -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<void>((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<typeof syncStripeCustomerSubscriptions> | 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<typeof syncStripeCustomerSubscriptions> | 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<typeof syncStripeCustomerSubscriptions> | 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<typeof syncStripeCustomerSubscriptions> | 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<typeof syncStripeCustomerSubscriptions> | undefined;
let committed: Awaited<ReturnType<typeof assertAccess>> | 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);
});
});
@@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
function renderDialog( 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 onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
@@ -16,6 +21,7 @@ function renderDialog(
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
} }
isTrial={overrides.isTrial ?? false} isTrial={overrides.isTrial ?? false}
canceledImmediately={overrides.canceledImmediately}
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
@@ -108,6 +114,29 @@ describe('CancelSubscriptionDialog', () => {
expect(onOpenChange).toHaveBeenCalledWith(false); 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', () => { it('names the trial instead of the subscription while still trialing', () => {
renderDialog({ isTrial: true }); renderDialog({ isTrial: true });
+156
View File
@@ -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(<SettingsPage billingOnly />);
}
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(<SettingsPage billingOnly />);
}
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();
});
});
@@ -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(<SettingsPage billingOnly />);
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`);
});
});
+409
View File
@@ -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<typeof import('@/lib/stripe')>()),
getStripe: () => stripe,
}));
const START = 1_800_000_000;
const END = 1_802_592_000;
function subscription(overrides: Record<string, unknown> = {}): 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<string, unknown> = {}) {
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<string, unknown> = {}): 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);
});
});
+230 -47
View File
@@ -15,6 +15,9 @@ import {
getOrCreateStripeCustomerId, getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt, getStorageCleanupEligibleAt,
getStripeCheckoutState, getStripeCheckoutState,
getInvoiceSubscriptionId,
getSubscriptionPeriodEnd,
getSubscriptionPeriodStart,
getTrialNotice, getTrialNotice,
getWorkspaceCreationEligibility, getWorkspaceCreationEligibility,
hasActiveSubscription, hasActiveSubscription,
@@ -33,6 +36,8 @@ import {
} from '@/lib/billing'; } from '@/lib/billing';
const dbMock = vi.hoisted(() => ({ const dbMock = vi.hoisted(() => ({
$transaction: vi.fn(),
$executeRaw: vi.fn(),
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() }, workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() }, workspaceMember: { count: vi.fn() },
@@ -152,7 +157,11 @@ describe('isPaidTier', () => {
it('counts an active subscription as paid', () => { it('counts an active subscription as paid', () => {
expect( expect(
isPaidTier( isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null }, {
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW NOW
) )
).toBe(true); ).toBe(true);
@@ -163,7 +172,11 @@ describe('isPaidTier', () => {
it('counts a Stripe trial as paid', () => { it('counts a Stripe trial as paid', () => {
expect( expect(
isPaidTier( isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null }, {
subscriptionStatus: BillingSubscriptionStatus.TRIALING,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW NOW
) )
).toBe(true); ).toBe(true);
@@ -175,6 +188,7 @@ describe('isPaidTier', () => {
{ {
subscriptionStatus: BillingSubscriptionStatus.CANCELED, subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
}, },
NOW NOW
) )
@@ -185,7 +199,11 @@ describe('isPaidTier', () => {
it('does not count a cardless trial as paid', () => { it('does not count a cardless trial as paid', () => {
expect( expect(
isPaidTier( isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, {
subscriptionStatus: BillingSubscriptionStatus.FREE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW NOW
) )
).toBe(false); ).toBe(false);
@@ -200,6 +218,7 @@ describe('isPaidTier', () => {
{ {
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE, subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
}, },
NOW NOW
) )
@@ -212,6 +231,7 @@ describe('isPaidTier', () => {
{ {
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED, subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
}, },
NOW NOW
) )
@@ -227,6 +247,7 @@ describe('isPaidTier', () => {
{ {
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
}, },
NOW NOW
) )
@@ -239,6 +260,7 @@ describe('isPaidTier', () => {
{ {
subscriptionStatus: BillingSubscriptionStatus.CANCELED, subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
billingAccessEndedAt: null,
}, },
NOW NOW
) )
@@ -250,7 +272,11 @@ describe('isPaidTier', () => {
expect( expect(
isPaidTier( isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, {
subscriptionStatus: BillingSubscriptionStatus.FREE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW NOW
) )
).toBe(true); ).toBe(true);
@@ -366,7 +392,12 @@ describe('hasBillingAccess', () => {
).toBe(true); ).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( const result = hasBillingAccess(
subject({ subject({
subscriptionStatus: 'CANCELED', subscriptionStatus: 'CANCELED',
@@ -375,9 +406,36 @@ describe('hasBillingAccess', () => {
}), }),
NOW 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); 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', () => { it('grants access to everyone when Stripe is disabled', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const result = hasBillingAccess( const result = hasBillingAccess(
@@ -393,7 +451,7 @@ describe('hasBillingAccess', () => {
}); });
describe('getBillingAccessEndDate', () => { 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 ended = new Date('2026-01-10T00:00:00Z');
const result = getBillingAccessEndDate( const result = getBillingAccessEndDate(
subject({ subject({
@@ -402,10 +460,10 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'), 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 periodEnd = new Date('2026-02-01T00:00:00Z');
const result = getBillingAccessEndDate( const result = getBillingAccessEndDate(
subject({ subject({
@@ -413,7 +471,7 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'), 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', () => { it('falls back to trialEndsAt when there is no paid period', () => {
@@ -452,7 +510,14 @@ describe('buildBillingAccessWhereInput', () => {
OR: [ OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: NOW } }, { 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', () => { describe('buildExpiredBillingWhereInput', () => {
it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => { it('requires the entire trial retention window before deleting an inactive account', () => {
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 } }] },
],
},
],
});
});
// 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 { const where = buildExpiredBillingWhereInput(NOW) as {
AND: Array<{ OR?: Array<Record<string, unknown>> }>; AND: Array<Record<string, unknown>>;
}; };
expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null }); expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null }); expect(where.AND[1]).toEqual({
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
});
}); });
// 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', () => { it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } }); expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
@@ -808,12 +855,98 @@ function updateData(): Record<string, unknown> {
return dbMock.user.update.mock.calls[0][0].data as Record<string, unknown>; return dbMock.user.update.mock.calls[0][0].data as Record<string, unknown>;
} }
// 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<string, unknown> = {}) {
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', () => { describe('database backed billing helpers', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE); vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
dbMock.$transaction
.mockReset()
.mockImplementation(async (work: (tx: typeof dbMock) => Promise<unknown>) => work(dbMock));
dbMock.$executeRaw.mockReset().mockResolvedValue(0);
dbMock.user.findUnique.mockReset(); dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset(); dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset(); dbMock.user.updateMany.mockReset();
@@ -1451,31 +1584,77 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date); 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 }); dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const endedAt = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser( await syncStripeSubscriptionToUser(
stripeSub({ stripeSub({
status: 'canceled', status: 'canceled',
ended_at: endedAt,
current_period_end: Math.floor(NOW.getTime() / 1000) + 3600, current_period_end: Math.floor(NOW.getTime() / 1000) + 3600,
}) })
); );
expect(updateData()).toMatchObject({ expect(updateData()).toMatchObject({
subscriptionStatus: BillingSubscriptionStatus.CANCELED, 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 }); 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( 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 () => { 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 }) 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({ dbMock.user.findUnique.mockResolvedValue({
id: 'u1', id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS), billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
@@ -1641,7 +1823,7 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null }) 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); expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
}); });
@@ -1705,7 +1887,8 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1'); await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt); 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 () => { 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'); 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()); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
}); });