mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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'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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user