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

fix(billing): stop collection after unpaid cancellation and preserve paid access
This commit is contained in:
Yusuf İpek
2026-09-08 16:56:06 +03:00
committed by GitHub
24 changed files with 3218 additions and 456 deletions
@@ -33,6 +33,25 @@ import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
/** Convert Stripe API units separately from the currency's display precision. */
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
const currencyCode = currency.toUpperCase();
try {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
});
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
// Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
// https://docs.stripe.com/currencies#special-cases
const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
return formatter.format(amountInMinorUnits / 10 ** apiExponent);
} catch {
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
}
}
interface NotificationSettings {
telegramChatId: string | null;
telegramEnabled: boolean;
@@ -51,6 +70,17 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean;
portalAvailable: boolean;
cancelAvailable: boolean;
cancelIsImmediate: boolean;
needsPaymentFix: boolean;
openInvoice: {
id: string | null;
hostedInvoiceUrl: string | null;
amountDue: number;
currency: string;
attemptCount: number;
nextPaymentAttempt: string | null;
} | null;
subscription: {
status: string;
label: string;
@@ -260,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
);
const handleBillingRedirect = useCallback(
async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
async (
endpoint: '/api/billing/checkout' | '/api/billing/portal',
flow?: 'payment_method_update'
) => {
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow ? { flow } : {}),
});
const data = await res.json();
@@ -333,9 +367,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: null;
showMessage(
'success',
endsOn
? `Your subscription ends on ${endsOn}. You keep full access until then.`
: 'Your subscription ends at the close of the current period.'
data.data?.canceledImmediately
? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.'
: endsOn
? `Your subscription ends on ${endsOn}. You keep full access until then.`
: 'Your subscription ends at the close of the current period.'
);
return true;
} catch {
@@ -458,13 +494,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<p className="text-sm text-muted-foreground mt-1">
{billing.subscription.hasActiveSubscription
? hasScheduledCancellation
? billing.subscription.hasActiveTrial
? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.'
: 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.'
: billing.subscription.hasActiveTrial
? 'Free trial, no card required.'
: 'Billing access has ended.'}
: billing.subscription.hasBillingAccess
? 'Workspace access remains available while you resolve your payment.'
: 'Billing access has ended.'}
</p>
</div>
<Badge
@@ -478,11 +516,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
!billing.subscription.hasActiveSubscription ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
Your latest payment didn&apos;t go through. Update your payment method to keep
your subscription starting a new one would create a duplicate.
your subscription. Starting a new one would create a duplicate.
</p>
) : null}
{billing.subscription.hasActiveTrial &&
{billing.subscription.status === 'TRIALING' &&
billing.subscription.trialEndsAt &&
hasScheduledCancellation ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
@@ -501,7 +539,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground">
Cancellation was scheduled on{' '}
Cancellation takes effect on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p>
) : null}
@@ -527,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div>
) : null}
{billing.openInvoice ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
<p className="text-sm font-semibold text-destructive">
A payment of{' '}
{formatInvoiceAmount(
billing.openInvoice.amountDue,
billing.openInvoice.currency
)}{' '}
did not go through
</p>
<p className="text-sm text-muted-foreground">
{billing.openInvoice.attemptCount} attempt
{billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
{billing.openInvoice.nextPaymentAttempt
? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
: ''}
. Update your payment method or pay the invoice to stop the retries, or cancel
to stop them for good.
</p>
{billing.subscription.billingAccessEndedAt ? (
<p className="text-sm text-muted-foreground">
{new Date(billing.subscription.billingAccessEndedAt) > new Date()
? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
: `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
</p>
) : null}
{billing.openInvoice.hostedInvoiceUrl ? (
<a
href={billing.openInvoice.hostedInvoiceUrl}
target="_blank"
rel="noreferrer"
className="inline-block text-sm font-medium text-primary hover:underline"
>
View and pay this invoice
</a>
) : null}
</div>
) : null}
<div className="flex flex-col sm:flex-row gap-3">
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
<Button
onClick={() => handleBillingRedirect('/api/billing/portal')}
onClick={() =>
handleBillingRedirect(
'/api/billing/portal',
billing.needsPaymentFix ? 'payment_method_update' : undefined
)
}
disabled={billingAction !== null}
>
{billingAction === 'portal' ? (
@@ -548,9 +630,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{/* Beside the portal button, not inside it. Someone who came to
cancel should not have to guess that "Manage" is the way, and
the portal cannot ask why they are leaving. */}
{billing.subscription.hasActiveSubscription &&
billing.portalAvailable &&
!hasScheduledCancellation ? (
{billing.cancelAvailable ? (
<Button
variant="ghost"
className="text-muted-foreground"
@@ -603,6 +683,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
onOpenChange={setCancelDialogOpen}
periodEnd={billing.subscription.currentPeriodEnd}
isTrial={billing.subscription.status === 'TRIALING'}
canceledImmediately={billing.cancelIsImmediate}
onConfirm={handleCancelSubscription}
/>
) : null}
+9 -5
View File
@@ -3,7 +3,7 @@ import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
CANCELLATION_NOTE_MAX_LENGTH,
cancelSubscriptionAtPeriodEnd,
cancelSubscription,
isCancellationReason,
} from '@/lib/cancellation';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
@@ -13,8 +13,8 @@ import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
/**
* In-app cancellation: end the subscription at the close of the current
* period and keep the one answer the customer gave about why.
* In-app cancellation: end unpaid subscriptions immediately, schedule paid
* subscriptions for period end, and record the optional reason.
*
* This exists beside the Stripe portal rather than instead of it. The portal
* cannot ask a question of our own, and by the time its webhook arrives the
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
);
}
const result = await cancelSubscriptionAtPeriodEnd({
const result = await cancelSubscription({
userId: session.user.id,
reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null,
@@ -98,7 +98,11 @@ export async function POST(request: NextRequest) {
}
const response = successResponse({
cancelAtPeriodEnd: true,
cancelAtPeriodEnd: !result.canceledImmediately,
canceledImmediately: result.canceledImmediately,
status: result.status,
cancelAt: result.cancelAt?.toISOString() ?? null,
voidedInvoices: result.voidedInvoices,
periodEnd: result.periodEnd?.toISOString() ?? null,
});
return withCacheControl(response, 'private, no-store');
+16 -1
View File
@@ -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({
+41 -4
View File
@@ -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');
+51 -2
View File
@@ -1,6 +1,12 @@
import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import {
findCancelableStripeSubscription,
isUnpaidStripeSubscription,
getBillingOverview,
getOpenInvoiceForCustomer,
} from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -15,12 +21,55 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig();
// Invoice details are only needed when the current subscription is behind on payment.
const needsPaymentFix =
billing.subscription.status === BillingSubscriptionStatus.PAST_DUE ||
billing.subscription.status === BillingSubscriptionStatus.UNPAID;
const openInvoice =
isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId
? await getOpenInvoiceForCustomer(
billing.subscription.stripeCustomerId,
billing.subscription.stripeSubscriptionId
)
: null;
const cancelable =
isStripeConfigured() && billing.subscription.stripeCustomerId
? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId)
: null;
const response = successResponse({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
// A customer id alone is not enough: it is created on the first checkout attempt, so
// someone who abandoned checkout would be sent to an empty portal.
portalAvailable:
isStripeConfigured() &&
Boolean(billing.subscription.stripeCustomerId) &&
(billing.subscription.hasRecoverableSubscription ||
Boolean(billing.subscription.stripeSubscriptionId)),
// An already scheduled unpaid subscription still needs immediate cancellation.
// A different unscheduled subscription may also remain after an earlier cancel.
cancelAvailable: Boolean(cancelable),
needsPaymentFix,
cancelIsImmediate: Boolean(
cancelable &&
(isUnpaidStripeSubscription(cancelable) ||
['canceled', 'incomplete_expired'].includes(cancelable.status))
),
openInvoice: openInvoice
? {
id: openInvoice.id,
hostedInvoiceUrl: openInvoice.hostedInvoiceUrl,
amountDue: openInvoice.amountDue,
currency: openInvoice.currency,
attemptCount: openInvoice.attemptCount,
nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null,
}
: null,
subscription: {
status: billing.subscription.status,
label: billing.subscription.label,
+5 -1
View File
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
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)) {
+20 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -55,6 +55,25 @@ export async function POST(request: NextRequest) {
}
break;
}
// Invoice events carry the payment health of a subscription earlier and more
// reliably than the subscription events alone. Without them a customer whose card
// failed keeps the mirror of a healthy subscription until Stripe eventually gives
// up, which is the whole dunning window spent showing them the wrong state.
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = getCustomerId(invoice.customer);
// Only subscription invoices. A one-off invoice against a customer record left
// behind by an abandoned checkout has no subscription, and syncing on it would
// find an empty list, mark the account canceled and book a churn event for a
// subscription that never existed.
if (customerId && getInvoiceSubscriptionId(invoice)) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
}
default:
break;
}