diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx
index f1e2793..0a6234f 100644
--- a/app/(dashboard)/settings/settings-page-client.tsx
+++ b/app/(dashboard)/settings/settings-page-client.tsx
@@ -33,6 +33,25 @@ import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
+/** Convert Stripe API units separately from the currency's display precision. */
+function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
+ const currencyCode = currency.toUpperCase();
+
+ try {
+ const formatter = new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency: currencyCode,
+ });
+ const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
+ // Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
+ // https://docs.stripe.com/currencies#special-cases
+ const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
+ return formatter.format(amountInMinorUnits / 10 ** apiExponent);
+ } catch {
+ return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
+ }
+}
+
interface NotificationSettings {
telegramChatId: string | null;
telegramEnabled: boolean;
@@ -51,6 +70,17 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean;
portalAvailable: boolean;
+ cancelAvailable: boolean;
+ cancelIsImmediate: boolean;
+ needsPaymentFix: boolean;
+ openInvoice: {
+ id: string | null;
+ hostedInvoiceUrl: string | null;
+ amountDue: number;
+ currency: string;
+ attemptCount: number;
+ nextPaymentAttempt: string | null;
+ } | null;
subscription: {
status: string;
label: string;
@@ -260,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
);
const handleBillingRedirect = useCallback(
- async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
+ async (
+ endpoint: '/api/billing/checkout' | '/api/billing/portal',
+ flow?: 'payment_method_update'
+ ) => {
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(flow ? { flow } : {}),
});
const data = await res.json();
@@ -333,9 +367,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: null;
showMessage(
'success',
- endsOn
- ? `Your subscription ends on ${endsOn}. You keep full access until then.`
- : 'Your subscription ends at the close of the current period.'
+ data.data?.canceledImmediately
+ ? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.'
+ : endsOn
+ ? `Your subscription ends on ${endsOn}. You keep full access until then.`
+ : 'Your subscription ends at the close of the current period.'
);
return true;
} catch {
@@ -458,13 +494,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{billing.subscription.hasActiveSubscription
? hasScheduledCancellation
- ? billing.subscription.hasActiveTrial
+ ? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.'
: 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.'
: billing.subscription.hasActiveTrial
? 'Free trial, no card required.'
- : 'Billing access has ended.'}
+ : billing.subscription.hasBillingAccess
+ ? 'Workspace access remains available while you resolve your payment.'
+ : 'Billing access has ended.'}
Your latest payment didn't go through. Update your payment method to keep
- your subscription — starting a new one would create a duplicate.
+ your subscription. Starting a new one would create a duplicate.
) : null}
- {billing.subscription.hasActiveTrial &&
+ {billing.subscription.status === 'TRIALING' &&
billing.subscription.trialEndsAt &&
hasScheduledCancellation ? (
@@ -501,7 +539,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{hasScheduledCancellation && billing.subscription.cancelAt ? (
- Cancellation was scheduled on{' '}
+ Cancellation takes effect on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
) : null}
@@ -527,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
) : null}
+ {billing.openInvoice ? (
+
+
+ A payment of{' '}
+ {formatInvoiceAmount(
+ billing.openInvoice.amountDue,
+ billing.openInvoice.currency
+ )}{' '}
+ did not go through
+
+
+ {billing.openInvoice.attemptCount} attempt
+ {billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
+ {billing.openInvoice.nextPaymentAttempt
+ ? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
+ : ''}
+ . Update your payment method or pay the invoice to stop the retries, or cancel
+ to stop them for good.
+
+ {billing.subscription.billingAccessEndedAt ? (
+
+ {new Date(billing.subscription.billingAccessEndedAt) > new Date()
+ ? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
+ : `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
+
+ ) : null}
+ {billing.openInvoice.hostedInvoiceUrl ? (
+
+ View and pay this invoice
+
+ ) : null}
+
+ ) : null}
+
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
handleBillingRedirect('/api/billing/portal')}
+ onClick={() =>
+ handleBillingRedirect(
+ '/api/billing/portal',
+ billing.needsPaymentFix ? 'payment_method_update' : undefined
+ )
+ }
disabled={billingAction !== null}
>
{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
cancel should not have to guess that "Manage" is the way, and
the portal cannot ask why they are leaving. */}
- {billing.subscription.hasActiveSubscription &&
- billing.portalAvailable &&
- !hasScheduledCancellation ? (
+ {billing.cancelAvailable ? (
) : null}
diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts
index 5a6d913..5aba713 100644
--- a/app/api/billing/cancel/route.ts
+++ b/app/api/billing/cancel/route.ts
@@ -3,7 +3,7 @@ import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
CANCELLATION_NOTE_MAX_LENGTH,
- cancelSubscriptionAtPeriodEnd,
+ cancelSubscription,
isCancellationReason,
} from '@/lib/cancellation';
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';
/**
- * In-app cancellation: end the subscription at the close of the current
- * period and keep the one answer the customer gave about why.
+ * In-app cancellation: end unpaid subscriptions immediately, schedule paid
+ * subscriptions for period end, and record the optional reason.
*
* 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
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
);
}
- const result = await cancelSubscriptionAtPeriodEnd({
+ const result = await cancelSubscription({
userId: session.user.id,
reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null,
@@ -98,7 +98,11 @@ export async function POST(request: NextRequest) {
}
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,
});
return withCacheControl(response, 'private, no-store');
diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts
index d8d6b52..7d1f944 100644
--- a/app/api/billing/checkout/route.ts
+++ b/app/api/billing/checkout/route.ts
@@ -1,7 +1,11 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
-import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing';
+import {
+ findBlockingStripeSubscription,
+ getOrCreateStripeCustomerId,
+ getStripeCheckoutState,
+} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
@@ -57,6 +61,17 @@ export async function POST(request: NextRequest) {
const stripe = getStripe();
const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id);
+
+ // The guard above reads the local mirror, which can be stale or cleared: the incident
+ // that prompted this had a customer holding three subscriptions at once because the
+ // mirror said there were none. Stripe is the one that knows.
+ const blockingSubscription = await findBlockingStripeSubscription(customerId);
+ if (blockingSubscription) {
+ return apiErrors.badRequest(
+ 'A subscription already exists for this account. Manage it from the billing portal.'
+ );
+ }
+
const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({
diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts
index 7b9f791..f2f8893 100644
--- a/app/api/billing/portal/route.ts
+++ b/app/api/billing/portal/route.ts
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server';
+import type Stripe from 'stripe';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
@@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) {
return request.nextUrl.origin;
}
+async function readRequestedFlow(request: NextRequest) {
+ try {
+ const body = await request.json();
+ return body?.flow === 'payment_method_update' ? 'payment_method_update' : null;
+ } catch {
+ return null;
+ }
+}
+
+async function createPortalSession(
+ stripe: Stripe,
+ customer: string,
+ returnUrl: string,
+ flow: 'payment_method_update' | null
+) {
+ if (flow === 'payment_method_update') {
+ try {
+ return await stripe.billingPortal.sessions.create({
+ customer,
+ return_url: returnUrl,
+ flow_data: { type: 'payment_method_update' },
+ });
+ } catch (error) {
+ // The portal configuration may not expose this flow; the plain portal still works.
+ logError('Falling back to the default Stripe portal flow:', error);
+ }
+ }
+
+ return stripe.billingPortal.sessions.create({
+ customer,
+ return_url: returnUrl,
+ });
+}
+
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
@@ -47,10 +82,12 @@ export async function POST(request: NextRequest) {
}
const stripe = getStripe();
- const portalSession = await stripe.billingPortal.sessions.create({
- customer: billing.subscription.stripeCustomerId,
- return_url: `${getAppOrigin(request)}/settings`,
- });
+ const portalSession = await createPortalSession(
+ stripe,
+ billing.subscription.stripeCustomerId,
+ `${getAppOrigin(request)}/settings`,
+ await readRequestedFlow(request)
+ );
const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store');
diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts
index 4720207..844ef6f 100644
--- a/app/api/billing/route.ts
+++ b/app/api/billing/route.ts
@@ -1,6 +1,12 @@
+import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth';
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 { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -15,12 +21,55 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
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({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
- portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
+ // A customer id alone is not enough: it is created on the first checkout attempt, so
+ // someone who abandoned checkout would be sent to an empty portal.
+ portalAvailable:
+ isStripeConfigured() &&
+ Boolean(billing.subscription.stripeCustomerId) &&
+ (billing.subscription.hasRecoverableSubscription ||
+ Boolean(billing.subscription.stripeSubscriptionId)),
+ // 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: {
status: billing.subscription.status,
label: billing.subscription.label,
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index 746d55a..180ddca 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
const owner = await db.user.findUnique({
where: { id: workspace.ownerId },
- select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
+ select: {
+ subscriptionStatus: true,
+ stripeCurrentPeriodEnd: true,
+ billingAccessEndedAt: true,
+ },
});
if (owner && !isPaidTier(owner)) {
diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts
index 8fed380..9815204 100644
--- a/app/api/stripe/webhook/route.ts
+++ b/app/api/stripe/webhook/route.ts
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
-import { syncStripeCustomerSubscriptions } from '@/lib/billing';
+import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -55,6 +55,25 @@ export async function POST(request: NextRequest) {
}
break;
}
+ // Invoice events carry the payment health of a subscription earlier and more
+ // reliably than the subscription events alone. Without them a customer whose card
+ // failed keeps the mirror of a healthy subscription until Stripe eventually gives
+ // up, which is the whole dunning window spent showing them the wrong state.
+ case 'invoice.paid':
+ case 'invoice.payment_failed':
+ case 'invoice.voided':
+ case 'invoice.marked_uncollectible': {
+ const invoice = event.data.object as Stripe.Invoice;
+ const customerId = getCustomerId(invoice.customer);
+ // 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:
break;
}
diff --git a/components/admin/cancellation-reasons-card.tsx b/components/admin/cancellation-reasons-card.tsx
index 7b77966..1978beb 100644
--- a/components/admin/cancellation-reasons-card.tsx
+++ b/components/admin/cancellation-reasons-card.tsx
@@ -78,7 +78,9 @@ export async function CancellationReasonsCard() {
{format(row.createdAt, 'MMM dd, yyyy')}
- {row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''}
+ {row.periodEnd
+ ? ` · billing period ends ${format(row.periodEnd, 'MMM dd')}`
+ : ''}
{getCancellationReasonLabel(row.reason)}
diff --git a/components/settings/cancel-subscription-dialog.tsx b/components/settings/cancel-subscription-dialog.tsx
index aa4810d..4d96cea 100644
--- a/components/settings/cancel-subscription-dialog.tsx
+++ b/components/settings/cancel-subscription-dialog.tsx
@@ -27,6 +27,8 @@ interface CancelSubscriptionDialogProps {
periodEnd: string | null;
/** True for a subscription that is still inside its Stripe trial. */
isTrial: boolean;
+ /** Unpaid subscriptions end now; cancellation does not extend access. */
+ canceledImmediately?: boolean;
/** Resolves true once the cancellation went through; false keeps the dialog and its answer. */
onConfirm: (input: {
reason: CancellationReason | null;
@@ -48,6 +50,7 @@ export function CancelSubscriptionDialog({
onOpenChange,
periodEnd,
isTrial,
+ canceledImmediately = false,
onConfirm,
}: CancelSubscriptionDialogProps) {
const [reason, setReason] = useState(null);
@@ -96,9 +99,11 @@ export function CancelSubscriptionDialog({
Cancel your {isTrial ? 'trial' : 'subscription'}?
- {endsOn
- ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.`
- : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'}
+ {canceledImmediately
+ ? 'This subscription ends immediately. Canceling does not extend access to your workspaces. Automatic collection stops for its open invoices. Eligible current-period subscription invoices are canceled; charges for prior service and other items may still be owed.'
+ : endsOn
+ ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.`
+ : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'}
diff --git a/lib/analytics/billing-events.ts b/lib/analytics/billing-events.ts
index e7fc5f9..b5b9435 100644
--- a/lib/analytics/billing-events.ts
+++ b/lib/analytics/billing-events.ts
@@ -1,11 +1,7 @@
-// Turning Stripe state into funnel events.
-//
-// These four events are derived from a before/after comparison inside the sync
-// that already re-reads every subscription a customer has, rather than from the
-// webhook event types. That is deliberate: webhooks arrive out of order and get
-// replayed, and `customer.subscription.updated` fires for changes that mean
-// nothing here. Comparing the row we are about to overwrite with the row we are
-// writing is order-independent, and the dedupe keys make a replay a no-op.
+// Turning Stripe state and accepted cancellations into funnel events.
+// Sync compares before/after state; in-app cancellation also records acceptance
+// because its local claim can hide that transition. Shared cycle keys make both
+// paths and replayed webhooks count the same cancellation once.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
}
+/** Shared by accepted in-app cancellations and sync; recordEvent logs write failures. */
+export async function recordSubscriptionCancellation(params: {
+ userId: string;
+ subscriptionId: string;
+ currentPeriodEnd: Date | null;
+}): Promise {
+ await recordEvent({
+ name: 'SUBSCRIPTION_CANCELED',
+ dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
+ userId: params.userId,
+ });
+}
+
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
- await recordEvent({
- name: 'SUBSCRIPTION_CANCELED',
- dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
+ await recordSubscriptionCancellation({
userId,
+ subscriptionId,
+ currentPeriodEnd: after.currentPeriodEnd,
});
}
diff --git a/lib/billing.ts b/lib/billing.ts
index d25ff35..4c06923 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -35,6 +35,36 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
+// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that
+// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same
+// membership: a subscription worth cancelling is a subscription worth blocking a second
+// checkout over, and two sets that disagreed only produced a Cancel button that always
+// failed and a checkout guard weaker than the mirror it was backing up.
+const LIVE_STRIPE_STATUSES = new Set([
+ 'active',
+ 'trialing',
+ 'past_due',
+ 'unpaid',
+ 'incomplete',
+]);
+
+// Cancelling one of these takes effect immediately: the open period was never paid for,
+// so there is nothing left to run out.
+const UNPAID_STRIPE_STATUSES = new Set([
+ 'past_due',
+ 'unpaid',
+ 'incomplete',
+]);
+
+// A subscription that was running and then missed a payment. It keeps access while Stripe
+// retries the card, so a customer whose card expired is not locked out before they have
+// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
+const RETRYING_STRIPE_STATUSES = new Set(['past_due', 'unpaid']);
+
+// Application grace period, independent of the Stripe retry settings. An unpaid
+// invoice's future period end does not extend this access window.
+const UNPAID_ACCESS_GRACE_DAYS = 14;
+
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
@@ -95,7 +125,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
* A legacy Stripe trial counts as paid because a card was handed over for it.
*/
export function isPaidTier(
- subject: Pick,
+ subject: Pick<
+ BillingAccessSubject,
+ 'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
+ >,
now: Date = new Date()
) {
if (!isStripeFeatureEnabled()) {
@@ -106,14 +139,19 @@ export function isPaidTier(
return true;
}
- // The period end alone is not proof of payment. Checked here and not in
- // `hasBillingAccess`, which keeps granting access on a period end it did not
- // question before: the cost of being wrong there is a customer locked out,
- // while the cost of being wrong here is a free account holding 200 GB.
+ // The period end alone is not proof of payment.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
+ // Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
+ // behind on payment. They did once: access stopped at the end of the payment grace window
+ // while this kept saying "paid" for the rest of the period, which left the account with
+ // no banner explaining the lockout and able to create workspaces it could not then see.
+ if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
+ return false;
+ }
+
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
@@ -132,21 +170,42 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true;
}
+ // Everything below decides whether the reported period still stands in for access, and
+ // the two guards exist because it very often does not. Both are scoped to this branch
+ // rather than applied at the top of the function: `billingAccessEndedAt` is only ever
+ // cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise
+ // outrank a freshly started cardless trial and burn the account's one trial for nothing.
+
+ // Stripe stamps a period on a subscription whose first charge never went through, so
+ // that period is not evidence of payment. The same rejection `isPaidTier` makes.
+ if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
+ return false;
+ }
+
+ // Stripe advances the period the moment it issues the renewal invoice, paid or not, and
+ // the period survives cancellation, so on its own it would hand a full free month to
+ // anyone whose renewal fails. This is the bound: a subscription behind on payment is
+ // stamped with the end of the payment grace window, a cancelled one with `ended_at`.
+ if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
+ return false;
+ }
+
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
- if (subject.billingAccessEndedAt) {
- return subject.billingAccessEndedAt;
- }
-
- if (subject.stripeCurrentPeriodEnd) {
- return subject.stripeCurrentPeriodEnd;
- }
-
- return subject.trialEndsAt;
+ const subscriptionEnd =
+ subject.billingAccessEndedAt ??
+ (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
+ ? null
+ : subject.stripeCurrentPeriodEnd);
+ // A trial grants access independently of the subscription cutoff. Retention starts
+ // after the last legitimate entitlement, never from an unpaid invoice's period.
+ if (!subscriptionEnd) return subject.trialEndsAt;
+ if (!subject.trialEndsAt) return subscriptionEnd;
+ return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
}
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
@@ -161,6 +220,9 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {};
}
+ // Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its
+ // period-end arm, so the query and the in-memory check cannot disagree about who still
+ // has access.
return {
OR: [
{
@@ -169,7 +231,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
},
},
{ trialEndsAt: { gt: now } },
- { stripeCurrentPeriodEnd: { gt: now } },
+ {
+ stripeCurrentPeriodEnd: { gt: now },
+ subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
+ OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
+ },
],
};
}
@@ -185,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
return { id: { in: [] } };
}
- // Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
- // Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
- // "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
- // false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
- // NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
- // looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
- // reports current_period_end on the subscription), so the cleanup silently matched nobody.
+ // Match the same last entitlement date as getBillingAccessEndDate, with explicit
+ // null branches because SQL comparisons against null do not evaluate to false.
return {
AND: [
{
@@ -199,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
- { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
- { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
+ { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
- AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
+ billingAccessEndedAt: null,
+ subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
+ stripeCurrentPeriodEnd: { lte: cleanupCutoff },
+ },
+ {
+ billingAccessEndedAt: null,
+ trialEndsAt: { lte: cleanupCutoff },
+ OR: [
+ { stripeCurrentPeriodEnd: null },
+ { subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
+ ],
},
],
},
@@ -723,6 +793,72 @@ function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
+/**
+ * The billing period moved off the subscription and onto its items in the Basil API
+ * version, so reading `subscription.current_period_end` yields undefined on every current
+ * version. Webhook payloads can still be rendered at an older version, so the legacy field
+ * is kept as a fallback rather than dropped.
+ */
+export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
+ const itemPeriodEnds = (subscription.items?.data ?? [])
+ .map((item) =>
+ getStripeTimestamp(
+ (item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end
+ )
+ )
+ .filter((value): value is number => value !== null);
+
+ if (itemPeriodEnds.length > 0) {
+ return Math.max(...itemPeriodEnds);
+ }
+
+ return getStripeTimestamp(
+ (subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end
+ );
+}
+
+/**
+ * Same field move as the period end. Stripe opens the new period when it issues the
+ * renewal invoice, so for an unpaid subscription this is roughly when the first payment
+ * attempt failed, which is what the retry window is measured from.
+ */
+export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null {
+ const itemPeriodStarts = (subscription.items?.data ?? [])
+ .map((item) =>
+ getStripeTimestamp(
+ (item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start
+ )
+ )
+ .filter((value): value is number => value !== null);
+
+ if (itemPeriodStarts.length > 0) {
+ return Math.min(...itemPeriodStarts);
+ }
+
+ return getStripeTimestamp(
+ (subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start
+ );
+}
+
+/**
+ * The invoice link to its subscription moved under `parent.subscription_details` in the
+ * Basil API version. Same fallback reasoning as the period above.
+ */
+export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
+ const fromParent = invoice.parent?.subscription_details?.subscription;
+ if (typeof fromParent === 'string') return fromParent;
+ if (fromParent && typeof fromParent === 'object') return fromParent.id;
+
+ const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription;
+ if (typeof legacy === 'string') return legacy;
+ if (legacy && typeof legacy === 'object' && 'id' in legacy) {
+ const id = (legacy as { id: unknown }).id;
+ return typeof id === 'string' ? id : null;
+ }
+
+ return null;
+}
+
function getInactiveBillingAccessEndedAt(
subscription: Stripe.Subscription,
currentPeriodEnd: number | null
@@ -733,9 +869,37 @@ function getInactiveBillingAccessEndedAt(
const canceledAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at
);
- const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
- return reference ? new Date(reference * 1000) : new Date();
+ // `ended_at` wins over everything: a subscription killed mid-period for non-payment
+ // must not keep access until a period the customer never paid for.
+ if (endedAt) {
+ return new Date(endedAt * 1000);
+ }
+
+ // Still running, just behind on payment: bound access to the application grace period,
+ // not the period end Stripe advanced to cover the unpaid invoice. The
+ // period start is when that invoice was issued, so it is what the window runs from; when
+ // it is missing (a paginated item list, an older payload shape) the window runs from now
+ // instead. Falling through to "ended" here would lock out the customer this branch
+ // exists to keep in, which is the wrong way to fail on missing data.
+ if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
+ const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
+ const periodStart = getSubscriptionPeriodStart(subscription);
+ const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace;
+
+ return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
+ }
+
+ // Preserve the existing period-based access policy for paused subscriptions.
+ // The paused status itself is not evidence that this period was paid.
+ if (subscription.status === 'paused' && currentPeriodEnd) {
+ return new Date(currentPeriodEnd * 1000);
+ }
+
+ // Anything else that gets here never paid for the period Stripe is reporting, so that
+ // period is not a date access can run to. `incomplete` and `incomplete_expired` are the
+ // cases that matter: their very first payment never went through.
+ return canceledAt ? new Date(canceledAt * 1000) : new Date();
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
@@ -746,31 +910,18 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
return subscription.items.data.some((item) => item.price.id === configuredPriceId);
}
-/**
- * When the current billing period ends, as a Unix timestamp, or null.
- *
- * The API version this client pins (2026-02-25) reports the period on each
- * subscription item rather than on the subscription itself, and every item of
- * a single-price subscription carries the same dates. The top-level field is
- * still read afterwards so an older fixture or a replayed event body from a
- * previous version keeps working.
- */
-export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
- const fromItem = subscription.items?.data?.[0]?.current_period_end;
- if (typeof fromItem === 'number') {
- return fromItem;
- }
-
- return 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
- ? subscription.current_period_end
- : null;
+export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
+ return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
}
-export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
+async function writeStripeSubscriptionToUser(
+ subscription: Stripe.Subscription,
+ client: Prisma.TransactionClient
+) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
- const user = await db.user.findUnique({
+ const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -813,13 +964,14 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// subscription created after the cardless trial shipped, and this fallback is
// what stops an abandoned or failed checkout from erasing the days the account
// still had. Legacy card-backed trials keep arriving through the branch above.
- const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
- const hasAccess =
- hasEntitledPrice &&
- (hasActiveSubscription(mappedStatus) ||
- Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
+ const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
+ // The reported period is not proof of payment: Stripe advances it when it issues the
+ // renewal invoice, paid or not, and it survives cancellation. Access therefore follows
+ // the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
+ // which is cleared again as soon as the subscription goes back to active.
+ const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
- const updated = await db.user.update({
+ const updated = await client.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -833,22 +985,15 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
- // A live trial means access has not ended, whatever the subscription says.
- // Stamping an end date here while the trial runs would date the storage
- // cleanup from today and tell the user their work dies before their trial
- // does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
- // that has already elapsed is a reason to stamp the end date, not to skip it.
- billingAccessEndedAt:
- hasAccess || hasActiveTrial(preservedTrialEnd)
- ? null
- : getInactiveBillingAccessEndedAt(
- subscription,
- hasEntitledPrice ? currentPeriodEnd : null
- ),
+ // Preserve the subscription cutoff even during a trial. The trial has its own
+ // access branch; clearing this cutoff would resurrect an unpaid period later.
+ billingAccessEndedAt: hasAccess
+ ? null
+ : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
- await recordSubscriptionTransition({
+ const transition: Parameters[0] = {
userId: user.id,
subscriptionId: subscription.id,
before: {
@@ -862,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
- });
+ };
- return updated;
+ return { updated, transition };
+}
+
+async function recordSyncedSubscription(
+ result: Awaited>
+) {
+ if (!result) return null;
+ // Analytics uses its own connection. Run it after commit, not while a billing
+ // transaction holds a connection and other syncs are queued on its advisory lock.
+ await recordSubscriptionTransition(result.transition);
+ return result.updated;
}
// A single Stripe customer can own several subscriptions at once (e.g. after
@@ -917,28 +1072,49 @@ export function selectAuthoritativeSubscription(
// Source-of-truth sync: instead of trusting a single subscription from a webhook
// event body (which may be an OLD subscription being deleted while a NEWER one is
// active), re-list ALL of the customer's subscriptions from Stripe and sync the
-// authoritative one. This is order-independent and self-healing.
+// authoritative one. The customer lock covers the Stripe read as well as the mirror
+// write: locking only after the read would still let a delayed older response win.
export async function syncStripeCustomerSubscriptions(customerId: string) {
- const stripe = getStripe();
- const { data: subscriptions } = await stripe.subscriptions.list({
- customer: customerId,
- status: 'all',
- limit: 100,
- });
+ const result = await db.$transaction(
+ async (tx) => {
+ // Two-key advisory locks occupy a separate namespace from the one-key
+ // cancellation locks. Cancellation releases its lock before calling sync.
+ await tx.$executeRaw`
+ SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
+ `;
+ const { data: subscriptions } = await getStripe().subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ });
- const authoritative = selectAuthoritativeSubscription(subscriptions);
- if (!authoritative) {
- return markSubscriptionCanceledByCustomerId(customerId);
- }
-
- return syncStripeSubscriptionToUser(authoritative);
+ const authoritative = selectAuthoritativeSubscription(subscriptions);
+ return authoritative
+ ? writeStripeSubscriptionToUser(authoritative, tx)
+ : writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
+ },
+ // Bound lock and connection occupancy. A slow Stripe call or lock wait fails
+ // this sync; writes through the expired transaction cannot overwrite a newer sync.
+ { maxWait: 10_000, timeout: 30_000 }
+ );
+ return recordSyncedSubscription(result);
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
- const user = await db.user.findUnique({
+ return recordSyncedSubscription(
+ await writeSubscriptionCanceledByCustomerId(customerId, options, db)
+ );
+}
+
+async function writeSubscriptionCanceledByCustomerId(
+ customerId: string,
+ options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
+ client: Prisma.TransactionClient
+) {
+ const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -958,9 +1134,9 @@ export async function markSubscriptionCanceledByCustomerId(
// Losing the subscription does not retract a trial that has not run out. The
// account keeps the days it was given and lands back on the trial's own end
// date, which is also what the cancellation copy in settings promises.
- const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
+ const preservedTrialEnd = user.trialEndsAt ?? null;
- const updated = await db.user.update({
+ const updated = await client.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -970,9 +1146,7 @@ export async function markSubscriptionCanceledByCustomerId(
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
- billingAccessEndedAt: preservedTrialEnd
- ? null
- : (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
+ billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
@@ -980,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId(
// uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two.
- await recordSubscriptionTransition({
+ const transition: Parameters[0] = {
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
@@ -994,7 +1168,218 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
+ };
+
+ return { updated, transition };
+}
+
+/**
+ * Returns a subscription of this customer that still grants access, if any. A customer can
+ * hold several at once, so the state of one says nothing about the others.
+ */
+export async function findLiveStripeSubscription(customerId: string) {
+ const stripe = getStripe();
+ const { data: subscriptions } = await stripe.subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
});
- return updated;
+ return (
+ selectAuthoritativeSubscription(
+ subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status))
+ ) ?? null
+ );
+}
+
+/**
+ * Asked before opening checkout. Answered by Stripe rather than by the local mirror: the
+ * mirror can be stale or cleared, and a customer who slips past this ends up paying for two
+ * subscriptions at once.
+ */
+export async function findBlockingStripeSubscription(customerId: string) {
+ const stripe = getStripe();
+ const { data: subscriptions } = await stripe.subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ });
+
+ return (
+ subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null
+ );
+}
+
+export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
+ return UNPAID_STRIPE_STATUSES.has(subscription.status);
+}
+
+/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
+export function isCurrentSubscriptionInvoice(
+ invoice: Stripe.Invoice,
+ subscription: Stripe.Subscription
+): boolean {
+ const latestId =
+ typeof subscription.latest_invoice === 'string'
+ ? subscription.latest_invoice
+ : subscription.latest_invoice?.id;
+ const start = getSubscriptionPeriodStart(subscription);
+ const end = getSubscriptionPeriodEnd(subscription);
+ if (
+ invoice.id !== latestId ||
+ invoice.status !== 'open' ||
+ invoice.amount_paid !== 0 ||
+ !['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
+ getInvoiceSubscriptionId(invoice) !== subscription.id ||
+ start === null ||
+ end === null ||
+ !invoice.lines ||
+ invoice.lines.has_more ||
+ invoice.lines.data.length === 0
+ )
+ return false;
+ return invoice.lines.data.every((line) => {
+ const details = line.parent?.subscription_item_details;
+ return (
+ line.parent?.type === 'subscription_item_details' &&
+ details?.subscription === subscription.id &&
+ details.proration === false &&
+ line.pricing?.price_details?.price === getStripePriceId() &&
+ line.period.start === start &&
+ line.period.end === end
+ );
+ });
+}
+
+async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
+ const invoices: Stripe.Invoice[] = [];
+ let startingAfter: string | undefined;
+ while (true) {
+ const page = await getStripe().invoices.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ invoices.push(
+ ...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
+ );
+ if (!page.has_more || page.data.length === 0) break;
+ startingAfter = page.data[page.data.length - 1].id;
+ }
+ return invoices;
+}
+
+/**
+ * Stop automatic collection on all open invoices for this subscription. Older or
+ * mixed invoices remain receivables; only a complete current renewal is voided.
+ * Failures propagate so callers can report and retry unfinished cleanup.
+ */
+export async function voidOpenSubscriptionInvoices(
+ customerId: string,
+ subscriptionId: string,
+ subscriptionSnapshot?: Stripe.Subscription
+) {
+ const stripe = getStripe();
+ const subscription =
+ subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
+ const customer =
+ typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
+ if (customer !== customerId) throw new Error('Subscription customer mismatch');
+ const voided: string[] = [];
+ for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
+ // Immediate cancellation normally pauses collection too. Explicitly keep retained
+ // receivables paused, including when retrying a partly completed cancellation.
+ if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
+ if (isCurrentSubscriptionInvoice(invoice, subscription)) {
+ await stripe.invoices.voidInvoice(invoice.id);
+ voided.push(invoice.id);
+ }
+ }
+ return voided;
+}
+
+/** Cancellation candidates differ from the subscription granting access. */
+export async function findCancelableStripeSubscription(customerId: string) {
+ const subscriptions: Stripe.Subscription[] = [];
+ let startingAfter: string | undefined;
+ while (true) {
+ const page = await getStripe().subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ subscriptions.push(...page.data);
+ if (!page.has_more || page.data.length === 0) break;
+ startingAfter = page.data[page.data.length - 1].id;
+ }
+ const candidate = selectAuthoritativeSubscription(
+ subscriptions.filter(
+ (subscription) =>
+ hasEntitledPrice(subscription, getStripePriceId()) &&
+ LIVE_STRIPE_STATUSES.has(subscription.status) &&
+ (isUnpaidStripeSubscription(subscription) ||
+ (!subscription.cancel_at && !subscription.cancel_at_period_end))
+ )
+ );
+ if (candidate) return candidate;
+ // A failed invoice write must remain reachable after Stripe accepted cancellation.
+ for (const subscription of subscriptions) {
+ if (
+ !['canceled', 'incomplete_expired'].includes(subscription.status) ||
+ !hasEntitledPrice(subscription, getStripePriceId())
+ )
+ continue;
+ const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
+ if (
+ invoices.some(
+ (invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
+ )
+ ) {
+ return subscription;
+ }
+ }
+ return null;
+}
+
+/**
+ * Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
+ * is: a customer can carry an open invoice left behind by a subscription they no longer
+ * hold, and pointing them at that one does nothing about the retries they are seeing.
+ */
+export async function getOpenInvoiceForCustomer(
+ customerId: string,
+ subscriptionId?: string | null
+) {
+ const stripe = getStripe();
+ const { data: invoices } = await stripe.invoices.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ });
+
+ const candidates = subscriptionId
+ ? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
+ : invoices;
+
+ const newest = candidates
+ .slice()
+ .sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
+ .at(0);
+
+ if (!newest) {
+ return null;
+ }
+
+ return {
+ id: newest.id ?? null,
+ hostedInvoiceUrl: newest.hosted_invoice_url ?? null,
+ amountDue: newest.amount_due ?? newest.total ?? 0,
+ currency: newest.currency ?? 'usd',
+ attemptCount: newest.attempt_count ?? 0,
+ nextPaymentAttempt: newest.next_payment_attempt
+ ? new Date(newest.next_payment_attempt * 1000)
+ : null,
+ };
}
diff --git a/lib/cancellation.ts b/lib/cancellation.ts
index 4e4d2d3..b3cc4bd 100644
--- a/lib/cancellation.ts
+++ b/lib/cancellation.ts
@@ -4,10 +4,13 @@ import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getSubscriptionPeriodEnd,
- hasActiveSubscription,
- syncStripeSubscriptionToUser,
+ findCancelableStripeSubscription,
+ isUnpaidStripeSubscription,
+ syncStripeCustomerSubscriptions,
+ voidOpenSubscriptionInvoices,
} from '@/lib/billing';
import { logError } from '@/lib/logger';
+import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
export {
CANCELLATION_NOTE_MAX_LENGTH,
@@ -33,7 +36,14 @@ const STRIPE_FEEDBACK: Record<
};
export type CancelSubscriptionResult =
- | { ok: true; periodEnd: Date | null }
+ | {
+ ok: true;
+ periodEnd: Date | null;
+ canceledImmediately: boolean;
+ voidedInvoices: string[];
+ status: Stripe.Subscription.Status;
+ cancelAt: Date | null;
+ }
| { ok: false; code: 'NO_SUBSCRIPTION' | 'ALREADY_CANCELING' | 'STRIPE_REJECTED' };
function isStripeInvalidRequest(error: unknown): boolean {
@@ -45,67 +55,118 @@ function isStripeInvalidRequest(error: unknown): boolean {
);
}
+/** Expire every open Checkout session for this incomplete subscription, including later pages. */
+async function expireSubscriptionCheckout(customerId: string, subscriptionId: string) {
+ const stripe = getStripe();
+ let startingAfter: string | undefined;
+ let expired = false;
+ do {
+ const sessions = await stripe.checkout.sessions.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ const matching = sessions.data.filter((session) => {
+ const owner = typeof session.customer === 'string' ? session.customer : session.customer?.id;
+ const id =
+ typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
+ return owner === customerId && id === subscriptionId && session.status === 'open';
+ });
+ await Promise.all(matching.map((session) => stripe.checkout.sessions.expire(session.id)));
+ expired ||= matching.length > 0;
+ startingAfter = sessions.has_more ? sessions.data.at(-1)?.id : undefined;
+ } while (startingAfter);
+ return expired;
+}
+
/**
- * Schedules the account's subscription to end at the close of the current
- * billing period and records why.
- *
- * The order of the writes is deliberate. The local flag is claimed first with
- * a conditional update, so two requests racing for the same subscription (a
- * double click, a retried request) cannot both reach Stripe and both write a
- * reason row: the second one loses the claim and gets `ALREADY_CANCELING`.
- * Stripe goes second because it is the only step that can refuse, and a
- * refusal hands the claim back. The reason row goes third, straight after
- * Stripe accepts, so it exists even if the sync below throws. The sync goes
- * last and is best effort: the webhook for the same update is already on its
- * way and will write the identical state, so a failure here only delays what
- * the settings page shows, it never loses the cancellation.
+ * Paid subscriptions end at period end; unpaid subscriptions end immediately.
+ * Record the reason before invoice cleanup so a failed cleanup can be retried
+ * on the canceled subscription without losing or duplicating the answer.
*/
-export async function cancelSubscriptionAtPeriodEnd(params: {
+export async function cancelSubscription(params: {
userId: string;
reason: CancellationReason | null;
note: string | null;
}): Promise {
+ const requestStartedAt = new Date();
const user = await db.user.findUnique({
where: { id: params.userId },
select: {
- subscriptionStatus: true,
+ stripeCustomerId: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
},
});
+ if (!user?.stripeCustomerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
- if (!user?.stripeSubscriptionId || !hasActiveSubscription(user.subscriptionStatus)) {
+ const customerId = user.stripeCustomerId;
+ const original = await findCancelableStripeSubscription(customerId);
+ if (!original) return { ok: false, code: 'NO_SUBSCRIPTION' };
+ const owner = typeof original.customer === 'string' ? original.customer : original.customer.id;
+ if (owner !== customerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
+
+ const subscriptionId = original.id;
+ const cleanupRetry = original.status === 'canceled' || original.status === 'incomplete_expired';
+ const canceledImmediately = cleanupRetry || isUnpaidStripeSubscription(original);
+ if (!canceledImmediately && original.status !== 'active' && original.status !== 'trialing') {
return { ok: false, code: 'NO_SUBSCRIPTION' };
}
-
- const subscriptionId = user.stripeSubscriptionId;
- const claimed = await db.user.updateMany({
- where: {
- id: params.userId,
- stripeSubscriptionId: subscriptionId,
- stripeCancelAtPeriodEnd: false,
- },
- data: { stripeCancelAtPeriodEnd: true },
- });
- if (claimed.count === 0) {
+ if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) {
return { ok: false, code: 'ALREADY_CANCELING' };
}
- let subscription: Stripe.Subscription;
+ // Retain the paid mirror's conditional claim for double-clicks. It cannot
+ // guard an unpaid cancellation, cleanup retry, or a different subscription.
+ const claimPaidMirror = !canceledImmediately && user.stripeSubscriptionId === subscriptionId;
+ if (claimPaidMirror) {
+ const claimed = await db.user.updateMany({
+ where: {
+ id: params.userId,
+ stripeCustomerId: customerId,
+ stripeSubscriptionId: subscriptionId,
+ stripeCancelAtPeriodEnd: false,
+ },
+ data: { stripeCancelAtPeriodEnd: true },
+ });
+ if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' };
+ }
+
+ let subscription = original;
try {
- subscription = await getStripe().subscriptions.update(subscriptionId, {
- cancel_at_period_end: true,
- cancellation_details: params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {},
- });
+ const stripe = getStripe();
+ const cancellationDetails = params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {};
+ if (!cleanupRetry) {
+ if (!canceledImmediately) {
+ subscription = await stripe.subscriptions.update(subscriptionId, {
+ cancel_at_period_end: true,
+ cancellation_details: cancellationDetails,
+ });
+ } else if (
+ original.status === 'incomplete' &&
+ (await expireSubscriptionCheckout(customerId, subscriptionId))
+ ) {
+ // Checkout owns incomplete subscriptions it created. Expiration cancels
+ // them; retrieving gives the response the actual resulting Stripe state.
+ subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
+ throw new Error('Checkout expiration did not end the subscription');
+ }
+ } else {
+ subscription = await stripe.subscriptions.cancel(subscriptionId, {
+ cancellation_details: cancellationDetails,
+ });
+ }
+ }
} catch (error) {
- await db.user.updateMany({
- where: { id: params.userId, stripeSubscriptionId: subscriptionId },
- data: { stripeCancelAtPeriodEnd: false },
- });
- // The subscription Stripe knows about is not the one we hold, most often
- // because it already ended there and the webhook has not caught up. That
- // is the customer's state, not a server fault, and the portal can show it.
+ if (claimPaidMirror) {
+ await db.user.updateMany({
+ where: { id: params.userId, stripeSubscriptionId: subscriptionId },
+ data: { stripeCancelAtPeriodEnd: false },
+ });
+ }
if (isStripeInvalidRequest(error)) {
logError('billing.cancel.rejected', error);
return { ok: false, code: 'STRIPE_REJECTED' };
@@ -113,24 +174,75 @@ export async function cancelSubscriptionAtPeriodEnd(params: {
throw error;
}
- const periodEndUnix = getSubscriptionPeriodEnd(subscription);
+ const periodEndUnix = getSubscriptionPeriodEnd(original);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
-
- await db.subscriptionCancellation.create({
- data: {
- userId: params.userId,
- stripeSubscriptionId: subscriptionId,
- reason: params.reason,
- note: params.note,
- periodEnd,
- },
+ // The paid claim already set the local flag, and another subscription may
+ // drive customer sync. Record acceptance directly with the same cycle key.
+ await recordSubscriptionCancellation({
+ userId: params.userId,
+ subscriptionId,
+ currentPeriodEnd: periodEnd,
});
+ await db.$transaction(async (tx) => {
+ // The paid mirror's claim does not cover other subscriptions. Serialize every
+ // reason write and reuse only a row written during this request, so a resumed
+ // subscription can record another cancellation without duplicating concurrent calls.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${subscriptionId}))`;
+ // Cleanup can be retried long after the request that canceled the subscription.
+ // Match that period and, when Stripe reports it, the terminal transition time.
+ // An incomplete expiration may have no ended_at, so its period is the fallback.
+ const existing = await tx.subscriptionCancellation.findFirst({
+ where: {
+ userId: params.userId,
+ stripeSubscriptionId: subscriptionId,
+ ...(cleanupRetry
+ ? {
+ periodEnd,
+ ...(original.ended_at
+ ? { createdAt: { gte: new Date(original.ended_at * 1000) } }
+ : {}),
+ }
+ : { createdAt: { gte: requestStartedAt } }),
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+ if (!existing) {
+ await tx.subscriptionCancellation.create({
+ data: {
+ userId: params.userId,
+ stripeSubscriptionId: subscriptionId,
+ reason: params.reason,
+ note: params.note,
+ periodEnd,
+ },
+ });
+ }
+ });
+
+ let voidedInvoices: string[] = [];
try {
- await syncStripeSubscriptionToUser(subscription);
- } catch (error) {
- logError('billing.cancel.sync', error);
+ if (canceledImmediately) {
+ // Eligibility must use the pre-cancellation period, not a shortened one.
+ // Failures propagate; the selector exposes canceled cleanup candidates.
+ voidedInvoices = await voidOpenSubscriptionInvoices(customerId, subscriptionId, original);
+ }
+ } finally {
+ // Reconcile the whole customer even if cleanup failed. Another subscription
+ // may still provide access. Webhooks can repair a failed local sync.
+ try {
+ await syncStripeCustomerSubscriptions(customerId);
+ } catch (error) {
+ logError('billing.cancel.sync', error);
+ }
}
- return { ok: true, periodEnd };
+ return {
+ ok: true,
+ periodEnd,
+ canceledImmediately,
+ voidedInvoices,
+ status: subscription.status,
+ cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
+ };
}
diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts
index f9bbba3..64438fe 100644
--- a/lib/storage-quota.ts
+++ b/lib/storage-quota.ts
@@ -43,7 +43,7 @@ export interface StorageContext {
export async function getStorageContextForUser(userId: string): Promise {
const user = await db.user.findUnique({
where: { id: userId },
- select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
+ select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true },
});
const isPaid = user ? isPaidTier(user) : false;
diff --git a/lib/stripe.ts b/lib/stripe.ts
index 8e1adda..afadb9b 100644
--- a/lib/stripe.ts
+++ b/lib/stripe.ts
@@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
let stripeClient: Stripe | null = null;
+// Pinned on purpose. Without it the SDK silently follows whatever version it ships
+// with, and field moves between versions (the subscription period moving onto items,
+// the invoice subscription link moving under `parent`) turn into null reads instead
+// of build failures. `satisfies` makes an SDK bump a compile error here first.
+const STRIPE_API_VERSION = '2026-02-25.clover' satisfies Stripe.LatestApiVersion;
+
export function isStripeConfigured() {
return isStripeBillingEnabled();
}
@@ -18,7 +24,7 @@ export function getStripe() {
}
if (!stripeClient) {
- stripeClient = new Stripe(secretKey);
+ stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION });
}
return stripeClient;
diff --git a/package.json b/package.json
index e2742e1..6ff5811 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,9 @@
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
- "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts"
+ "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts",
+ "stripe:resync:dry": "bun run scripts/resync-stripe-subscriptions.ts --dry-run",
+ "stripe:resync": "bun run scripts/resync-stripe-subscriptions.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts
new file mode 100644
index 0000000..d605ac7
--- /dev/null
+++ b/scripts/resync-stripe-subscriptions.ts
@@ -0,0 +1,90 @@
+/**
+ * Re-reads each Stripe customer's authoritative subscription and writes it back onto the user
+ * through the normal sync path.
+ *
+ * Needed once after a Stripe API version change: mirrored fields that moved between
+ * versions stay wrong in the database until that customer happens to produce a webhook,
+ * which for a customer whose payment already failed may never happen on its own.
+ */
+import { db, disconnectDb } from '../lib/db';
+import { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing';
+import { getStripe, isStripeConfigured } from '../lib/stripe';
+import { logError } from '../lib/logger';
+
+const TAG = '[resync-stripe-subscriptions]';
+
+async function main() {
+ const dryRun = process.argv.includes('--dry-run');
+
+ if (!isStripeConfigured()) {
+ console.log(`${TAG} Stripe is not configured, nothing to do`);
+ return;
+ }
+
+ const users = await db.user.findMany({
+ where: { stripeCustomerId: { not: null } },
+ select: { id: true, email: true, stripeCustomerId: true, stripeCurrentPeriodEnd: true },
+ });
+
+ let synced = 0;
+ let withoutSubscription = 0;
+ let failed = 0;
+
+ for (const user of users) {
+ if (!user.stripeCustomerId) continue;
+
+ try {
+ const label = user.email ?? user.id;
+
+ // Selected exactly the way the write path selects, over the customer's whole set
+ // rather than the live ones only. A mirror left wrong by the version change is most
+ // likely on a customer whose subscription is already canceled or incomplete, which
+ // is precisely who a live-only filter would skip.
+ const { data: subscriptions } = await getStripe().subscriptions.list({
+ customer: user.stripeCustomerId,
+ status: 'all',
+ limit: 100,
+ });
+ const subscription = selectAuthoritativeSubscription(subscriptions);
+
+ if (!subscription) {
+ withoutSubscription += 1;
+ continue;
+ }
+
+ if (dryRun) {
+ console.log(
+ `${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
+ );
+ synced += 1;
+ continue;
+ }
+
+ const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId);
+ if (updated) {
+ console.log(
+ `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}, access ends ${updated.billingAccessEndedAt?.toISOString() ?? 'null'}`
+ );
+ synced += 1;
+ }
+ } catch (error) {
+ failed += 1;
+ logError(`${TAG} Failed syncing ${user.email ?? user.id}:`, error);
+ }
+ }
+
+ console.log(`${TAG} Summary${dryRun ? ' (dry run)' : ''}`);
+ console.log(`${TAG} Customers: ${users.length}`);
+ console.log(`${TAG} Synced: ${synced}`);
+ console.log(`${TAG} Without a subscription: ${withoutSubscription}`);
+ console.log(`${TAG} Failed: ${failed}`);
+}
+
+main()
+ .catch((error) => {
+ logError(`${TAG} Fatal error:`, error);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await disconnectDb();
+ });
diff --git a/tests/api/billing-cancel.test.ts b/tests/api/billing-cancel.test.ts
index 9d550ec..1a1e42a 100644
--- a/tests/api/billing-cancel.test.ts
+++ b/tests/api/billing-cancel.test.ts
@@ -1,8 +1,11 @@
-import { describe, expect, it, vi } from 'vitest';
-import { BillingSubscriptionStatus } from '@prisma/client';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import { BillingSubscriptionStatus, type User } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
+import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
+import { GET as billingRoute } from '@/app/api/billing/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { createSubscribedUser, createUser } from '../factories';
@@ -10,64 +13,172 @@ import { createSubscribedUser, createUser } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const DAY = 24 * 60 * 60;
+const unix = (offsetSeconds: number) => Math.floor(Date.now() / 1000) + offsetSeconds;
-function unix(offsetSeconds: number): number {
- return Math.floor(Date.now() / 1000) + offsetSeconds;
-}
-
-function cancelRequest(body?: unknown) {
+function cancelRequest(body: unknown = {}) {
return apiRequest('/api/billing/cancel', {
method: 'POST',
headers: ORIGIN_HEADERS,
- body: body ?? {},
+ body,
});
}
-/**
- * Stands in for `stripe.subscriptions.update`, echoing back the subscription
- * the way Stripe does: same id, `cancel_at_period_end` flipped, period end
- * intact. The echo matters because the route syncs that object into the user
- * row without waiting for the webhook.
- */
-function stubStripeUpdate(
- options: { customer?: string | null; periodEnd?: number | null; status?: string } = {}
-) {
- const periodEnd = options.periodEnd === undefined ? unix(20 * DAY) : options.periodEnd;
- const update = vi.fn(async (id: string, params: Record) => ({
- id,
- // The sync looks the user up by this, so a stub that names the wrong
- // customer leaves the user row untouched and the webhook to fix it later.
- customer: options.customer ?? 'cus_test_cancel',
- status: options.status ?? 'active',
+function subscription(user: User, overrides: Partial = {}) {
+ return {
+ id: user.stripeSubscriptionId ?? 'sub_unmirrored',
+ customer: user.stripeCustomerId,
+ status: 'active',
created: unix(-30 * DAY),
- cancel_at_period_end: params.cancel_at_period_end === true,
+ cancel_at_period_end: user.stripeCancelAtPeriodEnd,
cancel_at: null,
trial_end: null,
- // Where the pinned API version reports the period: on the item, not on the
- // subscription. A stub that puts it at the top level hides a null date.
- items: { data: [{ price: { id: ENTITLED_PRICE_ID }, current_period_end: periodEnd }] },
- }));
+ latest_invoice: 'in_renewal',
+ items: {
+ data: [
+ {
+ id: 'si_plan',
+ price: { id: ENTITLED_PRICE_ID },
+ current_period_start: unix(-10 * DAY),
+ current_period_end: unix(20 * DAY),
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Subscription;
+}
- vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
- subscriptions: { update, list: vi.fn(async () => ({ data: [] })) },
+function renewal(sub: Stripe.Subscription, overrides: Partial = {}) {
+ return {
+ id: 'in_renewal',
+ customer: sub.customer,
+ status: 'open',
+ auto_advance: true,
+ amount_paid: 0,
+ billing_reason: 'subscription_cycle',
+ period_start: sub.items.data[0].current_period_start,
+ period_end: sub.items.data[0].current_period_end,
+ parent: { type: 'subscription_details', subscription_details: { subscription: sub.id } },
+ lines: {
+ has_more: false,
+ data: [
+ {
+ id: 'il_renewal',
+ amount: 2000,
+ period: {
+ start: sub.items.data[0].current_period_start,
+ end: sub.items.data[0].current_period_end,
+ },
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: {
+ subscription: sub.id,
+ subscription_item: 'si_plan',
+ proration: false,
+ },
+ },
+ pricing: { type: 'price_details', price_details: { price: ENTITLED_PRICE_ID } },
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Invoice;
+}
+
+// Only Stripe is replaced. Selection, invoice eligibility, reason persistence,
+// paid claims and customer-wide sync use their real implementation and test DB.
+function stubStripe(initial: Stripe.Subscription[], invoices: Stripe.Invoice[] = []) {
+ const subscriptions = initial.map((sub) => structuredClone(sub));
+ const replace = (id: string, patch: Partial) => {
+ const index = subscriptions.findIndex((sub) => sub.id === id);
+ if (index < 0) throw new Error(`Unknown fixture subscription ${id}`);
+ subscriptions[index] = { ...subscriptions[index], ...patch };
+ return structuredClone(subscriptions[index]);
+ };
+ const update = vi.fn(async (id: string, params: Stripe.SubscriptionUpdateParams) =>
+ replace(id, { cancel_at_period_end: params.cancel_at_period_end })
+ );
+ const cancel = vi.fn(async (id: string, params: Stripe.SubscriptionCancelParams) => {
+ void params;
+ return replace(id, {
+ status: 'canceled',
+ cancel_at_period_end: false,
+ canceled_at: unix(0),
+ ended_at: unix(0),
+ });
});
-
- return update;
+ const list = vi.fn(async (params: Stripe.SubscriptionListParams) => ({
+ data: structuredClone(subscriptions.filter((sub) => sub.customer === params.customer)),
+ has_more: false,
+ }));
+ const sessions: Stripe.Checkout.Session[] = [];
+ const sessionList = vi.fn(async (params: Stripe.Checkout.SessionListParams) => ({
+ data: sessions.filter(
+ (session) => session.status === 'open' && session.customer === params.customer
+ ),
+ has_more: false,
+ }));
+ const expire = vi.fn(async (id: string) => {
+ const session = sessions.find((item) => item.id === id)!;
+ session.status = 'expired';
+ const subId =
+ typeof session.subscription === 'string' ? session.subscription : session.subscription!.id;
+ replace(subId, { status: 'incomplete_expired' });
+ return session;
+ });
+ const voidInvoice = vi.fn(async (id: string) => {
+ const invoice = invoices.find((item) => item.id === id)!;
+ invoice.status = 'void';
+ return invoice;
+ });
+ const invoiceUpdate = vi.fn(async (id: string, params: Stripe.InvoiceUpdateParams) => {
+ const invoice = invoices.find((item) => item.id === id)!;
+ invoice.auto_advance = params.auto_advance ?? invoice.auto_advance;
+ return invoice;
+ });
+ vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
+ subscriptions: {
+ update,
+ cancel,
+ list,
+ retrieve: vi.fn(async (id: string) =>
+ structuredClone(subscriptions.find((sub) => sub.id === id))
+ ),
+ },
+ checkout: { sessions: { list: sessionList, expire } },
+ invoices: {
+ list: vi.fn(async (params: Stripe.InvoiceListParams) => ({
+ data: invoices.filter(
+ (invoice) => invoice.customer === params.customer && invoice.status === 'open'
+ ),
+ has_more: false,
+ })),
+ listLineItems: vi.fn(async (id: string) => invoices.find((item) => item.id === id)!.lines),
+ voidInvoice,
+ update: invoiceUpdate,
+ },
+ });
+ return { update, cancel, list, sessionList, expire, sessions, voidInvoice, invoiceUpdate };
}
describe('POST /api/billing/cancel', () => {
- it('returns 401 without a session', async () => {
+ it('returns 401 without a session and leaves subscription and reasons untouched', async () => {
+ const user = await createSubscribedUser();
+ const stripe = stubStripe([subscription(user)]);
signedOut();
-
const response = await callRoute(cancelRoute, cancelRequest());
-
expect(response.status).toBe(401);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
});
- it('rejects a cross-origin request', async () => {
+ it('rejects a cross-origin request without changing billing state', async () => {
const user = await createSubscribedUser();
signedInAs(user);
-
+ const stripe = stubStripe([subscription(user)]);
const response = await callRoute(
cancelRoute,
apiRequest('/api/billing/cancel', {
@@ -76,234 +187,638 @@ describe('POST /api/billing/cancel', () => {
body: {},
})
);
-
expect(response.status).toBe(403);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
});
- it('refuses when there is no active subscription to cancel', async () => {
- const trialUser = await createUser();
- signedInAs(trialUser);
- const update = stubStripeUpdate();
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
-
- expect(response.status).toBe(409);
- expect(update).not.toHaveBeenCalled();
+ it('refuses an account with no Stripe subscription', async () => {
+ const user = await createUser();
+ signedInAs(user);
+ const stripe = stubStripe([]);
+ expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
- it('refuses a second cancellation of a subscription already set to end', async () => {
+ it('does not cancel another customer subscription referenced by a stale local mirror or request body', async () => {
+ const owner = await createSubscribedUser();
+ const caller = await createSubscribedUser({ stripeSubscriptionId: 'sub_foreign_stale' });
+ signedInAs(caller);
+ const stripe = stubStripe([subscription(owner, { id: 'sub_foreign_stale' })]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({
+ reason: 'OTHER',
+ customerId: owner.stripeCustomerId,
+ subscriptionId: owner.stripeSubscriptionId,
+ })
+ );
+ expect(response.status).toBe(409);
+ expect(stripe.list).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: caller.stripeCustomerId })
+ );
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('rejects a candidate whose Stripe customer does not match the signed-in account', async () => {
+ const user = await createSubscribedUser();
+ const owner = await createSubscribedUser();
+ signedInAs(user);
+ const foreign = subscription(owner);
+ const stripe = stubStripe([foreign]);
+ stripe.list.mockResolvedValueOnce({ data: [foreign], has_more: false });
+ expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('rejects an already scheduled paid subscription without a reason write', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
- const update = stubStripeUpdate();
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
-
- expect(response.status).toBe(409);
- expect(update).not.toHaveBeenCalled();
+ const stripe = stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
});
- it('rejects an unknown reason without touching Stripe', async () => {
+ it.each([
+ { reason: 'RAGE_QUIT' },
+ { reason: 'OTHER', note: 'x'.repeat(501) },
+ { reason: 'OTHER', note: 42 },
+ ])('rejects malformed cancellation input %# before Stripe writes', async (body) => {
const user = await createSubscribedUser();
signedInAs(user);
- const update = stubStripeUpdate();
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'RAGE_QUIT' }));
-
- expect(response.status).toBe(400);
- expect(await readError(response)).toMatch(/reason/i);
- expect(update).not.toHaveBeenCalled();
+ const stripe = stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(400);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
});
- it('rejects a note longer than the column allows', async () => {
+ it.each(['active', 'trialing'] as const)(
+ 'schedules %s, persists the trimmed reason and syncs the user',
+ async (status) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const stripe = stubStripe([original]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload. ' })
+ );
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ cancelAtPeriodEnd: true,
+ canceledImmediately: false,
+ voidedInvoices: [],
+ status,
+ periodEnd: new Date(original.items.data[0].current_period_end * 1000).toISOString(),
+ });
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(user.stripeSubscriptionId, {
+ cancel_at_period_end: true,
+ cancellation_details: { feedback: 'missing_features' },
+ });
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ stripeSubscriptionId: original.id,
+ reason: 'MISSING_FEATURE',
+ note: 'Bulk upload.',
+ });
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.stripeCancelAtPeriodEnd).toBe(true);
+ expect(after.subscriptionStatus).toBe(
+ status === 'active' ? BillingSubscriptionStatus.ACTIVE : BillingSubscriptionStatus.TRIALING
+ );
+ expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(
+ original.items.data[0].current_period_end * 1000
+ );
+ }
+ );
+
+ it('finds an unscheduled paid subscription behind an already scheduled authoritative one', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
+ signedInAs(user);
+ const scheduled = subscription(user);
+ const other = subscription(user, {
+ id: 'sub_other_paid',
+ cancel_at_period_end: false,
+ created: unix(-60 * DAY),
+ });
+ const stripe = stubStripe([scheduled, other]);
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: false,
+ });
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PROJECT_ENDED' }));
+ expect(response.status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(
+ other.id,
+ expect.objectContaining({ cancel_at_period_end: true })
+ );
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
+ other.id
+ );
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
+ scheduled.id
+ );
+ });
+
+ it.each(['past_due', 'unpaid', 'incomplete'] as const)(
+ 'cancels %s immediately, stops collection and records the reason',
+ async (status) => {
+ const user = await createSubscribedUser({
+ subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
+ });
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'PRICE_OR_BILLING', note: ' Stop billing. ' })
+ );
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ cancelAtPeriodEnd: false,
+ voidedInvoices: [invoice.id],
+ status: 'canceled',
+ });
+ expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(original.id, {
+ cancellation_details: { feedback: 'too_expensive' },
+ });
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(invoice.status).toBe('void');
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ reason: 'PRICE_OR_BILLING',
+ note: 'Stop billing.',
+ stripeSubscriptionId: original.id,
+ });
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.CANCELED);
+ expect(after.stripeCancelAtPeriodEnd).toBe(false);
+ }
+ );
+
+ it('uses the original period to void the renewal when cancellation shortens the response period', async () => {
const user = await createSubscribedUser();
signedInAs(user);
- const update = stubStripeUpdate();
+ const original = subscription(user, { status: 'past_due' });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ const cancel = stripe.cancel.getMockImplementation()!;
+ stripe.cancel.mockImplementationOnce(async (id, params) => ({
+ ...(await cancel(id, params)),
+ items: {
+ ...original.items,
+ data: original.items.data.map((item) => ({ ...item, current_period_end: unix(0) })),
+ },
+ }));
+ const response = await callRoute(cancelRoute, cancelRequest());
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({ voidedInvoices: [invoice.id] });
+ expect(invoice.status).toBe('void');
+ });
+ it.each(['past_due', 'unpaid'] as const)(
+ 'offers cancellation for already scheduled %s and preserves another paid subscription',
+ async (status) => {
+ const user = await createSubscribedUser({
+ stripeCancelAtPeriodEnd: true,
+ subscriptionStatus:
+ status === 'past_due'
+ ? BillingSubscriptionStatus.PAST_DUE
+ : BillingSubscriptionStatus.UNPAID,
+ });
+ signedInAs(user);
+ const unpaid = subscription(user, { status });
+ const paid = subscription(user, { id: 'sub_still_paid', cancel_at_period_end: true });
+ const stripe = stubStripe([unpaid, paid]);
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ });
+ expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(
+ 200
+ );
+ expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(unpaid.id, expect.anything());
+ expect(stripe.update).not.toHaveBeenCalled();
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
+ expect(after.stripeSubscriptionId).toBe(paid.id);
+ expect(after.stripeCancelAtPeriodEnd).toBe(true);
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
+ unpaid.id
+ );
+ }
+ );
+
+ it('expires only the incomplete subscription matching an owned open Checkout session', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'incomplete' });
+ const other = subscription(user, { id: 'sub_other_checkout', status: 'incomplete' });
+ const stripe = stubStripe([original, other]);
+ stripe.sessions.push(
+ {
+ id: 'cs_other',
+ customer: user.stripeCustomerId,
+ subscription: other.id,
+ status: 'open',
+ } as Stripe.Checkout.Session,
+ {
+ id: 'cs_match',
+ customer: user.stripeCustomerId,
+ subscription: { id: original.id },
+ status: 'open',
+ } as Stripe.Checkout.Session
+ );
const response = await callRoute(
cancelRoute,
- cancelRequest({ reason: 'OTHER', note: 'x'.repeat(501) })
+ cancelRequest({ reason: 'OTHER', note: 'Checkout abandoned' })
);
-
- expect(response.status).toBe(400);
- expect(update).not.toHaveBeenCalled();
- });
-
- // The whole point: one Stripe write with the answer attached, one row that
- // keeps the answer on our side, and the user row updated before any webhook.
- it('schedules the cancellation, records the reason and syncs the user row', async () => {
- const user = await createSubscribedUser();
- signedInAs(user);
- const periodEnd = unix(12 * DAY);
- const update = stubStripeUpdate({ customer: user.stripeCustomerId, periodEnd });
-
- const response = await callRoute(
- cancelRoute,
- cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload for 16x9 and 9x16. ' })
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ status: 'incomplete_expired',
+ });
+ expect(stripe.sessionList).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: user.stripeCustomerId, status: 'open' })
);
-
- expect(response.status).toBe(200);
- const data = await readData<{ cancelAtPeriodEnd: boolean; periodEnd: string | null }>(response);
- expect(data.cancelAtPeriodEnd).toBe(true);
- expect(data.periodEnd).toBe(new Date(periodEnd * 1000).toISOString());
-
- expect(update).toHaveBeenCalledTimes(1);
- expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
- cancel_at_period_end: true,
- cancellation_details: { feedback: 'missing_features' },
- });
-
- const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
- expect(rows).toHaveLength(1);
- expect(rows[0]).toMatchObject({
- stripeSubscriptionId: user.stripeSubscriptionId,
- reason: 'MISSING_FEATURE',
- note: 'Bulk upload for 16x9 and 9x16.',
- });
- expect(rows[0].periodEnd?.getTime()).toBe(periodEnd * 1000);
-
- const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
- expect(after.stripeCancelAtPeriodEnd).toBe(true);
- expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
- expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
+ expect(stripe.expire).toHaveBeenCalledExactlyOnceWith('cs_match');
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(stripe.sessions[0].status).toBe('open');
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).note).toBe('Checkout abandoned');
});
- // Skipping the question is allowed and must still cancel. The row is kept
- // with no reason so the admin tally can count how often the question is
- // skipped rather than pretending those cancellations never happened.
- it('cancels with no reason given and records the skip', async () => {
+ it.each(['past_due', 'incomplete'] as const)(
+ 'retries failed %s cleanup without recanceling or overwriting its reason',
+ async (status) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ if (status === 'incomplete') {
+ stripe.sessions.push({
+ id: 'cs_retry',
+ customer: user.stripeCustomerId,
+ subscription: original.id,
+ status: 'open',
+ } as Stripe.Checkout.Session);
+ }
+ stripe.voidInvoice.mockRejectedValueOnce(new Error('Invoice cleanup unavailable'));
+ const first = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'OTHER', note: 'Keep my answer' })
+ );
+ expect(first.status).toBe(500);
+ expect(invoice.status).toBe('open');
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
+ status === 'incomplete'
+ ? BillingSubscriptionStatus.INCOMPLETE_EXPIRED
+ : BillingSubscriptionStatus.CANCELED
+ );
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ });
+ const second = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
+ expect(second.status).toBe(200);
+ expect(await readData(second)).toMatchObject({
+ canceledImmediately: true,
+ voidedInvoices: [invoice.id],
+ });
+ expect(stripe.cancel).toHaveBeenCalledTimes(status === 'incomplete' ? 0 : 1);
+ expect(stripe.expire).toHaveBeenCalledTimes(status === 'incomplete' ? 1 : 0);
+ expect(stripe.voidInvoice).toHaveBeenCalledTimes(2);
+ expect(invoice.status).toBe('void');
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ reason: 'OTHER', note: 'Keep my answer' });
+ }
+ );
+
+ it('stops retrying a retained receivable without voiding it', async () => {
const user = await createSubscribedUser();
signedInAs(user);
- const update = stubStripeUpdate();
-
- const response = await callRoute(cancelRoute, cancelRequest({}));
-
+ const original = subscription(user, { status: 'unpaid' });
+ const invoice = renewal(original, { billing_reason: 'manual' });
+ const stripe = stubStripe([original], [invoice]);
+ const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(200);
- expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
- cancel_at_period_end: true,
- cancellation_details: {},
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ voidedInvoices: [],
});
-
- const row = await db.subscriptionCancellation.findFirstOrThrow({
- where: { userId: user.id },
- });
- expect(row.reason).toBeNull();
- expect(row.note).toBeNull();
- });
-
- // A note under an answer that does not ask for one is still accepted by the
- // API; the dialog is what hides the box, and the route must not depend on it.
- it('keeps a note on any reason and drops an empty one', async () => {
- const user = await createSubscribedUser();
- signedInAs(user);
- stubStripeUpdate();
-
- const response = await callRoute(
- cancelRoute,
- cancelRequest({ reason: 'PROJECT_ENDED', note: ' ' })
+ expect(stripe.voidInvoice).not.toHaveBeenCalled();
+ expect(stripe.invoiceUpdate).toHaveBeenCalledWith(
+ invoice.id,
+ expect.objectContaining({ auto_advance: false })
);
-
- expect(response.status).toBe(200);
- const row = await db.subscriptionCancellation.findFirstOrThrow({
- where: { userId: user.id },
- });
- expect(row.reason).toBe('PROJECT_ENDED');
- expect(row.note).toBeNull();
+ expect(invoice.status).toBe('open');
+ expect(invoice.auto_advance).toBe(false);
});
- it('rejects a note that is not text', async () => {
+ it.each([{}, { reason: 'PROJECT_ENDED', note: ' ' }])(
+ 'allows skipping feedback and trims empty notes %#',
+ async (body) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(200);
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ reason: 'reason' in body ? body.reason : null,
+ note: null,
+ });
+ }
+ );
+
+ it('lets only one of two concurrent paid requests through', async () => {
const user = await createSubscribedUser();
signedInAs(user);
- const update = stubStripeUpdate();
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER', note: 42 }));
-
- expect(response.status).toBe(400);
- expect(update).not.toHaveBeenCalled();
- });
-
- // Two requests racing for the same subscription must produce one Stripe
- // write and one reason row, or the admin tally counts a churn twice.
- it('lets only one of two concurrent requests through', async () => {
- const user = await createSubscribedUser();
- signedInAs(user);
- const update = stubStripeUpdate({ customer: user.stripeCustomerId });
-
- const [first, second] = await Promise.all([
+ const stripe = stubStripe([subscription(user)]);
+ const results = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
-
- expect([first.status, second.status].sort()).toEqual([200, 409]);
- expect(update).toHaveBeenCalledTimes(1);
+ expect(results.map((response) => response.status).sort()).toEqual([200, 409]);
+ expect(stripe.update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
});
- // The reason row and the local flag must survive a sync that blows up: the
- // webhook rewrites the same state later, the answer would be gone for good.
- it('keeps the cancellation and the reason when the local sync fails', async () => {
+ it('keeps the cancellation and reason when customer-wide sync fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
- vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
- subscriptions: {
- // No `items` at all: the sync reads `items.data` and throws.
- update: vi.fn(async (id: string) => ({ id, customer: user.stripeCustomerId })),
- list: vi.fn(async () => ({ data: [] })),
+ const original = subscription(user);
+ const stripe = stubStripe([original]);
+ stripe.list
+ .mockResolvedValueOnce({ data: [original], has_more: false })
+ .mockRejectedValueOnce(new Error('Sync unavailable'));
+ expect(
+ (await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }))).status
+ ).toBe(200);
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).reason).toBe('PRICE_OR_BILLING');
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(true);
+ });
+
+ it.each([true, false])(
+ 'releases the paid claim when Stripe rejects the update (invalid request: %s)',
+ async (invalidRequest) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ const error = invalidRequest
+ ? Object.assign(new Error('No such subscription'), { type: 'StripeInvalidRequestError' })
+ : new Error('Stripe unavailable');
+ stripe.update.mockRejectedValueOnce(error);
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
+ expect(response.status).toBe(invalidRequest ? 409 : 500);
+ if (invalidRequest) expect(await readError(response)).toMatch(/Manage Subscription/);
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ }
+ );
+});
+
+describe('repeated and concurrent cancellation reasons', () => {
+ it('records a new immediate cancellation after an earlier scheduled cancellation was resumed', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'past_due' });
+ const stripe = stubStripe([original]);
+ await db.subscriptionCancellation.create({
+ data: {
+ userId: user.id,
+ stripeSubscriptionId: original.id,
+ reason: 'PRICE_OR_BILLING',
+ note: 'Previous canceled cycle',
+ createdAt: new Date(Date.now() - 40 * DAY * 1000),
+ periodEnd: new Date(Date.now() - 30 * DAY * 1000),
},
});
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }));
-
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'PROJECT_ENDED', note: 'Current cancellation' })
+ );
expect(response.status).toBe(200);
- const row = await db.subscriptionCancellation.findFirstOrThrow({ where: { userId: user.id } });
- expect(row.reason).toBe('PRICE_OR_BILLING');
- const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
- expect(after.stripeCancelAtPeriodEnd).toBe(true);
+ expect(stripe.cancel).toHaveBeenCalledTimes(1);
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(2);
+ expect(rows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }),
+ ])
+ );
});
- // A subscription Stripe no longer knows is the customer's state, not a
- // server fault: a 409 with a pointer to the portal, and the claim handed back
- // so the button still works once the webhook catches up.
- it('answers 409 and releases the claim when Stripe rejects the subscription id', async () => {
- const user = await createSubscribedUser();
+ it('records only one reason when concurrent requests cancel a nonmirrored paid subscription', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
- vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
- subscriptions: {
- update: vi.fn(async () => {
- throw Object.assign(new Error('No such subscription'), {
- type: 'StripeInvalidRequestError',
- });
- }),
- list: vi.fn(async () => ({ data: [] })),
- },
+ const scheduled = subscription(user);
+ const other = subscription(user, {
+ id: 'sub_other_paid',
+ cancel_at_period_end: false,
+ created: unix(-60 * DAY),
});
-
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
-
- expect(response.status).toBe(409);
- expect(await readError(response)).toMatch(/Manage Subscription/);
- expect(await db.subscriptionCancellation.count()).toBe(0);
- const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
- expect(after.stripeCancelAtPeriodEnd).toBe(false);
- });
-
- it('does not record a reason and releases the claim when Stripe is down', async () => {
- const user = await createSubscribedUser();
- signedInAs(user);
- vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
- subscriptions: {
- update: vi.fn(async () => {
- throw new Error('No such subscription');
- }),
- list: vi.fn(async () => ({ data: [] })),
- },
+ const stripe = stubStripe([scheduled, other]);
+ const update = stripe.update.getMockImplementation()!;
+ let entered = 0;
+ let release!: () => void;
+ const barrier = new Promise((resolve) => {
+ release = resolve;
});
+ const timeout = setTimeout(release, 1000);
+ stripe.update.mockImplementation(async (id, params) => {
+ entered += 1;
+ if (entered === 2) release();
+ await barrier;
+ return update(id, params);
+ });
+ try {
+ const responses = await Promise.all([
+ callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
+ callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
+ ]);
+ expect(entered).toBe(2);
+ expect(responses.map((response) => response.status)).toEqual([200, 200]);
+ expect(
+ await db.subscriptionCancellation.count({
+ where: { userId: user.id, stripeSubscriptionId: other.id },
+ })
+ ).toBe(1);
+ } finally {
+ clearTimeout(timeout);
+ }
+ });
+});
- const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
+describe('cancellation analytics through the route', () => {
+ beforeEach(() => {
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+ });
- expect(response.status).toBe(500);
- expect(await db.subscriptionCancellation.count()).toBe(0);
- const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
- expect(after.stripeCancelAtPeriodEnd).toBe(false);
+ it.each(['canceled subscription', 'empty customer'] as const)(
+ 'records paid cancellation before sync and deduplicates %s deletion in the same cycle',
+ async (deletion) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user);
+ const stripe = stubStripe([original]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'OTHER', note: 'Leaving after this project' })
+ );
+ expect(response.status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(original.id, {
+ cancel_at_period_end: true,
+ cancellation_details: { feedback: 'other' },
+ });
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ userId: user.id,
+ stripeSubscriptionId: original.id,
+ reason: 'OTHER',
+ note: 'Leaving after this project',
+ });
+ const where = { userId: user.id, name: 'SUBSCRIPTION_CANCELED' as const };
+ // This must exist before any later transition can conceal the missing event.
+ const accepted = await db.analyticsEvent.findMany({ where });
+ expect(accepted).toHaveLength(1);
+ expect(accepted[0].dedupeKey).toBe(
+ `SUBSCRIPTION_CANCELED:${original.id}:${original.items.data[0].current_period_end * 1000}`
+ );
+
+ await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
+ await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
+ stripe.list.mockResolvedValue({
+ data:
+ deletion === 'empty customer'
+ ? []
+ : [{ ...original, status: 'canceled', cancel_at_period_end: false }],
+ has_more: false,
+ });
+ await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
+ await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
+ const replayed = await db.analyticsEvent.findMany({ where });
+ expect(replayed.map((event) => event.id)).toEqual([accepted[0].id]);
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
+ BillingSubscriptionStatus.CANCELED
+ );
+ }
+ );
+
+ it('records cancellation of a paid subscription that does not drive the customer mirror', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
+ signedInAs(user);
+ const authoritative = subscription(user);
+ const other = subscription(user, {
+ id: 'sub_analytics_other',
+ cancel_at_period_end: false,
+ created: unix(-60 * DAY),
+ });
+ const stripe = stubStripe([authoritative, other]);
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
+ expect(response.status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(other.id, expect.anything());
+ const events = await db.analyticsEvent.findMany({
+ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' },
+ });
+ expect(events).toHaveLength(1);
+ expect(events[0].dedupeKey).toBe(
+ `SUBSCRIPTION_CANCELED:sub_analytics_other:${other.items.data[0].current_period_end * 1000}`
+ );
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
+ authoritative.id
+ );
+ });
+
+ it('records no cancellation event when Stripe rejects the paid cancellation', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ stripe.update.mockRejectedValueOnce(
+ Object.assign(new Error('Stripe rejected cancellation'), {
+ type: 'StripeInvalidRequestError',
+ })
+ );
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
+ expect(response.status).toBe(409);
+ expect(stripe.update).toHaveBeenCalledTimes(1);
+ expect(
+ await db.analyticsEvent.count({ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' } })
+ ).toBe(0);
+ expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('keeps the cancellation and reason when analytics recording fails', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user);
+ const stripe = stubStripe([original]);
+ const recording = vi
+ .spyOn(db.analyticsEvent, 'createMany')
+ .mockRejectedValue(new Error('Analytics unavailable'));
+ try {
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'OTHER', note: 'Keep this answer' })
+ );
+ expect(response.status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledTimes(1);
+ expect(recording).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: [expect.objectContaining({ name: 'SUBSCRIPTION_CANCELED', userId: user.id })],
+ })
+ );
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ reason: 'OTHER',
+ note: 'Keep this answer',
+ });
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(true);
+ } finally {
+ recording.mockRestore();
+ }
+ });
+
+ it('still cancels without recording analytics when the feature is disabled', async () => {
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }))).status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledTimes(1);
+ expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
+ expect(await db.analyticsEvent.count({ where: { userId: user.id } })).toBe(0);
});
});
diff --git a/tests/api/billing-entitlement.test.ts b/tests/api/billing-entitlement.test.ts
new file mode 100644
index 0000000..43c050a
--- /dev/null
+++ b/tests/api/billing-entitlement.test.ts
@@ -0,0 +1,224 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import {
+ buildBillingAccessWhereInput,
+ buildExpiredBillingWhereInput,
+ getBillingAccessEndDate,
+ getStorageCleanupEligibleAt,
+ hasBillingAccess,
+ isPaidTier,
+ startCardlessTrial,
+ syncStripeCustomerSubscriptions,
+} from '@/lib/billing';
+import { getStripe } from '@/lib/stripe';
+import { db } from '../helpers/db';
+import { createUser } from '../factories';
+
+// Uses the API project's real database and reset hooks. Run only when no other API suite uses it.
+const CUSTOMER_ID = 'cus_entitlement_regression';
+const SUBSCRIPTION_ID = 'sub_entitlement_regression';
+const PRICE_ID = 'price_entitlement_regression';
+const TRIAL_START = new Date('2026-10-01T00:00:00.000Z');
+const TRIAL_END = new Date('2026-10-08T00:00:00.000Z');
+const CANCELED_AT = new Date('2026-10-02T00:00:00.000Z');
+const REPORTED_PERIOD_END = new Date('2026-11-01T00:00:00.000Z');
+
+function subscription(overrides: Partial = {}): Stripe.Subscription {
+ return {
+ id: SUBSCRIPTION_ID,
+ customer: CUSTOMER_ID,
+ status: 'canceled',
+ created: Date.parse('2026-09-01T00:00:00.000Z') / 1000,
+ trial_end: null,
+ ended_at: CANCELED_AT.getTime() / 1000,
+ canceled_at: CANCELED_AT.getTime() / 1000,
+ cancel_at: null,
+ cancel_at_period_end: false,
+ // Deliberately no top-level period: the regression depends on the item-only payload.
+ items: {
+ data: [
+ {
+ price: { id: PRICE_ID },
+ current_period_start: TRIAL_START.getTime() / 1000,
+ current_period_end: REPORTED_PERIOD_END.getTime() / 1000,
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Subscription;
+}
+
+function stubSubscription(value: Stripe.Subscription) {
+ const list = vi.fn(async () => ({ data: [value] }));
+ vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
+ return list;
+}
+
+async function startDeferredTrial() {
+ const user = await createUser({
+ subscriptionStatus: 'PAST_DUE',
+ stripeCustomerId: CUSTOMER_ID,
+ stripeSubscriptionId: SUBSCRIPTION_ID,
+ stripePriceId: PRICE_ID,
+ stripeCurrentPeriodEnd: REPORTED_PERIOD_END,
+ trialEndsAt: null,
+ billingTrialConsumedAt: null,
+ });
+ expect(await startCardlessTrial(user.id, TRIAL_START)).toBe(true);
+ const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ return user.id;
+}
+
+async function matchingAccessUsers(userId: string, now: Date) {
+ return db.user.findMany({
+ where: { AND: [{ id: userId }, buildBillingAccessWhereInput(now)] },
+ select: { id: true },
+ });
+}
+
+async function matchingCleanupUsers(userId: string, now: Date) {
+ return db.user.findMany({
+ where: { AND: [{ id: userId }, buildExpiredBillingWhereInput(now)] },
+ select: { id: true },
+ });
+}
+
+describe('billing entitlement and retention after subscription sync', () => {
+ beforeEach(() => {
+ vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
+ vi.stubEnv('STRIPE_PRICE_ID', PRICE_ID);
+ // Mock only Date so PostgreSQL sockets and query timers keep running normally.
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(TRIAL_START);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // Catches restoring `hasAccess || hasActiveTrial(preservedTrialEnd)` when writing the cutoff.
+ it('preserves a deferred trial without granting paid access to the canceled unpaid period', async () => {
+ const userId = await startDeferredTrial();
+ const list = stubSubscription(subscription());
+ vi.setSystemTime(CANCELED_AT);
+
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ expect(list).toHaveBeenCalledWith({ customer: CUSTOMER_ID, status: 'all', limit: 100 });
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.subscriptionStatus).toBe('CANCELED');
+ expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
+
+ await Promise.all(
+ [
+ { now: CANCELED_AT, expected: true },
+ { now: new Date('2026-10-07T23:59:59.999Z'), expected: true },
+ { now: TRIAL_END, expected: false },
+ { now: new Date('2026-10-09T00:00:00.000Z'), expected: false },
+ ].map(async ({ now, expected }) => {
+ expect(isPaidTier(stored, now)).toBe(false);
+ expect(hasBillingAccess(stored, now)).toBe(expected);
+ expect(await matchingAccessUsers(userId, now)).toEqual(expected ? [{ id: userId }] : []);
+ })
+ );
+ });
+
+ // Catches choosing the raw unpaid period, choosing the earlier expiry, or requiring that raw period to lapse in SQL.
+ it.each([
+ {
+ label: 'trial outlasts the subscription',
+ subscriptionEnd: CANCELED_AT,
+ lastEntitlementEnd: TRIAL_END,
+ cleanupAt: new Date('2026-10-23T00:00:00.000Z'),
+ },
+ {
+ label: 'subscription outlasts the trial',
+ subscriptionEnd: new Date('2026-10-12T00:00:00.000Z'),
+ lastEntitlementEnd: new Date('2026-10-12T00:00:00.000Z'),
+ cleanupAt: new Date('2026-10-27T00:00:00.000Z'),
+ },
+ ])('retains storage until the last legitimate expiry plus 15 days: $label', async (scenario) => {
+ const userId = await startDeferredTrial();
+ stubSubscription(
+ subscription({
+ ended_at: scenario.subscriptionEnd.getTime() / 1000,
+ canceled_at: scenario.subscriptionEnd.getTime() / 1000,
+ })
+ );
+ vi.setSystemTime(scenario.subscriptionEnd);
+
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.billingAccessEndedAt).toEqual(scenario.subscriptionEnd);
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
+ expect(getBillingAccessEndDate(stored)).toEqual(scenario.lastEntitlementEnd);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(scenario.cleanupAt);
+ expect(hasBillingAccess(stored, scenario.cleanupAt)).toBe(false);
+ const [before, at] = await Promise.all([
+ matchingCleanupUsers(userId, new Date(scenario.cleanupAt.getTime() - 1)),
+ matchingCleanupUsers(userId, scenario.cleanupAt),
+ ]);
+ expect(before).toEqual([]);
+ expect(at).toEqual([{ id: userId }]);
+ });
+
+ // Catches replacing persisted trial history with keepUnexpiredTrial on a terminal resync.
+ it('keeps expired trial history and the retention deadline across repeated terminal syncs', async () => {
+ const userId = await startDeferredTrial();
+ stubSubscription(subscription());
+ vi.setSystemTime(CANCELED_AT);
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ for (const now of ['2026-10-09T00:00:00.000Z', '2026-10-20T00:00:00.000Z']) {
+ vi.setSystemTime(new Date(now));
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
+ expect(isPaidTier(stored)).toBe(false);
+ expect(hasBillingAccess(stored)).toBe(false);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-10-23T00:00:00.000Z'));
+ expect(await matchingCleanupUsers(userId, new Date(now))).toEqual([]);
+ }
+
+ expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([
+ { id: userId },
+ ]);
+ });
+
+ // Catches treating scheduled cancellation as immediate termination of a paid subscription.
+ it('keeps a paid scheduled cancellation accessible after the cardless trial expires', async () => {
+ const userId = await startDeferredTrial();
+ stubSubscription(
+ subscription({
+ status: 'active',
+ ended_at: null,
+ cancel_at_period_end: true,
+ cancel_at: REPORTED_PERIOD_END.getTime() / 1000,
+ })
+ );
+ vi.setSystemTime(CANCELED_AT);
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ const afterTrial = new Date('2026-10-09T00:00:00.000Z');
+ expect(stored.subscriptionStatus).toBe('ACTIVE');
+ expect(stored.stripeCancelAtPeriodEnd).toBe(true);
+ expect(stored.billingAccessEndedAt).toBeNull();
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(isPaidTier(stored, afterTrial)).toBe(true);
+ expect(hasBillingAccess(stored, afterTrial)).toBe(true);
+ expect(await matchingAccessUsers(userId, afterTrial)).toEqual([{ id: userId }]);
+ expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([]);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-11-16T00:00:00.000Z'));
+ });
+});
diff --git a/tests/api/billing-sync-concurrency.test.ts b/tests/api/billing-sync-concurrency.test.ts
new file mode 100644
index 0000000..d118ade
--- /dev/null
+++ b/tests/api/billing-sync-concurrency.test.ts
@@ -0,0 +1,339 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import { Pool } from 'pg';
+import {
+ buildBillingAccessWhereInput,
+ hasBillingAccess,
+ syncStripeCustomerSubscriptions,
+} from '@/lib/billing';
+import { getStripe } from '@/lib/stripe';
+import { db } from '../helpers/db';
+import { createUser } from '../factories';
+
+// Real PostgreSQL persistence and advisory locks; only Stripe responses are emulated.
+// The held response models transport delay, not Stripe's actual webhook scheduling.
+const CUSTOMER = 'cus_sync_concurrency';
+const PRICE = 'price_sync_concurrency';
+
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+function subscription(
+ status: 'active' | 'canceled',
+ id = 'sub_paid',
+ customer = CUSTOMER
+): Stripe.Subscription {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ id,
+ customer,
+ status,
+ created: now - 86_400,
+ trial_end: null,
+ cancel_at: null,
+ cancel_at_period_end: false,
+ ended_at: status === 'canceled' ? now - 60 : null,
+ canceled_at: status === 'canceled' ? now - 60 : null,
+ items: {
+ data: [
+ {
+ price: { id: PRICE },
+ current_period_start: now - 86_400,
+ current_period_end: now + 30 * 86_400,
+ },
+ ],
+ },
+ } as Stripe.Subscription;
+}
+
+async function seed(status: 'ACTIVE' | 'CANCELED' = 'CANCELED', customer = CUSTOMER) {
+ return createUser({
+ stripeCustomerId: customer,
+ stripeSubscriptionId: `sub_seed_${customer}`,
+ stripePriceId: PRICE,
+ subscriptionStatus: status,
+ stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 86_400_000),
+ trialEndsAt: null,
+ billingTrialConsumedAt: new Date(Date.now() - 60 * 86_400_000),
+ billingAccessEndedAt: status === 'CANCELED' ? new Date(Date.now() - 60_000) : null,
+ });
+}
+
+function installStripe() {
+ const list = vi.fn<
+ (params: Stripe.SubscriptionListParams) => Promise<{
+ data: Stripe.Subscription[];
+ has_more: boolean;
+ }>
+ >();
+ vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
+ return list;
+}
+
+async function waitForQueuedSync(customer = CUSTOMER) {
+ // Observe a real waiter rather than sleeping and assuming the other request ran.
+ // Replacing the database lock with a process-local mutex fails this assertion.
+ await vi.waitFor(
+ async () => {
+ const rows = await db.$queryRaw<{ waiting: boolean }[]>`
+ SELECT EXISTS (
+ SELECT 1 FROM pg_locks
+ WHERE locktype = 'advisory' AND NOT granted
+ AND classid = hashtext('stripe-subscription-sync')::oid
+ AND objid = hashtext(${customer})::oid AND objsubid = 2
+ ) AS waiting
+ `;
+ expect(rows).toEqual([{ waiting: true }]);
+ },
+ { timeout: 2_000, interval: 20 }
+ );
+}
+
+async function assertAccess(userId: string, expected: boolean) {
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(hasBillingAccess(stored)).toBe(expected);
+ expect(
+ await db.user.findMany({
+ where: { AND: [{ id: userId }, buildBillingAccessWhereInput()] },
+ select: { id: true },
+ })
+ ).toEqual(expected ? [{ id: userId }] : []);
+ return stored;
+}
+
+beforeEach(() => {
+ vi.stubEnv('STRIPE_PRICE_ID', PRICE);
+ vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+});
+
+describe('customer-wide Stripe sync serialization', () => {
+ it.each(['canceled-then-paid', 'paid-then-canceled', 'empty-then-paid'] as const)(
+ 'keeps the newer snapshot for overlapping %s reads',
+ async (order) => {
+ const paidFirst = order === 'paid-then-canceled';
+ const user = await seed(paidFirst ? 'ACTIVE' : 'CANCELED');
+ const stale =
+ order === 'empty-then-paid'
+ ? []
+ : [subscription(paidFirst ? 'active' : 'canceled', paidFirst ? 'sub_paid' : 'sub_old')];
+ const fresh = subscription(paidFirst ? 'canceled' : 'active');
+ const list = installStripe();
+ const entered = deferred();
+ const release = deferred();
+ list.mockImplementationOnce(async () => {
+ entered.resolve();
+ await release.promise;
+ return { data: stale, has_more: false };
+ });
+ list.mockImplementationOnce(async () => {
+ // Reading Stripe for the next sync must wait for the previous mirror commit.
+ const previous = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(previous.stripeSubscriptionId).toBe(stale[0]?.id ?? null);
+ return { data: [fresh], has_more: false };
+ });
+ const first = syncStripeCustomerSubscriptions(CUSTOMER);
+ let second: ReturnType | undefined;
+ // Attach handlers immediately so assertion failures still drain both requests.
+ void first.catch(() => {});
+ try {
+ await entered.promise;
+ second = syncStripeCustomerSubscriptions(CUSTOMER);
+ void second.catch(() => {});
+ await waitForQueuedSync();
+ expect(list).toHaveBeenCalledTimes(1);
+ const unchanged = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(unchanged.stripeSubscriptionId).toBe(user.stripeSubscriptionId);
+ } finally {
+ release.resolve();
+ await Promise.allSettled([first, ...(second ? [second] : [])]);
+ }
+ await expect(first).resolves.not.toBeNull();
+ await expect(second!).resolves.not.toBeNull();
+ expect(list).toHaveBeenCalledTimes(2);
+ const stored = await assertAccess(user.id, !paidFirst);
+ expect(stored.subscriptionStatus).toBe(paidFirst ? 'CANCELED' : 'ACTIVE');
+ expect(stored.stripeSubscriptionId).toBe('sub_paid');
+ expect(
+ await db.analyticsEvent.findMany({
+ where: { userId: user.id },
+ select: { name: true },
+ })
+ ).toEqual([{ name: paidFirst ? 'SUBSCRIPTION_CANCELED' : 'SUBSCRIPTION_STARTED' }]);
+ }
+ );
+
+ it('honors the customer lock held by an independent database connection before reading Stripe', async () => {
+ const user = await seed();
+ const list = installStripe().mockResolvedValue({
+ data: [subscription('active')],
+ has_more: false,
+ });
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
+ const connection = await pool.connect();
+ let pending: ReturnType | undefined;
+ try {
+ await connection.query('BEGIN');
+ await connection.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
+ 'stripe-subscription-sync',
+ CUSTOMER,
+ ]);
+ pending = syncStripeCustomerSubscriptions(CUSTOMER);
+ void pending.catch(() => {});
+ await waitForQueuedSync();
+ expect(list).not.toHaveBeenCalled();
+ } finally {
+ await connection.query('ROLLBACK');
+ connection.release();
+ await pool.end();
+ if (pending) await Promise.allSettled([pending]);
+ }
+ await expect(pending!).resolves.not.toBeNull();
+ expect(list).toHaveBeenCalledTimes(1);
+ await assertAccess(user.id, true);
+ });
+
+ it('lets a different customer sync while the first customer waits on Stripe', async () => {
+ await seed();
+ const otherCustomer = 'cus_sync_independent';
+ const otherUser = await seed('CANCELED', otherCustomer);
+ const entered = deferred();
+ const release = deferred();
+ const list = installStripe().mockImplementation(async ({ customer }) => {
+ if (customer === CUSTOMER) {
+ entered.resolve();
+ await release.promise;
+ }
+ return { data: [subscription('active', `sub_${customer}`, customer)], has_more: false };
+ });
+ const first = syncStripeCustomerSubscriptions(CUSTOMER);
+ void first.catch(() => {});
+ let second: ReturnType | undefined;
+ let secondFinished = false;
+ try {
+ await entered.promise;
+ second = syncStripeCustomerSubscriptions(otherCustomer);
+ void second.then(
+ () => {
+ secondFinished = true;
+ },
+ () => {
+ secondFinished = true;
+ }
+ );
+ await vi.waitFor(() => expect(secondFinished).toBe(true), { timeout: 2_000 });
+ await expect(second).resolves.not.toBeNull();
+ await assertAccess(otherUser.id, true);
+ expect(list).toHaveBeenCalledTimes(2);
+ } finally {
+ release.resolve();
+ await Promise.allSettled([first, ...(second ? [second] : [])]);
+ }
+ await expect(first).resolves.not.toBeNull();
+ });
+
+ it('releases a failed sync for its queued successor without changing the original mirror', async () => {
+ const user = await seed();
+ const entered = deferred();
+ const release = deferred();
+ const failure = new Error('Emulated Stripe read failure');
+ const list = installStripe();
+ list.mockImplementationOnce(async () => {
+ entered.resolve();
+ await release.promise;
+ throw failure;
+ });
+ list.mockImplementationOnce(async () => {
+ expect(await db.user.findUniqueOrThrow({ where: { id: user.id } })).toEqual(user);
+ return { data: [subscription('active')], has_more: false };
+ });
+ const first = syncStripeCustomerSubscriptions(CUSTOMER);
+ void first.catch(() => {});
+ let second: ReturnType | undefined;
+ try {
+ await entered.promise;
+ second = syncStripeCustomerSubscriptions(CUSTOMER);
+ void second.catch(() => {});
+ await waitForQueuedSync();
+ } finally {
+ release.resolve();
+ await Promise.allSettled([first, ...(second ? [second] : [])]);
+ }
+ await expect(first).rejects.toBe(failure);
+ await expect(second!).resolves.not.toBeNull();
+ expect(list).toHaveBeenCalledTimes(2);
+ await assertAccess(user.id, true);
+ });
+
+ it('cannot overwrite a newer mirror when a Stripe response arrives after transaction expiry', async () => {
+ const user = await seed();
+ const entered = deferred();
+ const release = deferred();
+ const callbackFinished = deferred();
+ const list = installStripe();
+ list.mockImplementationOnce(async () => {
+ entered.resolve();
+ await release.promise;
+ return { data: [subscription('canceled', 'sub_stale')], has_more: false };
+ });
+ list.mockResolvedValueOnce({ data: [subscription('active', 'sub_new')], has_more: false });
+
+ // Keep the real transaction and expiry machinery, shortening only the first
+ // request's deadline. Its callback can outlive rollback while Stripe is held.
+ const transact = db.$transaction.bind(db);
+ const transaction = vi.spyOn(db, '$transaction').mockImplementationOnce((callback, options) =>
+ transact(
+ async (tx) => {
+ try {
+ return await callback(tx);
+ } finally {
+ callbackFinished.resolve();
+ }
+ },
+ { ...options, timeout: 200 }
+ )
+ );
+ const first = syncStripeCustomerSubscriptions(CUSTOMER);
+ void first.catch(() => {});
+ let second: ReturnType | undefined;
+ let committed: Awaited> | undefined;
+ try {
+ await entered.promise;
+ // Wait for PostgreSQL to release A's lock, not an assumed sleep duration.
+ await vi.waitFor(
+ async () => {
+ const rows = await db.$queryRaw<{ held: boolean }[]>`
+ SELECT EXISTS (
+ SELECT 1 FROM pg_locks
+ WHERE locktype = 'advisory' AND granted
+ AND classid = hashtext('stripe-subscription-sync')::oid
+ AND objid = hashtext(${CUSTOMER})::oid AND objsubid = 2
+ ) AS held
+ `;
+ expect(rows).toEqual([{ held: false }]);
+ },
+ { timeout: 3_000, interval: 20 }
+ );
+ second = syncStripeCustomerSubscriptions(CUSTOMER);
+ void second.catch(() => {});
+ await expect(second).resolves.not.toBeNull();
+ committed = await assertAccess(user.id, true);
+ expect(committed.stripeSubscriptionId).toBe('sub_new');
+ } finally {
+ release.resolve();
+ // The outer promise may reject on expiry before its callback finishes.
+ // Drain both so a late global-client write cannot escape the assertions.
+ await Promise.allSettled([first, ...(second ? [second] : [])]);
+ await callbackFinished.promise;
+ transaction.mockRestore();
+ }
+ await expect(first).rejects.toMatchObject({ code: 'P2028' });
+ expect(list).toHaveBeenCalledTimes(2);
+ expect(await assertAccess(user.id, true)).toEqual(committed);
+ });
+});
diff --git a/tests/component/cancel-subscription-dialog.test.tsx b/tests/component/cancel-subscription-dialog.test.tsx
index 795d317..837c15c 100644
--- a/tests/component/cancel-subscription-dialog.test.tsx
+++ b/tests/component/cancel-subscription-dialog.test.tsx
@@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
function renderDialog(
- overrides: { periodEnd?: string | null; isTrial?: boolean; confirmResult?: boolean } = {}
+ overrides: {
+ periodEnd?: string | null;
+ isTrial?: boolean;
+ canceledImmediately?: boolean;
+ confirmResult?: boolean;
+ } = {}
) {
const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
const onOpenChange = vi.fn();
@@ -16,6 +21,7 @@ function renderDialog(
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
}
isTrial={overrides.isTrial ?? false}
+ canceledImmediately={overrides.canceledImmediately}
onConfirm={onConfirm}
/>
);
@@ -108,6 +114,29 @@ describe('CancelSubscriptionDialog', () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
+ it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => {
+ renderDialog({ canceledImmediately: true });
+
+ expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument();
+ expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent(
+ 'Canceling does not extend access to your workspaces.'
+ );
+ expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent(
+ 'charges for prior service and other items may still be owed.'
+ );
+ expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument();
+ expect(screen.getAllByRole('radio')).toHaveLength(5);
+ });
+
+ it('retains the scheduled period-end explanation', () => {
+ renderDialog();
+
+ expect(screen.getByText(/Everything stays on until/)).toHaveTextContent(
+ new Date('2026-10-01T00:00:00.000Z').toLocaleDateString()
+ );
+ expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument();
+ });
+
it('names the trial instead of the subscription while still trialing', () => {
renderDialog({ isTrial: true });
diff --git a/tests/component/settings-billing.test.tsx b/tests/component/settings-billing.test.tsx
new file mode 100644
index 0000000..177afd1
--- /dev/null
+++ b/tests/component/settings-billing.test.tsx
@@ -0,0 +1,156 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
+
+function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) => {
+ if (url !== '/api/billing') return { ok: false };
+ return {
+ ok: true,
+ json: async () => ({
+ data: {
+ isEnabled: true,
+ isConfigured: true,
+ checkoutAvailable: false,
+ portalAvailable: true,
+ cancelAvailable: false,
+ needsPaymentFix: false,
+ openInvoice: null,
+ workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
+ subscription: {
+ status,
+ label: status === 'ACTIVE' ? 'Active' : 'Trialing',
+ hasActiveSubscription: true,
+ hasRecoverableSubscription: true,
+ hasActiveTrial: true,
+ hasBillingAccess: true,
+ currentPeriodEnd: '2026-10-08T12:00:00Z',
+ trialEndsAt: '2026-09-15T12:00:00Z',
+ cancelAtPeriodEnd: true,
+ cancelAt: '2026-10-08T12:00:00Z',
+ },
+ },
+ }),
+ };
+ })
+ );
+ render( );
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('scheduled cancellation in billing settings', () => {
+ it('keeps a paid subscription distinct from its remaining cardless trial', async () => {
+ renderScheduledCancellation('ACTIVE');
+
+ expect(
+ await screen.findByText(
+ 'Subscription canceled. Access remains active until the end of the current billing period.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/Trial canceled/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Access ends on/)).not.toBeInTheDocument();
+ expect(screen.getByText(/Your subscription ends on/)).toHaveTextContent(
+ new Date('2026-10-08T12:00:00Z').toLocaleDateString()
+ );
+ expect(screen.getByText(/Cancellation takes effect on/)).toBeInTheDocument();
+ expect(screen.queryByText(/Cancellation was scheduled on/)).not.toBeInTheDocument();
+ });
+
+ it('still explains the trial end for a Stripe trial subscription', async () => {
+ renderScheduledCancellation('TRIALING');
+
+ expect(
+ await screen.findByText('Trial canceled. Access remains active until the trial ends.')
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Access ends on/)).toHaveTextContent(
+ new Date('2026-09-15T12:00:00Z').toLocaleDateString()
+ );
+ });
+});
+
+function renderPastDue(hasBillingAccess: boolean) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) => {
+ if (url !== '/api/billing') return { ok: false };
+ return {
+ ok: true,
+ json: async () => ({
+ data: {
+ isEnabled: true,
+ isConfigured: true,
+ checkoutAvailable: false,
+ portalAvailable: true,
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ needsPaymentFix: true,
+ openInvoice: null,
+ workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false },
+ subscription: {
+ status: 'PAST_DUE',
+ label: 'Past due',
+ hasActiveSubscription: false,
+ hasRecoverableSubscription: true,
+ hasActiveTrial: false,
+ hasBillingAccess,
+ currentPeriodEnd: '2026-10-08T12:00:00Z',
+ trialEndsAt: null,
+ cancelAtPeriodEnd: false,
+ cancelAt: null,
+ },
+ },
+ }),
+ };
+ })
+ );
+ render( );
+}
+
+describe('past-due access in billing settings', () => {
+ it('opens immediate unpaid cancellation copy and waits for confirmation', async () => {
+ const user = userEvent.setup();
+ renderPastDue(true);
+
+ await user.click(await screen.findByRole('button', { name: 'Cancel subscription' }));
+
+ const dialog = within(screen.getByRole('dialog'));
+ expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent(
+ 'Canceling does not extend access to your workspaces.'
+ );
+ expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent(
+ 'charges for prior service and other items may still be owed.'
+ );
+ expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument();
+ expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled();
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
+
+ await user.click(dialog.getByRole('button', { name: 'Keep subscription' }));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
+ });
+
+ it('shows continued workspace access during payment grace without an active trial', async () => {
+ renderPastDue(true);
+
+ expect(
+ await screen.findByText('Workspace access remains available while you resolve your payment.')
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument();
+ expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
+ });
+
+ it('shows access has ended when payment grace has expired and no trial remains', async () => {
+ renderPastDue(false);
+
+ expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Workspace access remains available while you resolve your payment.')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/component/settings-currency.test.tsx b/tests/component/settings-currency.test.tsx
new file mode 100644
index 0000000..60eeb31
--- /dev/null
+++ b/tests/component/settings-currency.test.tsx
@@ -0,0 +1,87 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
+
+// API scaling expectations are literal, independent of production Intl logic.
+// USD, JPY, KRW: https://docs.stripe.com/currencies#zero-decimal
+// ISK, UGX: https://docs.stripe.com/currencies#special-cases
+// KWD: https://support.stripe.com/questions/which-payments-methods-and-products-are-available-in-the-uae?locale=en-GB
+// KWD support is account/region dependent. This is a synthetic component fixture,
+// not evidence that the configured billing account accepts KWD invoices.
+const cases = [
+ { currency: 'usd', amountDue: 1099, expected: '$10.99' },
+ { currency: 'jpy', amountDue: 500, expected: '¥500' },
+ { currency: 'krw', amountDue: 500, expected: '₩500' },
+ { currency: 'kwd', amountDue: 12340, expected: 'KWD 12.340' },
+ { currency: 'isk', amountDue: 500, expected: 'ISK 5' },
+ { currency: 'ugx', amountDue: 500, expected: 'UGX 5' },
+];
+
+const NumberFormat = Intl.NumberFormat;
+
+beforeEach(() => {
+ // Pin the locale while retaining the real currency precision and formatting.
+ vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (locales, options) {
+ return new NumberFormat(locales ?? 'en-US', options);
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('actual Settings invoice display against Stripe currency contract', () => {
+ it.each(cases)('$currency amount_due=$amountDue displays $expected', async (fixture) => {
+ expect(new Intl.NumberFormat().resolvedOptions().locale).toBe('en-US');
+ const fetchMock = vi.fn(async (url: string) => {
+ if (url !== '/api/billing') return { ok: false };
+ return {
+ ok: true,
+ json: async () => ({
+ data: {
+ isEnabled: true,
+ isConfigured: true,
+ status: 'ready',
+ checkoutAvailable: false,
+ portalAvailable: false,
+ cancelAvailable: false,
+ cancelIsImmediate: true,
+ needsPaymentFix: true,
+ openInvoice: {
+ id: 'in_currency_fixture',
+ hostedInvoiceUrl: null,
+ amountDue: fixture.amountDue,
+ currency: fixture.currency,
+ attemptCount: 1,
+ nextPaymentAttempt: null,
+ },
+ workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
+ subscription: {
+ status: 'PAST_DUE',
+ label: 'Past due',
+ hasActiveSubscription: false,
+ hasRecoverableSubscription: true,
+ hasActiveTrial: false,
+ hasBillingAccess: true,
+ isPaid: false,
+ priceId: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ cancelAt: null,
+ trialEndsAt: null,
+ billingAccessEndedAt: null,
+ storageCleanupEligibleAt: null,
+ },
+ },
+ }),
+ };
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ render( );
+ const banner = await screen.findByText(/^A payment of .* did not go through$/);
+ const actual = banner.textContent!.replace(/\s+/g, ' ');
+ expect(fetchMock.mock.calls.some(([url]) => url === '/api/billing')).toBe(true);
+ expect(actual).toBe(`A payment of ${fixture.expected} did not go through`);
+ });
+});
diff --git a/tests/unit/lib/billing-invoices.test.ts b/tests/unit/lib/billing-invoices.test.ts
new file mode 100644
index 0000000..ab8ad14
--- /dev/null
+++ b/tests/unit/lib/billing-invoices.test.ts
@@ -0,0 +1,409 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import {
+ findCancelableStripeSubscription,
+ isCurrentSubscriptionInvoice,
+ voidOpenSubscriptionInvoices,
+} from '@/lib/billing';
+
+const stripe = vi.hoisted(() => ({
+ subscriptions: { list: vi.fn(), retrieve: vi.fn() },
+ invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() },
+}));
+
+vi.mock('@/lib/db', () => ({ db: {} }));
+vi.mock('@/lib/stripe', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getStripe: () => stripe,
+}));
+
+const START = 1_800_000_000;
+const END = 1_802_592_000;
+
+function subscription(overrides: Record = {}): Stripe.Subscription {
+ return {
+ id: 'sub_target',
+ customer: 'cus_target',
+ status: 'past_due',
+ created: 100,
+ latest_invoice: 'in_current',
+ cancel_at: null,
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: 'price_plan' },
+ current_period_start: START,
+ current_period_end: END,
+ },
+ ],
+ },
+ ...overrides,
+ } as unknown as Stripe.Subscription;
+}
+
+function line(overrides: Record = {}) {
+ return {
+ id: 'il_plan',
+ amount: 1900,
+ period: { start: START, end: END },
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_target', proration: false },
+ },
+ pricing: { price_details: { price: 'price_plan' } },
+ ...overrides,
+ };
+}
+
+function invoice(overrides: Record = {}): Stripe.Invoice {
+ return {
+ id: 'in_current',
+ customer: 'cus_target',
+ status: 'open',
+ amount_paid: 0,
+ amount_due: 1900,
+ billing_reason: 'subscription_cycle',
+ auto_advance: true,
+ parent: { subscription_details: { subscription: 'sub_target' } },
+ lines: { data: [line()], has_more: false },
+ ...overrides,
+ } as unknown as Stripe.Invoice;
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.stubEnv('STRIPE_PRICE_ID', 'price_plan');
+ stripe.subscriptions.retrieve.mockResolvedValue(subscription());
+ stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false });
+ stripe.invoices.list.mockResolvedValue({ data: [], has_more: false });
+ stripe.invoices.update.mockResolvedValue({});
+ stripe.invoices.voidInvoice.mockResolvedValue({});
+});
+
+describe('subscription invoice cleanup', () => {
+ it.each(['subscription_cycle', 'subscription_create'])(
+ 'voids a complete unpaid current %s invoice and returns its id',
+ async (billingReason) => {
+ const current = invoice({ billing_reason: billingReason });
+ stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false });
+
+ expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true);
+ await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([
+ 'in_current',
+ ]);
+
+ expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target');
+ expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', {
+ auto_advance: false,
+ });
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ }
+ );
+
+ const retainedInvoices: [string, () => Stripe.Invoice][] = [
+ [
+ 'a different start with the current end',
+ () =>
+ invoice({
+ lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false },
+ }),
+ ],
+ [
+ 'a different end with the current start',
+ () =>
+ invoice({
+ lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false },
+ }),
+ ],
+ ['an older invoice id', () => invoice({ id: 'in_old' })],
+ [
+ 'an older service period',
+ () =>
+ invoice({
+ lines: {
+ data: [line({ period: { start: START - 2_592_000, end: START } })],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a mixed invoice containing a manual charge',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line(),
+ line({
+ id: 'il_manual',
+ parent: { type: 'invoice_item_details', invoice_item_details: {} },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a proration',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line({
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_target', proration: true },
+ },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ ['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })],
+ ['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })],
+ [
+ 'a different price',
+ () =>
+ invoice({
+ lines: {
+ data: [line({ pricing: { price_details: { price: 'price_other' } } })],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a line belonging to a different subscription',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line({
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_other', proration: false },
+ },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ ['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })],
+ ['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })],
+ ];
+
+ it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => {
+ const retained = makeInvoice();
+ stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false });
+
+ expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false);
+ await expect(
+ voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
+ ).resolves.toEqual([]);
+ expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, {
+ auto_advance: false,
+ });
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled();
+ });
+
+ it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => {
+ stripe.invoices.list
+ .mockResolvedValueOnce({
+ data: [
+ invoice({ id: 'in_old' }),
+ invoice({
+ id: 'in_foreign',
+ parent: { subscription_details: { subscription: 'sub_other' } },
+ }),
+ ],
+ has_more: true,
+ })
+ .mockResolvedValueOnce({ data: [invoice()], has_more: false });
+
+ await expect(
+ voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
+ ).resolves.toEqual(['in_current']);
+ expect(stripe.invoices.list.mock.calls).toEqual([
+ [{ customer: 'cus_target', status: 'open', limit: 100 }],
+ [{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }],
+ ]);
+ expect(stripe.invoices.update.mock.calls).toEqual([
+ ['in_old', { auto_advance: false }],
+ ['in_current', { auto_advance: false }],
+ ]);
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ });
+
+ it('voids a current invoice even when collection was already paused before a retry', async () => {
+ stripe.invoices.list.mockResolvedValue({
+ data: [invoice({ auto_advance: false })],
+ has_more: false,
+ });
+
+ await expect(
+ voidOpenSubscriptionInvoices(
+ 'cus_target',
+ 'sub_target',
+ subscription({
+ status: 'canceled',
+ latest_invoice: { id: 'in_current' },
+ })
+ )
+ ).resolves.toEqual(['in_current']);
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ });
+
+ it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)(
+ 'propagates the Stripe %s failure rather than claiming successful cleanup',
+ async (operation) => {
+ const failure = new Error(`Stripe ${operation} failed`);
+ stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false });
+ const failingCall =
+ operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation];
+ failingCall.mockRejectedValueOnce(failure);
+
+ await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure);
+ expect(failingCall).toHaveBeenCalledTimes(1);
+ if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ }
+ );
+
+ it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => {
+ await expect(
+ voidOpenSubscriptionInvoices(
+ 'cus_target',
+ 'sub_target',
+ subscription({
+ customer: { id: 'cus_other' },
+ })
+ )
+ ).rejects.toThrow('Subscription customer mismatch');
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ });
+});
+
+describe('cancellation candidate selection', () => {
+ it.each([
+ { cancel_at: END, cancel_at_period_end: false },
+ { cancel_at: null, cancel_at_period_end: true },
+ ])(
+ 'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one',
+ async (schedule) => {
+ stripe.subscriptions.list
+ .mockResolvedValueOnce({
+ data: [
+ subscription({
+ id: 'sub_newer',
+ status: 'active',
+ created: 200,
+ ...schedule,
+ }),
+ ],
+ has_more: true,
+ })
+ .mockResolvedValueOnce({
+ data: [
+ subscription({
+ id: 'sub_older',
+ status: 'active',
+ created: 100,
+ }),
+ ],
+ has_more: false,
+ });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older');
+ expect(stripe.subscriptions.list.mock.calls).toEqual([
+ [{ customer: 'cus_target', status: 'all', limit: 100 }],
+ [{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }],
+ ]);
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each(['past_due', 'unpaid', 'incomplete'] as const)(
+ 'still selects a scheduled %s subscription for immediate cancellation',
+ async (status) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })],
+ has_more: false,
+ });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each([
+ ['a current invoice still awaiting void', { auto_advance: false }],
+ ['an older invoice still collecting', { id: 'in_old', auto_advance: true }],
+ ])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
+ expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({
+ customer: 'cus_target',
+ status: 'open',
+ limit: 100,
+ });
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ });
+
+ it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockResolvedValue({
+ data: [
+ invoice({ id: 'in_old', auto_advance: false }),
+ invoice({
+ id: 'in_foreign',
+ parent: { subscription_details: { subscription: 'sub_other' } },
+ }),
+ ],
+ has_more: false,
+ });
+
+ await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
+ });
+
+ it.each(['active', 'canceled'] as const)(
+ 'ignores a %s subscription for a different product',
+ async (status) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [
+ subscription({
+ status,
+ items: { data: [{ price: { id: 'price_other' } }] },
+ }),
+ ],
+ has_more: false,
+ });
+
+ await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => {
+ const failure = new Error('Stripe invoice lookup failed');
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockRejectedValueOnce(failure);
+
+ await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure);
+ });
+});
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index ddaec7d..7de40a4 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -15,6 +15,9 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
+ getInvoiceSubscriptionId,
+ getSubscriptionPeriodEnd,
+ getSubscriptionPeriodStart,
getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
@@ -33,6 +36,8 @@ import {
} from '@/lib/billing';
const dbMock = vi.hoisted(() => ({
+ $transaction: vi.fn(),
+ $executeRaw: vi.fn(),
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
@@ -152,7 +157,11 @@ describe('isPaidTier', () => {
it('counts an active subscription as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -163,7 +172,11 @@ describe('isPaidTier', () => {
it('counts a Stripe trial as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.TRIALING,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -175,6 +188,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -185,7 +199,11 @@ describe('isPaidTier', () => {
it('does not count a cardless trial as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.FREE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(false);
@@ -200,6 +218,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -212,6 +231,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -227,6 +247,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -239,6 +260,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -250,7 +272,11 @@ describe('isPaidTier', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.FREE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -366,7 +392,12 @@ describe('hasBillingAccess', () => {
).toBe(true);
});
- it('ignores billingAccessEndedAt while the paid period is still running', () => {
+ // Was the opposite assertion, on the premise that a future period end means a paid
+ // period. It does not: Stripe advances the period when it issues the renewal invoice,
+ // paid or not, and the period survives cancellation, so this exact shape (cutoff in the
+ // past, period end in the future) is what a subscription cancelled while behind on
+ // payment looks like. Honouring the period here handed out a free month.
+ it('honours billingAccessEndedAt even while the reported period is still running', () => {
const result = hasBillingAccess(
subject({
subscriptionStatus: 'CANCELED',
@@ -375,9 +406,36 @@ describe('hasBillingAccess', () => {
}),
NOW
);
+ expect(result).toBe(false);
+ });
+
+ // The other half of that: a stale cutoff must not outrank a live trial, or starting a
+ // cardless trial on a lapsed account would consume the account's one trial and grant
+ // nothing, since only a Stripe sync ever clears the cutoff.
+ it('lets an unexpired trial win over a cutoff already in the past', () => {
+ const result = hasBillingAccess(
+ subject({
+ subscriptionStatus: 'CANCELED',
+ trialEndsAt: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS),
+ }),
+ NOW
+ );
expect(result).toBe(true);
});
+ // Stripe stamps a period on a subscription whose first charge never went through.
+ it('refuses a period end carried by a subscription that never paid', () => {
+ const result = hasBillingAccess(
+ subject({
+ subscriptionStatus: 'INCOMPLETE_EXPIRED',
+ stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ }),
+ NOW
+ );
+ expect(result).toBe(false);
+ });
+
it('grants access to everyone when Stripe is disabled', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const result = hasBillingAccess(
@@ -393,7 +451,7 @@ describe('hasBillingAccess', () => {
});
describe('getBillingAccessEndDate', () => {
- it('prefers billingAccessEndedAt over every other date', () => {
+ it('keeps an independent trial beyond the subscription cutoff', () => {
const ended = new Date('2026-01-10T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -402,10 +460,10 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
- expect(result).toBe(ended);
+ expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
- it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => {
+ it('keeps a longer trial when billing has not been marked ended', () => {
const periodEnd = new Date('2026-02-01T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -413,7 +471,7 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
- expect(result).toBe(periodEnd);
+ expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to trialEndsAt when there is no paid period', () => {
@@ -452,7 +510,14 @@ describe('buildBillingAccessWhereInput', () => {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: NOW } },
- { stripeCurrentPeriodEnd: { gt: NOW } },
+ // Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end
+ // is only evidence of access when a payment stands behind it and no cutoff has
+ // passed. Scoped to this arm, not the whole query, so a live trial still wins.
+ {
+ stripeCurrentPeriodEnd: { gt: NOW },
+ subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] },
+ OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }],
+ },
],
});
});
@@ -472,36 +537,18 @@ describe('buildBillingAccessWhereInput', () => {
});
describe('buildExpiredBillingWhereInput', () => {
- it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
- const cutoff = new Date('2025-12-31T00:00:00.000Z');
-
- expect(buildExpiredBillingWhereInput(NOW)).toEqual({
- AND: [
- { subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
- { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
- { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
- {
- OR: [
- { billingAccessEndedAt: { lte: cutoff } },
- { AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] },
- ],
- },
- ],
+ it('requires the entire trial retention window before deleting an inactive account', () => {
+ const where = buildExpiredBillingWhereInput(NOW) as {
+ AND: Array>;
+ };
+ expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
+ expect(where.AND[1]).toEqual({
+ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
});
});
- // The NOT form this replaced could not express "no access" for a row whose date columns are
- // empty, because SQL turns a comparison against NULL into unknown rather than false. Every
- // branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
- // proves it against a real database; this only guards the shape.
- it('admits a null trial and a null period end as expired rather than skipping the row', () => {
- const where = buildExpiredBillingWhereInput(NOW) as {
- AND: Array<{ OR?: Array> }>;
- };
- expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
- expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
- });
-
+ // Real SQL behavior with null dates and future unpaid periods is covered by the
+ // API cleanup and entitlement suites; the unit check guards the retention boundary.
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
@@ -808,12 +855,98 @@ function updateData(): Record {
return dbMock.user.update.mock.calls[0][0].data as Record;
}
+// The shape Stripe actually sends on the pinned API version: the period lives on the
+// subscription's items, not on the subscription. `stripeSub` above still uses the older
+// top-level shape, so without these the whole reason this code exists goes untested and
+// every other test in this file passes through the legacy fallback instead.
+describe('Stripe field locations', () => {
+ const periodStart = 1_800_000_000;
+ const periodEnd = periodStart + 30 * 86_400;
+
+ function itemPeriodSub(overrides: Record = {}) {
+ return {
+ id: 'sub_1',
+ customer: 'cus_1',
+ status: 'past_due',
+ items: {
+ data: [
+ {
+ price: { id: ENTITLED_PRICE },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ },
+ ],
+ },
+ ...overrides,
+ } as unknown as Stripe.Subscription;
+ }
+
+ it('reads the period off the subscription items', () => {
+ expect(getSubscriptionPeriodEnd(itemPeriodSub())).toBe(periodEnd);
+ expect(getSubscriptionPeriodStart(itemPeriodSub())).toBe(periodStart);
+ });
+
+ // A webhook body can still be rendered at the version that was current when the
+ // endpoint was created, so the old location has to keep working.
+ it('falls back to the legacy top-level period', () => {
+ const legacy = {
+ items: { data: [{ price: { id: ENTITLED_PRICE } }] },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ } as unknown as Stripe.Subscription;
+
+ expect(getSubscriptionPeriodEnd(legacy)).toBe(periodEnd);
+ expect(getSubscriptionPeriodStart(legacy)).toBe(periodStart);
+ });
+
+ it('returns null when neither location carries a period', () => {
+ const bare = {
+ items: { data: [{ price: { id: ENTITLED_PRICE } }] },
+ } as unknown as Stripe.Subscription;
+
+ expect(getSubscriptionPeriodEnd(bare)).toBeNull();
+ expect(getSubscriptionPeriodStart(bare)).toBeNull();
+ });
+
+ it('reads the invoice subscription off parent.subscription_details', () => {
+ const invoice = {
+ parent: { subscription_details: { subscription: 'sub_9' } },
+ } as unknown as Stripe.Invoice;
+
+ expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
+ });
+
+ it('accepts an expanded subscription object on the invoice parent', () => {
+ const invoice = {
+ parent: { subscription_details: { subscription: { id: 'sub_9' } } },
+ } as unknown as Stripe.Invoice;
+
+ expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
+ });
+
+ it('falls back to the legacy top-level invoice subscription', () => {
+ expect(getInvoiceSubscriptionId({ subscription: 'sub_9' } as unknown as Stripe.Invoice)).toBe(
+ 'sub_9'
+ );
+ });
+
+ // A one-off invoice belongs to no subscription, and the webhook relies on this to leave
+ // the account alone rather than marking it canceled.
+ it('returns null for an invoice with no subscription', () => {
+ expect(getInvoiceSubscriptionId({} as unknown as Stripe.Invoice)).toBeNull();
+ });
+});
+
describe('database backed billing helpers', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
+ dbMock.$transaction
+ .mockReset()
+ .mockImplementation(async (work: (tx: typeof dbMock) => Promise) => work(dbMock));
+ dbMock.$executeRaw.mockReset().mockResolvedValue(0);
dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset();
@@ -1451,31 +1584,77 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
- it('keeps access while a canceled subscription is still inside its paid period', async () => {
+ // Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription
+ // keeps access to the reported period end. That is only right if the period was paid
+ // for, and a canceled subscription cannot tell you that it was: the period Stripe
+ // reports advances when the renewal invoice is issued and survives the cancellation,
+ // so this is also exactly the shape of "cancelled while behind on payment". A cutoff
+ // is stamped instead, and `ended_at` is what it comes from.
+ it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
+ const endedAt = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
stripeSub({
status: 'canceled',
+ ended_at: endedAt,
current_period_end: Math.floor(NOW.getTime() / 1000) + 3600,
})
);
expect(updateData()).toMatchObject({
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
- billingAccessEndedAt: null,
});
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000);
});
- it('ends access at the period end once the paid period has passed', async () => {
+ // Behind on payment but still being retried: access runs to the end of Stripe's retry
+ // window, measured from the period start, not to the period end Stripe advanced to
+ // cover the invoice that was never paid.
+ it('bounds a past_due subscription to the retry window', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
- const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600;
+ const periodStart = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
- stripeSub({ status: 'canceled', current_period_end: periodEnd })
+ stripeSub({
+ status: 'past_due',
+ current_period_start: periodStart,
+ current_period_end: periodStart + 30 * 24 * 60 * 60,
+ })
);
- expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000);
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
+ (periodStart + 14 * 24 * 60 * 60) * 1000
+ );
+ });
+
+ // The same thing through the payload shape production actually sends, where the period
+ // sits on the items rather than on the subscription. Every other fixture in this file
+ // uses the older top-level shape and so never exercises the read this change is for.
+ it('bounds a past_due subscription whose period is on its items', async () => {
+ dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
+ const periodStart = Math.floor(NOW.getTime() / 1000);
+ const periodEnd = periodStart + 30 * 24 * 60 * 60;
+
+ await syncStripeSubscriptionToUser({
+ id: 'sub_1',
+ customer: 'cus_1',
+ status: 'past_due',
+ items: {
+ data: [
+ {
+ price: { id: ENTITLED_PRICE },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ },
+ ],
+ },
+ } as unknown as Stripe.Subscription);
+
+ expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000);
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
+ (periodStart + 14 * 24 * 60 * 60) * 1000
+ );
});
it('falls back to ended_at when there is no period end', async () => {
@@ -1627,10 +1806,13 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
- expect(updateData().billingAccessEndedAt).toBeNull();
+ expect(updateData().billingAccessEndedAt).toEqual(NOW);
+ expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual(
+ new Date(NOW.getTime() + 19 * DAY_MS)
+ );
});
- it('clears a trial that has already run out', async () => {
+ it('preserves an expired trial for the storage retention calculation', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
@@ -1641,7 +1823,7 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
- expect(updateData().trialEndsAt).toBeNull();
+ expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
@@ -1705,7 +1887,8 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
- expect(updateData().billingAccessEndedAt).toBeNull();
+ expect(updateData().billingAccessEndedAt).toEqual(NOW);
+ expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true);
});
it('still ends access when the trial has already run out', async () => {
@@ -1716,7 +1899,7 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
- expect(updateData().trialEndsAt).toBeNull();
+ expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});