From fe1faeced4200d23e1048f0d2bbfcb24d796483a Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 8 Sep 2026 13:30:42 +0300
Subject: [PATCH 1/6] fix(billing): pin the Stripe API version and stop unpaid
periods granting access
The Stripe client was built without an apiVersion, so the SDK followed whatever
version it shipped with. Two fields moved in the Basil API version: the billing
period went from the subscription onto its items, and the invoice link to its
subscription went under parent.subscription_details. Both reads returned
undefined without failing, which left stripeCurrentPeriodEnd null for every
subscriber and left the app with no invoice handling at all. A customer whose
card failed saw nothing about the invoice that was still retrying, and a
cancellation did nothing to stop those retries.
- Pin the API version, with `satisfies` so an SDK bump is a compile error here
before it is a null read in production.
- Read the period off subscription items and the subscription off invoice
parents, keeping the legacy fields as a fallback for older payloads.
- Handle invoice.paid, invoice.payment_failed, invoice.voided and
invoice.marked_uncollectible through the existing customer-wide resync, so
the mirror reflects payment health during dunning rather than after it.
- Add an in-app cancellation route: at period end when the subscription is
paid, immediately plus voiding the open invoices when it is not, because
cancelling alone does not stop collection on an invoice already issued.
- Ask Stripe, not just the local mirror, before opening checkout.
- Show the open invoice, the retry date and a payment-method-update shortcut in
settings, and put a confirmation in front of cancellation.
Access no longer rests on the reported period alone. Stripe advances the period
when it issues the renewal invoice, paid or not, and the period survives
cancellation, so once the period field started being read correctly that check
would have handed a full free month to anyone whose renewal failed, and the new
cancel route would have let them void the invoice and keep the month. Access now
follows the subscription status, billingAccessEndedAt is enforced as a hard
cutoff in both hasBillingAccess and the query that mirrors it, and a subscription
behind on payment keeps access for Stripe's retry window rather than for the
period it never paid for.
---
.../settings/settings-page-client.tsx | 169 ++++++++++-
app/api/billing/cancel/route.ts | 78 +++++
app/api/billing/checkout/route.ts | 17 +-
app/api/billing/portal/route.ts | 45 ++-
app/api/billing/route.ts | 44 ++-
app/api/stripe/webhook/route.ts | 15 +
lib/billing.ts | 285 +++++++++++++++++-
lib/stripe.ts | 8 +-
package.json | 4 +-
scripts/resync-stripe-subscriptions.ts | 81 +++++
10 files changed, 718 insertions(+), 28 deletions(-)
create mode 100644 app/api/billing/cancel/route.ts
create mode 100644 scripts/resync-stripe-subscriptions.ts
diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx
index 96b3744..e052a97 100644
--- a/app/(dashboard)/settings/settings-page-client.tsx
+++ b/app/(dashboard)/settings/settings-page-client.tsx
@@ -29,8 +29,39 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
+/**
+ * Stripe reports amounts in the currency's smallest unit, and how many of those make a
+ * whole unit differs per currency: two for USD, none for JPY. The formatter knows the
+ * exponent, so it decides the divisor instead of a hardcoded 100.
+ */
+function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
+ const currencyCode = currency.toUpperCase();
+
+ try {
+ const formatter = new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency: currencyCode,
+ });
+ const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
+ return formatter.format(amountInMinorUnits / 10 ** fractionDigits);
+ } catch {
+ return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
+ }
+}
+
interface NotificationSettings {
telegramChatId: string | null;
telegramEnabled: boolean;
@@ -49,6 +80,16 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean;
portalAvailable: boolean;
+ cancelAvailable: boolean;
+ needsPaymentFix: boolean;
+ openInvoice: {
+ id: string | null;
+ hostedInvoiceUrl: string | null;
+ amountDue: number;
+ currency: string;
+ attemptCount: number;
+ nextPaymentAttempt: string | null;
+ } | null;
subscription: {
status: string;
label: string;
@@ -148,7 +189,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [testing, setTesting] = useState(null);
const [billing, setBilling] = useState(null);
const [billingLoading, setBillingLoading] = useState(true);
- const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null);
+ const [billingAction, setBillingAction] = useState<
+ 'checkout' | 'portal' | 'trial' | 'cancel' | null
+ >(null);
const [storageInfo, setStorageInfo] = useState(null);
const [storageLoading, setStorageLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -255,12 +298,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
);
const handleBillingRedirect = useCallback(
- async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
+ async (
+ endpoint: '/api/billing/checkout' | '/api/billing/portal',
+ flow?: 'payment_method_update'
+ ) => {
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(flow ? { flow } : {}),
});
const data = await res.json();
@@ -302,6 +349,38 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
}
}, [showMessage]);
+ const handleCancelSubscription = useCallback(async () => {
+ setBillingAction('cancel');
+ try {
+ const res = await fetch('/api/billing/cancel', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ showMessage('error', data.error || 'Failed to cancel subscription');
+ return;
+ }
+
+ const billingRes = await fetch('/api/billing');
+ if (billingRes.ok) {
+ setBilling((await billingRes.json()).data);
+ }
+
+ showMessage(
+ 'success',
+ data.data.canceledImmediately
+ ? 'Subscription canceled. No further payment will be attempted.'
+ : 'Subscription canceled. Access remains until the end of the current billing period.'
+ );
+ } catch {
+ showMessage('error', 'Failed to cancel subscription');
+ } finally {
+ setBillingAction(null);
+ }
+ }, [showMessage]);
+
if (loading) {
return (
@@ -481,10 +560,53 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
) : null}
+ {billing.openInvoice ? (
+
+
+ A payment of{' '}
+ {formatInvoiceAmount(
+ billing.openInvoice.amountDue,
+ billing.openInvoice.currency
+ )}{' '}
+ did not go through
+
+
+ {billing.openInvoice.attemptCount} attempt
+ {billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
+ {billing.openInvoice.nextPaymentAttempt
+ ? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
+ : ''}
+ . Update your payment method or pay the invoice to stop the retries, or cancel
+ to stop them for good.
+
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
>
)}
+
+ {billing.cancelAvailable ? (
+
+
+
+
+
+
+ Cancel your subscription?
+
+ {billing.needsPaymentFix
+ ? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.'
+ : 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'}
+
+
+
+ Keep Subscription
+
+ Cancel Subscription
+
+
+
+
+ ) : null}
>
)}
diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts
new file mode 100644
index 0000000..f8e08ae
--- /dev/null
+++ b/app/api/billing/cancel/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest } from 'next/server';
+import { auth } from '@/lib/auth';
+import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
+import {
+ findLiveStripeSubscription,
+ getBillingOverview,
+ isUnpaidStripeSubscription,
+ syncStripeSubscriptionToUser,
+ voidOpenSubscriptionInvoices,
+} from '@/lib/billing';
+import { rateLimit } from '@/lib/rate-limit';
+import { isStripeFeatureEnabled } from '@/lib/feature-flags';
+import { getStripe, isStripeConfigured } from '@/lib/stripe';
+import { isTrustedSameOriginRequest } from '@/lib/request-origin';
+import { logError } from '@/lib/logger';
+
+export async function POST(request: NextRequest) {
+ try {
+ const limited = await rateLimit(request, 'mutate');
+ if (limited) return limited;
+
+ if (!isTrustedSameOriginRequest(request)) {
+ return apiErrors.forbidden('Invalid request origin');
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return apiErrors.unauthorized();
+ }
+
+ if (!isStripeFeatureEnabled()) {
+ return apiErrors.badRequest('Stripe billing is disabled by this host');
+ }
+
+ if (!isStripeConfigured()) {
+ return apiErrors.internalError('Stripe billing is not configured');
+ }
+
+ const billing = await getBillingOverview(session.user.id);
+ const customerId = billing.subscription.stripeCustomerId;
+ if (!customerId) {
+ return apiErrors.badRequest('No Stripe customer exists for this account');
+ }
+
+ const subscription = await findLiveStripeSubscription(customerId);
+ if (!subscription) {
+ return apiErrors.badRequest('No subscription to cancel');
+ }
+
+ const stripe = getStripe();
+ const unpaid = isUnpaidStripeSubscription(subscription);
+
+ // Scheduling an unpaid subscription to the end of its period leaves the customer
+ // owing money for a period they never paid for, while the already issued invoice
+ // keeps retrying their card on its own. Those cancel immediately instead, and the
+ // invoice for the unserved period is voided in the same pass.
+ const canceled = unpaid
+ ? await stripe.subscriptions.cancel(subscription.id)
+ : await stripe.subscriptions.update(subscription.id, { cancel_at_period_end: true });
+
+ const voidedInvoices = unpaid
+ ? await voidOpenSubscriptionInvoices(customerId, subscription.id)
+ : [];
+
+ await syncStripeSubscriptionToUser(canceled);
+
+ const response = successResponse({
+ canceledImmediately: unpaid,
+ status: canceled.status,
+ cancelAt: canceled.cancel_at ? new Date(canceled.cancel_at * 1000).toISOString() : null,
+ voidedInvoices,
+ });
+ return withCacheControl(response, 'private, no-store');
+ } catch (error) {
+ logError('Error canceling Stripe subscription:', error);
+ return apiErrors.internalError('Failed to cancel subscription');
+ }
+}
diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts
index d8d6b52..7d1f944 100644
--- a/app/api/billing/checkout/route.ts
+++ b/app/api/billing/checkout/route.ts
@@ -1,7 +1,11 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
-import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing';
+import {
+ findBlockingStripeSubscription,
+ getOrCreateStripeCustomerId,
+ getStripeCheckoutState,
+} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
@@ -57,6 +61,17 @@ export async function POST(request: NextRequest) {
const stripe = getStripe();
const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id);
+
+ // The guard above reads the local mirror, which can be stale or cleared: the incident
+ // that prompted this had a customer holding three subscriptions at once because the
+ // mirror said there were none. Stripe is the one that knows.
+ const blockingSubscription = await findBlockingStripeSubscription(customerId);
+ if (blockingSubscription) {
+ return apiErrors.badRequest(
+ 'A subscription already exists for this account. Manage it from the billing portal.'
+ );
+ }
+
const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({
diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts
index 7b9f791..f2f8893 100644
--- a/app/api/billing/portal/route.ts
+++ b/app/api/billing/portal/route.ts
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server';
+import type Stripe from 'stripe';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
@@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) {
return request.nextUrl.origin;
}
+async function readRequestedFlow(request: NextRequest) {
+ try {
+ const body = await request.json();
+ return body?.flow === 'payment_method_update' ? 'payment_method_update' : null;
+ } catch {
+ return null;
+ }
+}
+
+async function createPortalSession(
+ stripe: Stripe,
+ customer: string,
+ returnUrl: string,
+ flow: 'payment_method_update' | null
+) {
+ if (flow === 'payment_method_update') {
+ try {
+ return await stripe.billingPortal.sessions.create({
+ customer,
+ return_url: returnUrl,
+ flow_data: { type: 'payment_method_update' },
+ });
+ } catch (error) {
+ // The portal configuration may not expose this flow; the plain portal still works.
+ logError('Falling back to the default Stripe portal flow:', error);
+ }
+ }
+
+ return stripe.billingPortal.sessions.create({
+ customer,
+ return_url: returnUrl,
+ });
+}
+
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
@@ -47,10 +82,12 @@ export async function POST(request: NextRequest) {
}
const stripe = getStripe();
- const portalSession = await stripe.billingPortal.sessions.create({
- customer: billing.subscription.stripeCustomerId,
- return_url: `${getAppOrigin(request)}/settings`,
- });
+ const portalSession = await createPortalSession(
+ stripe,
+ billing.subscription.stripeCustomerId,
+ `${getAppOrigin(request)}/settings`,
+ await readRequestedFlow(request)
+ );
const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store');
diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts
index 4720207..b85cae1 100644
--- a/app/api/billing/route.ts
+++ b/app/api/billing/route.ts
@@ -1,6 +1,7 @@
+import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
-import { getBillingOverview } from '@/lib/billing';
+import { getBillingOverview, getOpenInvoiceForCustomer } from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -15,12 +16,51 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig();
+
+ // Only looked up when the account actually owes something, so the common path does not
+ // pay for a Stripe round trip.
+ const needsPaymentFix =
+ billing.subscription.status === BillingSubscriptionStatus.PAST_DUE ||
+ billing.subscription.status === BillingSubscriptionStatus.UNPAID;
+ const openInvoice =
+ isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId
+ ? await getOpenInvoiceForCustomer(
+ billing.subscription.stripeCustomerId,
+ billing.subscription.stripeSubscriptionId
+ )
+ : null;
+
const response = successResponse({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
- portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
+ // A customer id alone is not enough: it is created on the first checkout attempt, so
+ // someone who abandoned checkout would be sent to an empty portal.
+ portalAvailable:
+ isStripeConfigured() &&
+ Boolean(billing.subscription.stripeCustomerId) &&
+ (billing.subscription.hasRecoverableSubscription ||
+ Boolean(billing.subscription.stripeSubscriptionId)),
+ // Gated on the status rather than on the mirrored subscription id: the id survives a
+ // cancellation until the deletion webhook arrives, and offering Cancel on an already
+ // canceled subscription just returns an error.
+ cancelAvailable:
+ isStripeConfigured() &&
+ billing.subscription.hasRecoverableSubscription &&
+ !billing.subscription.cancelAt &&
+ !billing.subscription.cancelAtPeriodEnd,
+ needsPaymentFix,
+ openInvoice: openInvoice
+ ? {
+ id: openInvoice.id,
+ hostedInvoiceUrl: openInvoice.hostedInvoiceUrl,
+ amountDue: openInvoice.amountDue,
+ currency: openInvoice.currency,
+ attemptCount: openInvoice.attemptCount,
+ nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null,
+ }
+ : null,
subscription: {
status: billing.subscription.status,
label: billing.subscription.label,
diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts
index 8fed380..52dbb4a 100644
--- a/app/api/stripe/webhook/route.ts
+++ b/app/api/stripe/webhook/route.ts
@@ -55,6 +55,21 @@ export async function POST(request: NextRequest) {
}
break;
}
+ // Invoice events carry the payment health of a subscription earlier and more
+ // reliably than the subscription events alone. Without them a customer whose card
+ // failed keeps the mirror of a healthy subscription until Stripe eventually gives
+ // up, which is the whole dunning window spent showing them the wrong state.
+ case 'invoice.paid':
+ case 'invoice.payment_failed':
+ case 'invoice.voided':
+ case 'invoice.marked_uncollectible': {
+ const invoice = event.data.object as Stripe.Invoice;
+ const customerId = getCustomerId(invoice.customer);
+ if (customerId) {
+ await syncStripeCustomerSubscriptions(customerId);
+ }
+ break;
+ }
default:
break;
}
diff --git a/lib/billing.ts b/lib/billing.ts
index 69b4bac..6eaa502 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -4,6 +4,7 @@ import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
+import { logError } from '@/lib/logger';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
@@ -35,6 +36,45 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
+// Stripe-side counterparts of the sets above, used where a raw Stripe subscription is in
+// hand rather than the mirrored status.
+
+// A customer holding one of these must not be sent through checkout again; they belong in
+// the billing portal. `incomplete` is deliberately absent: an abandoned first payment
+// leaves one behind for about a day, and blocking on it would lock the customer out of
+// checkout with nothing to fix in the portal.
+const BLOCKING_STRIPE_STATUSES = new Set([
+ 'active',
+ 'trialing',
+ 'past_due',
+ 'unpaid',
+]);
+
+// Subscriptions still worth mirroring onto the user.
+const LIVE_STRIPE_STATUSES = new Set([
+ 'active',
+ 'trialing',
+ 'past_due',
+ 'unpaid',
+]);
+
+// Cancelling one of these takes effect immediately: the open period was never paid for,
+// so there is nothing left to run out.
+const UNPAID_STRIPE_STATUSES = new Set([
+ 'past_due',
+ 'unpaid',
+ 'incomplete',
+]);
+
+// A subscription that was running and then missed a payment. It keeps access while Stripe
+// retries the card, so a customer whose card expired is not locked out before they have
+// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
+const RETRYING_STRIPE_STATUSES = new Set(['past_due', 'unpaid']);
+
+// Roughly Stripe's default Smart Retries window. Access follows the retry window rather
+// than the period Stripe advanced when it issued the invoice that was never paid.
+const UNPAID_ACCESS_GRACE_DAYS = 14;
+
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
@@ -124,6 +164,16 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true;
}
+ // A recorded end date is a hard stop, checked before anything else. Stripe advances the
+ // billing period the moment it issues the renewal invoice, paid or not, and the period
+ // survives cancellation, so the period end below would otherwise hand a full free month
+ // to anyone whose renewal fails. `isPaidTier` guards the storage tier against the same
+ // thing; this guards access itself, without the lockout that worried it: a subscription
+ // behind on payment is stamped with the end of Stripe's retry window, not with today.
+ if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
+ return false;
+ }
+
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
@@ -161,15 +211,24 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {};
}
+ // Mirrors `hasBillingAccess`, including its hard stop, so the query and the in-memory
+ // check cannot disagree about who still has access.
return {
- OR: [
+ AND: [
{
- subscriptionStatus: {
- in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
- },
+ OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
+ },
+ {
+ OR: [
+ {
+ subscriptionStatus: {
+ in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
+ },
+ },
+ { trialEndsAt: { gt: now } },
+ { stripeCurrentPeriodEnd: { gt: now } },
+ ],
},
- { trialEndsAt: { gt: now } },
- { stripeCurrentPeriodEnd: { gt: now } },
],
};
}
@@ -723,6 +782,72 @@ function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
+/**
+ * The billing period moved off the subscription and onto its items in the Basil API
+ * version, so reading `subscription.current_period_end` yields undefined on every current
+ * version. Webhook payloads can still be rendered at an older version, so the legacy field
+ * is kept as a fallback rather than dropped.
+ */
+export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
+ const itemPeriodEnds = (subscription.items?.data ?? [])
+ .map((item) =>
+ getStripeTimestamp(
+ (item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end
+ )
+ )
+ .filter((value): value is number => value !== null);
+
+ if (itemPeriodEnds.length > 0) {
+ return Math.max(...itemPeriodEnds);
+ }
+
+ return getStripeTimestamp(
+ (subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end
+ );
+}
+
+/**
+ * Same field move as the period end. Stripe opens the new period when it issues the
+ * renewal invoice, so for an unpaid subscription this is roughly when the first payment
+ * attempt failed, which is what the retry window is measured from.
+ */
+export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null {
+ const itemPeriodStarts = (subscription.items?.data ?? [])
+ .map((item) =>
+ getStripeTimestamp(
+ (item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start
+ )
+ )
+ .filter((value): value is number => value !== null);
+
+ if (itemPeriodStarts.length > 0) {
+ return Math.min(...itemPeriodStarts);
+ }
+
+ return getStripeTimestamp(
+ (subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start
+ );
+}
+
+/**
+ * The invoice link to its subscription moved under `parent.subscription_details` in the
+ * Basil API version. Same fallback reasoning as the period above.
+ */
+export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
+ const fromParent = invoice.parent?.subscription_details?.subscription;
+ if (typeof fromParent === 'string') return fromParent;
+ if (fromParent && typeof fromParent === 'object') return fromParent.id;
+
+ const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription;
+ if (typeof legacy === 'string') return legacy;
+ if (legacy && typeof legacy === 'object' && 'id' in legacy) {
+ const id = (legacy as { id: unknown }).id;
+ return typeof id === 'string' ? id : null;
+ }
+
+ return null;
+}
+
function getInactiveBillingAccessEndedAt(
subscription: Stripe.Subscription,
currentPeriodEnd: number | null
@@ -733,9 +858,27 @@ function getInactiveBillingAccessEndedAt(
const canceledAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at
);
- const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
- return reference ? new Date(reference * 1000) : new Date();
+ // `ended_at` wins over everything: a subscription killed mid-period for non-payment
+ // must not keep access until a period the customer never paid for.
+ if (endedAt) {
+ return new Date(endedAt * 1000);
+ }
+
+ // Still running, just behind on payment: access ends when Stripe gives up retrying, not
+ // at the period end, which Stripe already advanced to cover the unpaid invoice.
+ if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
+ const periodStart = getSubscriptionPeriodStart(subscription);
+ if (periodStart) {
+ const graceEnd = periodStart + UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
+ return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
+ }
+ }
+
+ // Anything else that gets here never paid for the period Stripe is reporting, so that
+ // period is not a date access can run to. `incomplete` and `incomplete_expired` are the
+ // cases that matter: their very first payment never went through.
+ return canceledAt ? new Date(canceledAt * 1000) : new Date();
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
@@ -768,10 +911,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
return null;
}
- const currentPeriodEnd =
- 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
- ? subscription.current_period_end
- : null;
+ const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
const cancelAt =
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
? subscription.cancel_at
@@ -797,10 +937,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// what stops an abandoned or failed checkout from erasing the days the account
// still had. Legacy card-backed trials keep arriving through the branch above.
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
- const hasAccess =
- hasEntitledPrice &&
- (hasActiveSubscription(mappedStatus) ||
- Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
+ // The reported period is not proof of payment: Stripe advances it when it issues the
+ // renewal invoice, paid or not, and it survives cancellation. Access therefore follows
+ // the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
+ // which is cleared again as soon as the subscription goes back to active.
+ const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
const updated = await db.user.update({
where: { id: user.id },
@@ -981,3 +1122,115 @@ export async function markSubscriptionCanceledByCustomerId(
return updated;
}
+
+/**
+ * Returns a subscription of this customer that still grants access, if any. A customer can
+ * hold several at once, so the state of one says nothing about the others.
+ */
+export async function findLiveStripeSubscription(customerId: string) {
+ const stripe = getStripe();
+ const { data: subscriptions } = await stripe.subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ });
+
+ return (
+ selectAuthoritativeSubscription(
+ subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status))
+ ) ?? null
+ );
+}
+
+/**
+ * Asked before opening checkout. Answered by Stripe rather than by the local mirror: the
+ * mirror can be stale or cleared, and a customer who slips past this ends up paying for two
+ * subscriptions at once.
+ */
+export async function findBlockingStripeSubscription(customerId: string) {
+ const stripe = getStripe();
+ const { data: subscriptions } = await stripe.subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ });
+
+ return (
+ subscriptions.find((subscription) => BLOCKING_STRIPE_STATUSES.has(subscription.status)) ?? null
+ );
+}
+
+export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
+ return UNPAID_STRIPE_STATUSES.has(subscription.status);
+}
+
+/**
+ * Cancelling a subscription in Stripe does not stop collection on invoices that were
+ * already issued; they keep retrying on their own until they are paid or voided. Only
+ * invoices for a period the customer never paid for are voided here.
+ */
+export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
+ const stripe = getStripe();
+ const { data: invoices } = await stripe.invoices.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ });
+
+ const voided: string[] = [];
+
+ for (const invoice of invoices) {
+ if (!invoice.id) continue;
+ if (getInvoiceSubscriptionId(invoice) !== subscriptionId) continue;
+
+ try {
+ await stripe.invoices.voidInvoice(invoice.id);
+ voided.push(invoice.id);
+ } catch (error) {
+ logError(`Failed to void Stripe invoice ${invoice.id}:`, error);
+ }
+ }
+
+ return voided;
+}
+
+/**
+ * Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
+ * is: a customer can carry an open invoice left behind by a subscription they no longer
+ * hold, and pointing them at that one does nothing about the retries they are seeing.
+ */
+export async function getOpenInvoiceForCustomer(
+ customerId: string,
+ subscriptionId?: string | null
+) {
+ const stripe = getStripe();
+ const { data: invoices } = await stripe.invoices.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ });
+
+ const candidates = subscriptionId
+ ? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
+ : invoices;
+
+ const newest = candidates
+ .slice()
+ .sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
+ .at(0);
+
+ if (!newest) {
+ return null;
+ }
+
+ return {
+ id: newest.id ?? null,
+ hostedInvoiceUrl: newest.hosted_invoice_url ?? null,
+ amountDue: newest.amount_due ?? newest.total ?? 0,
+ currency: newest.currency ?? 'usd',
+ attemptCount: newest.attempt_count ?? 0,
+ nextPaymentAttempt: newest.next_payment_attempt
+ ? new Date(newest.next_payment_attempt * 1000)
+ : null,
+ };
+}
diff --git a/lib/stripe.ts b/lib/stripe.ts
index 8e1adda..afadb9b 100644
--- a/lib/stripe.ts
+++ b/lib/stripe.ts
@@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
let stripeClient: Stripe | null = null;
+// Pinned on purpose. Without it the SDK silently follows whatever version it ships
+// with, and field moves between versions (the subscription period moving onto items,
+// the invoice subscription link moving under `parent`) turn into null reads instead
+// of build failures. `satisfies` makes an SDK bump a compile error here first.
+const STRIPE_API_VERSION = '2026-02-25.clover' satisfies Stripe.LatestApiVersion;
+
export function isStripeConfigured() {
return isStripeBillingEnabled();
}
@@ -18,7 +24,7 @@ export function getStripe() {
}
if (!stripeClient) {
- stripeClient = new Stripe(secretKey);
+ stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION });
}
return stripeClient;
diff --git a/package.json b/package.json
index e2742e1..6ff5811 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,9 @@
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
- "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts"
+ "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts",
+ "stripe:resync:dry": "bun run scripts/resync-stripe-subscriptions.ts --dry-run",
+ "stripe:resync": "bun run scripts/resync-stripe-subscriptions.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts
new file mode 100644
index 0000000..0a06235
--- /dev/null
+++ b/scripts/resync-stripe-subscriptions.ts
@@ -0,0 +1,81 @@
+/**
+ * Re-reads every Stripe customer's live subscription and writes it back onto the user
+ * through the normal sync path.
+ *
+ * Needed once after a Stripe API version change: mirrored fields that moved between
+ * versions stay wrong in the database until that customer happens to produce a webhook,
+ * which for a customer whose payment already failed may never happen on its own.
+ */
+import { db, disconnectDb } from '../lib/db';
+import { findLiveStripeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing';
+import { isStripeConfigured } from '../lib/stripe';
+import { logError } from '../lib/logger';
+
+const TAG = '[resync-stripe-subscriptions]';
+
+async function main() {
+ const dryRun = process.argv.includes('--dry-run');
+
+ if (!isStripeConfigured()) {
+ console.log(`${TAG} Stripe is not configured, nothing to do`);
+ return;
+ }
+
+ const users = await db.user.findMany({
+ where: { stripeCustomerId: { not: null } },
+ select: { id: true, email: true, stripeCustomerId: true, stripeCurrentPeriodEnd: true },
+ });
+
+ let synced = 0;
+ let withoutSubscription = 0;
+ let failed = 0;
+
+ for (const user of users) {
+ if (!user.stripeCustomerId) continue;
+
+ try {
+ const subscription = await findLiveStripeSubscription(user.stripeCustomerId);
+
+ if (!subscription) {
+ withoutSubscription += 1;
+ continue;
+ }
+
+ const label = user.email ?? user.id;
+
+ if (dryRun) {
+ console.log(
+ `${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
+ );
+ synced += 1;
+ continue;
+ }
+
+ const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId);
+ if (updated) {
+ console.log(
+ `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
+ );
+ synced += 1;
+ }
+ } catch (error) {
+ failed += 1;
+ logError(`${TAG} Failed syncing ${user.email ?? user.id}:`, error);
+ }
+ }
+
+ console.log(`${TAG} Summary${dryRun ? ' (dry run)' : ''}`);
+ console.log(`${TAG} Customers: ${users.length}`);
+ console.log(`${TAG} Synced: ${synced}`);
+ console.log(`${TAG} Without a live subscription: ${withoutSubscription}`);
+ console.log(`${TAG} Failed: ${failed}`);
+}
+
+main()
+ .catch((error) => {
+ logError(`${TAG} Fatal error:`, error);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await disconnectDb();
+ });
From 85855a6d52792486810040efd0bb62fd4af64e42 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 8 Sep 2026 13:51:59 +0300
Subject: [PATCH 2/6] fix(billing): close the gaps the code and security
reviews found
Follow-up on the same change, from a high-effort code review and security
review run over the diff.
Access gate:
- Scope both period-end guards to the period-end branch of hasBillingAccess
instead of the top of the function. A cutoff is only ever cleared by a Stripe
sync, so checking it first meant a stale one from a lapsed subscription
outranked a freshly started cardless trial: the account burned its
once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors
the same shape.
- Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED
subscription, the rejection isPaidTier already makes. The cutoff is
deliberately left null while a trial is live, so a trial user who abandoned a
checkout kept the failed subscription's period once the trial ran out.
- Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period
where hasBillingAccess says access is over. That split left a locked-out
account with no banner explaining it and able to create workspaces it could
not then see. Both callers now select the field.
Lifecycle:
- Cancel through syncStripeCustomerSubscriptions rather than writing the single
cancelled subscription, so a customer holding a second live subscription is
not locked out of an account they are still being billed for.
- Ignore invoice events with no subscription. A one-off invoice against a
customer record left by an abandoned checkout was marking the account
canceled and booking a churn event for a subscription that never existed.
- Fall back to a window measured from now when a subscription behind on payment
reports no period start, rather than falling through to "access ended", which
locked out the customer that branch exists to keep in.
- Let a paused subscription run to its period end; it was being ended at once.
- Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include
incomplete. The two sets were identical, which offered a Cancel button that
always returned "No subscription to cancel" and left the Stripe-side checkout
guard weaker than the mirror check it backs up.
UI and ops:
- cancelIsImmediate from the API, so the confirmation says what will actually
happen to an incomplete subscription instead of promising the period end.
- The access banner reads "ended on" once the date has passed.
- The resync script selects the way the write path selects, over the customer's
whole set. Filtering to live subscriptions first made the dry run disagree
with the real run and skipped canceled and incomplete customers entirely,
who are exactly the stale mirrors the script exists for.
Three existing tests asserted the behaviour this fixes: that a canceled
subscription keeps access to its reported period end, and that the cutoff is
ignored while that period runs. Both rest on the premise that a future period
end means a paid period, which is what is not true. They now assert the bound,
alongside new cases for the retry window, the trial-versus-stale-cutoff
ordering, and a never-paid period.
---
.../settings/settings-page-client.tsx | 8 +-
app/api/billing/cancel/route.ts | 7 +-
app/api/billing/route.ts | 5 +
app/api/projects/route.ts | 6 +-
app/api/stripe/webhook/route.ts | 8 +-
lib/billing.ts | 125 +++++++++++-------
lib/storage-quota.ts | 2 +-
scripts/resync-stripe-subscriptions.ts | 21 ++-
tests/unit/lib/billing.test.ts | 101 ++++++++++++--
9 files changed, 205 insertions(+), 78 deletions(-)
diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx
index e052a97..c3a6383 100644
--- a/app/(dashboard)/settings/settings-page-client.tsx
+++ b/app/(dashboard)/settings/settings-page-client.tsx
@@ -81,6 +81,7 @@ interface BillingOverview {
checkoutAvailable: boolean;
portalAvailable: boolean;
cancelAvailable: boolean;
+ cancelIsImmediate: boolean;
needsPaymentFix: boolean;
openInvoice: {
id: string | null;
@@ -581,8 +582,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{billing.subscription.billingAccessEndedAt ? (
- Access to your workspaces continues until{' '}
- {new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.
+ {new Date(billing.subscription.billingAccessEndedAt) > new Date()
+ ? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
+ : `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
) : null}
{billing.openInvoice.hostedInvoiceUrl ? (
@@ -673,7 +675,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
Cancel your subscription?
- {billing.needsPaymentFix
+ {billing.cancelIsImmediate
? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.'
: 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'}
diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts
index f8e08ae..a1b6a05 100644
--- a/app/api/billing/cancel/route.ts
+++ b/app/api/billing/cancel/route.ts
@@ -5,7 +5,7 @@ import {
findLiveStripeSubscription,
getBillingOverview,
isUnpaidStripeSubscription,
- syncStripeSubscriptionToUser,
+ syncStripeCustomerSubscriptions,
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
@@ -62,7 +62,10 @@ export async function POST(request: NextRequest) {
? await voidOpenSubscriptionInvoices(customerId, subscription.id)
: [];
- await syncStripeSubscriptionToUser(canceled);
+ // Re-derived from the customer's whole set rather than written from `canceled` alone.
+ // A customer can hold more than one subscription, and mirroring just the one that was
+ // cancelled would lock out an account still being billed on another.
+ await syncStripeCustomerSubscriptions(customerId);
const response = successResponse({
canceledImmediately: unpaid,
diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts
index b85cae1..5c49907 100644
--- a/app/api/billing/route.ts
+++ b/app/api/billing/route.ts
@@ -51,6 +51,11 @@ export async function GET() {
!billing.subscription.cancelAt &&
!billing.subscription.cancelAtPeriodEnd,
needsPaymentFix,
+ // Whether cancelling ends the subscription there and then rather than at the period
+ // end, which is what the confirmation copy has to say. Mirrors the branch the cancel
+ // route takes: nothing was paid for the open period, so there is nothing to run out.
+ cancelIsImmediate:
+ needsPaymentFix || billing.subscription.status === BillingSubscriptionStatus.INCOMPLETE,
openInvoice: openInvoice
? {
id: openInvoice.id,
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index 746d55a..180ddca 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
const owner = await db.user.findUnique({
where: { id: workspace.ownerId },
- select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
+ select: {
+ subscriptionStatus: true,
+ stripeCurrentPeriodEnd: true,
+ billingAccessEndedAt: true,
+ },
});
if (owner && !isPaidTier(owner)) {
diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts
index 52dbb4a..9815204 100644
--- a/app/api/stripe/webhook/route.ts
+++ b/app/api/stripe/webhook/route.ts
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
-import { syncStripeCustomerSubscriptions } from '@/lib/billing';
+import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -65,7 +65,11 @@ export async function POST(request: NextRequest) {
case 'invoice.marked_uncollectible': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = getCustomerId(invoice.customer);
- if (customerId) {
+ // Only subscription invoices. A one-off invoice against a customer record left
+ // behind by an abandoned checkout has no subscription, and syncing on it would
+ // find an empty list, mark the account canceled and book a churn event for a
+ // subscription that never existed.
+ if (customerId && getInvoiceSubscriptionId(invoice)) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
diff --git a/lib/billing.ts b/lib/billing.ts
index 6eaa502..2807bb4 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -36,26 +36,17 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
-// Stripe-side counterparts of the sets above, used where a raw Stripe subscription is in
-// hand rather than the mirrored status.
-
-// A customer holding one of these must not be sent through checkout again; they belong in
-// the billing portal. `incomplete` is deliberately absent: an abandoned first payment
-// leaves one behind for about a day, and blocking on it would lock the customer out of
-// checkout with nothing to fix in the portal.
-const BLOCKING_STRIPE_STATUSES = new Set([
- 'active',
- 'trialing',
- 'past_due',
- 'unpaid',
-]);
-
-// Subscriptions still worth mirroring onto the user.
+// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that
+// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same
+// membership: a subscription worth cancelling is a subscription worth blocking a second
+// checkout over, and two sets that disagreed only produced a Cancel button that always
+// failed and a checkout guard weaker than the mirror it was backing up.
const LIVE_STRIPE_STATUSES = new Set([
'active',
'trialing',
'past_due',
'unpaid',
+ 'incomplete',
]);
// Cancelling one of these takes effect immediately: the open period was never paid for,
@@ -135,7 +126,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
* A legacy Stripe trial counts as paid because a card was handed over for it.
*/
export function isPaidTier(
- subject: Pick,
+ subject: Pick<
+ BillingAccessSubject,
+ 'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
+ >,
now: Date = new Date()
) {
if (!isStripeFeatureEnabled()) {
@@ -146,14 +140,19 @@ export function isPaidTier(
return true;
}
- // The period end alone is not proof of payment. Checked here and not in
- // `hasBillingAccess`, which keeps granting access on a period end it did not
- // question before: the cost of being wrong there is a customer locked out,
- // while the cost of being wrong here is a free account holding 200 GB.
+ // The period end alone is not proof of payment.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
+ // Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
+ // behind on payment. They did once: access stopped at the end of Stripe's retry window
+ // while this kept saying "paid" for the rest of the period, which left the account with
+ // no banner explaining the lockout and able to create workspaces it could not then see.
+ if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
+ return false;
+ }
+
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
@@ -164,16 +163,6 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true;
}
- // A recorded end date is a hard stop, checked before anything else. Stripe advances the
- // billing period the moment it issues the renewal invoice, paid or not, and the period
- // survives cancellation, so the period end below would otherwise hand a full free month
- // to anyone whose renewal fails. `isPaidTier` guards the storage tier against the same
- // thing; this guards access itself, without the lockout that worried it: a subscription
- // behind on payment is stamped with the end of Stripe's retry window, not with today.
- if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
- return false;
- }
-
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
@@ -182,6 +171,26 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true;
}
+ // Everything below decides whether the reported period still stands in for access, and
+ // the two guards exist because it very often does not. Both are scoped to this branch
+ // rather than applied at the top of the function: `billingAccessEndedAt` is only ever
+ // cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise
+ // outrank a freshly started cardless trial and burn the account's one trial for nothing.
+
+ // Stripe stamps a period on a subscription whose first charge never went through, so
+ // that period is not evidence of payment. The same rejection `isPaidTier` makes.
+ if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
+ return false;
+ }
+
+ // Stripe advances the period the moment it issues the renewal invoice, paid or not, and
+ // the period survives cancellation, so on its own it would hand a full free month to
+ // anyone whose renewal fails. This is the bound: a subscription behind on payment is
+ // stamped with the end of Stripe's retry window, a cancelled one with `ended_at`.
+ if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
+ return false;
+ }
+
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
@@ -211,23 +220,21 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {};
}
- // Mirrors `hasBillingAccess`, including its hard stop, so the query and the in-memory
- // check cannot disagree about who still has access.
+ // Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its
+ // period-end arm, so the query and the in-memory check cannot disagree about who still
+ // has access.
return {
- AND: [
+ OR: [
{
- OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
+ subscriptionStatus: {
+ in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
+ },
},
+ { trialEndsAt: { gt: now } },
{
- OR: [
- {
- subscriptionStatus: {
- in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
- },
- },
- { trialEndsAt: { gt: now } },
- { stripeCurrentPeriodEnd: { gt: now } },
- ],
+ stripeCurrentPeriodEnd: { gt: now },
+ subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
+ OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
},
],
};
@@ -866,13 +873,23 @@ function getInactiveBillingAccessEndedAt(
}
// Still running, just behind on payment: access ends when Stripe gives up retrying, not
- // at the period end, which Stripe already advanced to cover the unpaid invoice.
+ // at the period end, which Stripe already advanced to cover the unpaid invoice. The
+ // period start is when that invoice was issued, so it is what the window runs from; when
+ // it is missing (a paginated item list, an older payload shape) the window runs from now
+ // instead. Falling through to "ended" here would lock out the customer this branch
+ // exists to keep in, which is the wrong way to fail on missing data.
if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
+ const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
const periodStart = getSubscriptionPeriodStart(subscription);
- if (periodStart) {
- const graceEnd = periodStart + UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
- return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
- }
+ const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace;
+
+ return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
+ }
+
+ // A pause is not a non-payment: the period behind it was paid for, so it runs out
+ // normally. Stripe's portal pauses keep the status `active`, but the API can set this.
+ if (subscription.status === 'paused' && currentPeriodEnd) {
+ return new Date(currentPeriodEnd * 1000);
}
// Anything else that gets here never paid for the period Stripe is reporting, so that
@@ -1156,7 +1173,7 @@ export async function findBlockingStripeSubscription(customerId: string) {
});
return (
- subscriptions.find((subscription) => BLOCKING_STRIPE_STATUSES.has(subscription.status)) ?? null
+ subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null
);
}
@@ -1166,8 +1183,14 @@ export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
/**
* Cancelling a subscription in Stripe does not stop collection on invoices that were
- * already issued; they keep retrying on their own until they are paid or voided. Only
- * invoices for a period the customer never paid for are voided here.
+ * already issued; they keep retrying on their own until they are paid or voided. Voiding
+ * them is what actually stops the card being charged after someone has cancelled.
+ *
+ * Note this writes off a real receivable, not only an unserved one: a `past_due` customer
+ * has had access for up to `UNPAID_ACCESS_GRACE_DAYS` before they get here. That is a
+ * deliberate trade, on the grounds that chasing a single month of a small subscription
+ * costs more than it recovers and that the customer is leaving anyway. `markUncollectible`
+ * is the one-line change if the receivable should be kept on the books instead.
*/
export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
const stripe = getStripe();
diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts
index f9bbba3..64438fe 100644
--- a/lib/storage-quota.ts
+++ b/lib/storage-quota.ts
@@ -43,7 +43,7 @@ export interface StorageContext {
export async function getStorageContextForUser(userId: string): Promise {
const user = await db.user.findUnique({
where: { id: userId },
- select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
+ select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true },
});
const isPaid = user ? isPaidTier(user) : false;
diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts
index 0a06235..bec7f00 100644
--- a/scripts/resync-stripe-subscriptions.ts
+++ b/scripts/resync-stripe-subscriptions.ts
@@ -7,8 +7,8 @@
* which for a customer whose payment already failed may never happen on its own.
*/
import { db, disconnectDb } from '../lib/db';
-import { findLiveStripeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing';
-import { isStripeConfigured } from '../lib/stripe';
+import { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing';
+import { getStripe, isStripeConfigured } from '../lib/stripe';
import { logError } from '../lib/logger';
const TAG = '[resync-stripe-subscriptions]';
@@ -34,15 +34,24 @@ async function main() {
if (!user.stripeCustomerId) continue;
try {
- const subscription = await findLiveStripeSubscription(user.stripeCustomerId);
+ const label = user.email ?? user.id;
+
+ // Selected exactly the way the write path selects, over the customer's whole set
+ // rather than the live ones only. A mirror left wrong by the version change is most
+ // likely on a customer whose subscription is already canceled or incomplete, which
+ // is precisely who a live-only filter would skip.
+ const { data: subscriptions } = await getStripe().subscriptions.list({
+ customer: user.stripeCustomerId,
+ status: 'all',
+ limit: 100,
+ });
+ const subscription = selectAuthoritativeSubscription(subscriptions);
if (!subscription) {
withoutSubscription += 1;
continue;
}
- const label = user.email ?? user.id;
-
if (dryRun) {
console.log(
`${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
@@ -54,7 +63,7 @@ async function main() {
const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId);
if (updated) {
console.log(
- `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
+ `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}, access ends ${updated.billingAccessEndedAt?.toISOString() ?? 'null'}`
);
synced += 1;
}
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index ddaec7d..432faf6 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -152,7 +152,11 @@ describe('isPaidTier', () => {
it('counts an active subscription as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -163,7 +167,11 @@ describe('isPaidTier', () => {
it('counts a Stripe trial as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.TRIALING,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -175,6 +183,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -185,7 +194,11 @@ describe('isPaidTier', () => {
it('does not count a cardless trial as paid', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.FREE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(false);
@@ -200,6 +213,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -212,6 +226,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -227,6 +242,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -239,6 +255,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
+ billingAccessEndedAt: null,
},
NOW
)
@@ -250,7 +267,11 @@ describe('isPaidTier', () => {
expect(
isPaidTier(
- { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
+ {
+ subscriptionStatus: BillingSubscriptionStatus.FREE,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: null,
+ },
NOW
)
).toBe(true);
@@ -366,7 +387,12 @@ describe('hasBillingAccess', () => {
).toBe(true);
});
- it('ignores billingAccessEndedAt while the paid period is still running', () => {
+ // Was the opposite assertion, on the premise that a future period end means a paid
+ // period. It does not: Stripe advances the period when it issues the renewal invoice,
+ // paid or not, and the period survives cancellation, so this exact shape (cutoff in the
+ // past, period end in the future) is what a subscription cancelled while behind on
+ // payment looks like. Honouring the period here handed out a free month.
+ it('honours billingAccessEndedAt even while the reported period is still running', () => {
const result = hasBillingAccess(
subject({
subscriptionStatus: 'CANCELED',
@@ -375,9 +401,36 @@ describe('hasBillingAccess', () => {
}),
NOW
);
+ expect(result).toBe(false);
+ });
+
+ // The other half of that: a stale cutoff must not outrank a live trial, or starting a
+ // cardless trial on a lapsed account would consume the account's one trial and grant
+ // nothing, since only a Stripe sync ever clears the cutoff.
+ it('lets an unexpired trial win over a cutoff already in the past', () => {
+ const result = hasBillingAccess(
+ subject({
+ subscriptionStatus: 'CANCELED',
+ trialEndsAt: new Date(NOW.getTime() + DAY_MS),
+ billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS),
+ }),
+ NOW
+ );
expect(result).toBe(true);
});
+ // Stripe stamps a period on a subscription whose first charge never went through.
+ it('refuses a period end carried by a subscription that never paid', () => {
+ const result = hasBillingAccess(
+ subject({
+ subscriptionStatus: 'INCOMPLETE_EXPIRED',
+ stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
+ }),
+ NOW
+ );
+ expect(result).toBe(false);
+ });
+
it('grants access to everyone when Stripe is disabled', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const result = hasBillingAccess(
@@ -452,7 +505,14 @@ describe('buildBillingAccessWhereInput', () => {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: NOW } },
- { stripeCurrentPeriodEnd: { gt: NOW } },
+ // Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end
+ // is only evidence of access when a payment stands behind it and no cutoff has
+ // passed. Scoped to this arm, not the whole query, so a live trial still wins.
+ {
+ stripeCurrentPeriodEnd: { gt: NOW },
+ subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] },
+ OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }],
+ },
],
});
});
@@ -1451,31 +1511,48 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
- it('keeps access while a canceled subscription is still inside its paid period', async () => {
+ // Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription
+ // keeps access to the reported period end. That is only right if the period was paid
+ // for, and a canceled subscription cannot tell you that it was: the period Stripe
+ // reports advances when the renewal invoice is issued and survives the cancellation,
+ // so this is also exactly the shape of "cancelled while behind on payment". A cutoff
+ // is stamped instead, and `ended_at` is what it comes from.
+ it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
+ const endedAt = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
stripeSub({
status: 'canceled',
+ ended_at: endedAt,
current_period_end: Math.floor(NOW.getTime() / 1000) + 3600,
})
);
expect(updateData()).toMatchObject({
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
- billingAccessEndedAt: null,
});
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000);
});
- it('ends access at the period end once the paid period has passed', async () => {
+ // Behind on payment but still being retried: access runs to the end of Stripe's retry
+ // window, measured from the period start, not to the period end Stripe advanced to
+ // cover the invoice that was never paid.
+ it('bounds a past_due subscription to the retry window', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
- const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600;
+ const periodStart = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
- stripeSub({ status: 'canceled', current_period_end: periodEnd })
+ stripeSub({
+ status: 'past_due',
+ current_period_start: periodStart,
+ current_period_end: periodStart + 30 * 24 * 60 * 60,
+ })
);
- expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000);
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
+ (periodStart + 14 * 24 * 60 * 60) * 1000
+ );
});
it('falls back to ended_at when there is no period end', async () => {
From d5cb288719d3c3095cc272d9852954187c68e2f3 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 8 Sep 2026 13:58:54 +0300
Subject: [PATCH 3/6] test(api): register the billing cancel route in the auth
matrix
The api suite enumerates every route module under app/api and requires each
one to be classified as session-guarded or deliberately public. The new cancel
route was neither, so the suite failed on an unclassified module and on the
module count. It takes the same shape as the other billing routes: a session
plus a same-origin header.
---
tests/api/auth-matrix.test.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts
index 3ecb072..91d9ffd 100644
--- a/tests/api/auth-matrix.test.ts
+++ b/tests/api/auth-matrix.test.ts
@@ -48,6 +48,7 @@ import * as adminGrowthRoute from '@/app/api/admin/growth/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
+import * as billingCancelRoute from '@/app/api/billing/cancel/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
import * as billingTrialRoute from '@/app/api/billing/trial/route';
@@ -149,7 +150,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
-const EXPECTED_ROUTE_MODULE_COUNT = 67;
+const EXPECTED_ROUTE_MODULE_COUNT = 68;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -405,6 +406,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
params: (f) => ({ requestId: f.approvalRequestId }),
body: { decision: 'APPROVED' },
},
+ {
+ file: 'billing/cancel/route.ts',
+ module: billingCancelRoute,
+ url: () => '/api/billing/cancel',
+ headers: { origin: 'http://localhost:3000' },
+ },
{
file: 'billing/checkout/route.ts',
module: billingCheckoutRoute,
From c0809e23bd489bd3ff6093e0cee91b03dc6e45e9 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 8 Sep 2026 14:07:48 +0300
Subject: [PATCH 4/6] test(billing): cover the Stripe field locations this
change depends on
Every subscription fixture in the suite carries current_period_end at the top
level, which is the location the pinned API version no longer uses. So the item
level read, the reason this code exists, had no test at all and every other case
passed through the legacy fallback instead.
Covers both locations for the period and for the invoice's subscription link,
the null case the webhook relies on to leave a one-off invoice alone, and the
retry-window bound through the payload shape production actually sends.
---
tests/unit/lib/billing.test.ts | 114 +++++++++++++++++++++++++++++++++
1 file changed, 114 insertions(+)
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index 432faf6..6f1b4db 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -15,6 +15,9 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
+ getInvoiceSubscriptionId,
+ getSubscriptionPeriodEnd,
+ getSubscriptionPeriodStart,
getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
@@ -868,6 +871,88 @@ function updateData(): Record {
return dbMock.user.update.mock.calls[0][0].data as Record;
}
+// The shape Stripe actually sends on the pinned API version: the period lives on the
+// subscription's items, not on the subscription. `stripeSub` above still uses the older
+// top-level shape, so without these the whole reason this code exists goes untested and
+// every other test in this file passes through the legacy fallback instead.
+describe('Stripe field locations', () => {
+ const periodStart = 1_800_000_000;
+ const periodEnd = periodStart + 30 * 86_400;
+
+ function itemPeriodSub(overrides: Record = {}) {
+ return {
+ id: 'sub_1',
+ customer: 'cus_1',
+ status: 'past_due',
+ items: {
+ data: [
+ {
+ price: { id: ENTITLED_PRICE },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ },
+ ],
+ },
+ ...overrides,
+ } as unknown as Stripe.Subscription;
+ }
+
+ it('reads the period off the subscription items', () => {
+ expect(getSubscriptionPeriodEnd(itemPeriodSub())).toBe(periodEnd);
+ expect(getSubscriptionPeriodStart(itemPeriodSub())).toBe(periodStart);
+ });
+
+ // A webhook body can still be rendered at the version that was current when the
+ // endpoint was created, so the old location has to keep working.
+ it('falls back to the legacy top-level period', () => {
+ const legacy = {
+ items: { data: [{ price: { id: ENTITLED_PRICE } }] },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ } as unknown as Stripe.Subscription;
+
+ expect(getSubscriptionPeriodEnd(legacy)).toBe(periodEnd);
+ expect(getSubscriptionPeriodStart(legacy)).toBe(periodStart);
+ });
+
+ it('returns null when neither location carries a period', () => {
+ const bare = {
+ items: { data: [{ price: { id: ENTITLED_PRICE } }] },
+ } as unknown as Stripe.Subscription;
+
+ expect(getSubscriptionPeriodEnd(bare)).toBeNull();
+ expect(getSubscriptionPeriodStart(bare)).toBeNull();
+ });
+
+ it('reads the invoice subscription off parent.subscription_details', () => {
+ const invoice = {
+ parent: { subscription_details: { subscription: 'sub_9' } },
+ } as unknown as Stripe.Invoice;
+
+ expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
+ });
+
+ it('accepts an expanded subscription object on the invoice parent', () => {
+ const invoice = {
+ parent: { subscription_details: { subscription: { id: 'sub_9' } } },
+ } as unknown as Stripe.Invoice;
+
+ expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
+ });
+
+ it('falls back to the legacy top-level invoice subscription', () => {
+ expect(getInvoiceSubscriptionId({ subscription: 'sub_9' } as unknown as Stripe.Invoice)).toBe(
+ 'sub_9'
+ );
+ });
+
+ // A one-off invoice belongs to no subscription, and the webhook relies on this to leave
+ // the account alone rather than marking it canceled.
+ it('returns null for an invoice with no subscription', () => {
+ expect(getInvoiceSubscriptionId({} as unknown as Stripe.Invoice)).toBeNull();
+ });
+});
+
describe('database backed billing helpers', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -1555,6 +1640,35 @@ describe('database backed billing helpers', () => {
);
});
+ // The same thing through the payload shape production actually sends, where the period
+ // sits on the items rather than on the subscription. Every other fixture in this file
+ // uses the older top-level shape and so never exercises the read this change is for.
+ it('bounds a past_due subscription whose period is on its items', async () => {
+ dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
+ const periodStart = Math.floor(NOW.getTime() / 1000);
+ const periodEnd = periodStart + 30 * 24 * 60 * 60;
+
+ await syncStripeSubscriptionToUser({
+ id: 'sub_1',
+ customer: 'cus_1',
+ status: 'past_due',
+ items: {
+ data: [
+ {
+ price: { id: ENTITLED_PRICE },
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ },
+ ],
+ },
+ } as unknown as Stripe.Subscription);
+
+ expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000);
+ expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
+ (periodStart + 14 * 24 * 60 * 60) * 1000
+ );
+ });
+
it('falls back to ended_at when there is no period end', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const endedAt = Math.floor(NOW.getTime() / 1000) - 7200;
From 2c890314c1622f4104231e38bac5f575d34f9e75 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 8 Sep 2026 15:17:19 +0300
Subject: [PATCH 5/6] fix(billing): show paid cancellation dates instead of
leftover trial
---
.../settings/settings-page-client.tsx | 8 +--
tests/component/settings-billing.test.tsx | 72 +++++++++++++++++++
2 files changed, 76 insertions(+), 4 deletions(-)
create mode 100644 tests/component/settings-billing.test.tsx
diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx
index c3a6383..149ae91 100644
--- a/app/(dashboard)/settings/settings-page-client.tsx
+++ b/app/(dashboard)/settings/settings-page-client.tsx
@@ -492,7 +492,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{billing.subscription.hasActiveSubscription
? hasScheduledCancellation
- ? billing.subscription.hasActiveTrial
+ ? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.'
: 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.'
@@ -512,11 +512,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
!billing.subscription.hasActiveSubscription ? (
Your latest payment didn't go through. Update your payment method to keep
- your subscription — starting a new one would create a duplicate.
+ your subscription. Starting a new one would create a duplicate.