diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 96b3744..e052a97 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -29,8 +29,39 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { cn } from '@/lib/utils'; +/** + * Stripe reports amounts in the currency's smallest unit, and how many of those make a + * whole unit differs per currency: two for USD, none for JPY. The formatter knows the + * exponent, so it decides the divisor instead of a hardcoded 100. + */ +function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { + const currencyCode = currency.toUpperCase(); + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + }); + const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2; + return formatter.format(amountInMinorUnits / 10 ** fractionDigits); + } catch { + return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`; + } +} + interface NotificationSettings { telegramChatId: string | null; telegramEnabled: boolean; @@ -49,6 +80,16 @@ interface BillingOverview { status: 'disabled' | 'ready' | 'misconfigured'; checkoutAvailable: boolean; portalAvailable: boolean; + cancelAvailable: boolean; + needsPaymentFix: boolean; + openInvoice: { + id: string | null; + hostedInvoiceUrl: string | null; + amountDue: number; + currency: string; + attemptCount: number; + nextPaymentAttempt: string | null; + } | null; subscription: { status: string; label: string; @@ -148,7 +189,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo const [testing, setTesting] = useState(null); const [billing, setBilling] = useState(null); const [billingLoading, setBillingLoading] = useState(true); - const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null); + const [billingAction, setBillingAction] = useState< + 'checkout' | 'portal' | 'trial' | 'cancel' | null + >(null); const [storageInfo, setStorageInfo] = useState(null); const [storageLoading, setStorageLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -255,12 +298,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo ); const handleBillingRedirect = useCallback( - async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => { + async ( + endpoint: '/api/billing/checkout' | '/api/billing/portal', + flow?: 'payment_method_update' + ) => { setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal'); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(flow ? { flow } : {}), }); const data = await res.json(); @@ -302,6 +349,38 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo } }, [showMessage]); + const handleCancelSubscription = useCallback(async () => { + setBillingAction('cancel'); + try { + const res = await fetch('/api/billing/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + + if (!res.ok) { + showMessage('error', data.error || 'Failed to cancel subscription'); + return; + } + + const billingRes = await fetch('/api/billing'); + if (billingRes.ok) { + setBilling((await billingRes.json()).data); + } + + showMessage( + 'success', + data.data.canceledImmediately + ? 'Subscription canceled. No further payment will be attempted.' + : 'Subscription canceled. Access remains until the end of the current billing period.' + ); + } catch { + showMessage('error', 'Failed to cancel subscription'); + } finally { + setBillingAction(null); + } + }, [showMessage]); + if (loading) { return (
@@ -481,10 +560,53 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
) : null} + {billing.openInvoice ? ( +
+

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

+

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

+ {billing.subscription.billingAccessEndedAt ? ( +

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

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