mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
This commit is contained in:
@@ -29,18 +29,9 @@ 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';
|
||||
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
|
||||
import type { CancellationReason } from '@/lib/cancellation-reasons';
|
||||
|
||||
/**
|
||||
* Stripe reports amounts in the currency's smallest unit, and how many of those make a
|
||||
@@ -193,6 +184,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
const [billingAction, setBillingAction] = useState<
|
||||
'checkout' | 'portal' | 'trial' | 'cancel' | null
|
||||
>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
|
||||
const [storageLoading, setStorageLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
@@ -350,37 +342,48 @@ 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();
|
||||
const handleCancelSubscription = useCallback(
|
||||
async (input: { reason: CancellationReason | null; note: string | null }) => {
|
||||
setBillingAction('cancel');
|
||||
try {
|
||||
const res = await fetch('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage('error', data.error || 'Failed to cancel subscription');
|
||||
return;
|
||||
if (!res.ok) {
|
||||
showMessage('error', data.error || 'Failed to cancel subscription');
|
||||
return false;
|
||||
}
|
||||
|
||||
setCancelDialogOpen(false);
|
||||
const billingRes = await fetch('/api/billing');
|
||||
if (billingRes.ok) {
|
||||
setBilling((await billingRes.json()).data);
|
||||
}
|
||||
const endsOn = data.data?.periodEnd
|
||||
? new Date(data.data.periodEnd).toLocaleDateString()
|
||||
: null;
|
||||
showMessage(
|
||||
'success',
|
||||
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 {
|
||||
showMessage('error', 'Failed to cancel subscription');
|
||||
return false;
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
|
||||
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]);
|
||||
},
|
||||
[showMessage]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -498,7 +501,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
: '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
|
||||
@@ -622,7 +627,22 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
'Update Payment Method'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
) : null}
|
||||
{/* 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.cancelAvailable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
) : null}
|
||||
{billing.subscription.hasRecoverableSubscription &&
|
||||
billing.portalAvailable ? null : (
|
||||
<>
|
||||
{billing.workspaceCreation.canStartTrial ? (
|
||||
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
|
||||
@@ -652,53 +672,23 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{billing.cancelAvailable ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={billingAction !== null}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{billingAction === 'cancel' ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Canceling...
|
||||
</>
|
||||
) : (
|
||||
'Cancel Subscription'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel your subscription?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={billingAction !== null}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Cancel Subscription
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{billing ? (
|
||||
<CancelSubscriptionDialog
|
||||
open={cancelDialogOpen}
|
||||
onOpenChange={setCancelDialogOpen}
|
||||
periodEnd={billing.subscription.currentPeriodEnd}
|
||||
isTrial={billing.subscription.status === 'TRIALING'}
|
||||
canceledImmediately={billing.cancelIsImmediate}
|
||||
onConfirm={handleCancelSubscription}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{billing?.subscription.hasBillingAccess && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from '@/lib/admin-stats';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
|
||||
import { CancellationReasonsCard } from '@/components/admin/cancellation-reasons-card';
|
||||
import {
|
||||
Users,
|
||||
Folder,
|
||||
@@ -289,6 +291,14 @@ export default async function AdminDashboardPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Outside the `stripeStats` guard on purpose: the answers live in our own
|
||||
table and must stay readable while a Stripe outage blanks the cards above. */}
|
||||
{isStripeBillingEnabled() && (
|
||||
<Suspense fallback={null}>
|
||||
<CancellationReasonsCard />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,25 @@ import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
findLiveStripeSubscription,
|
||||
getBillingOverview,
|
||||
isUnpaidStripeSubscription,
|
||||
syncStripeCustomerSubscriptions,
|
||||
voidOpenSubscriptionInvoices,
|
||||
} from '@/lib/billing';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
CANCELLATION_NOTE_MAX_LENGTH,
|
||||
cancelSubscription,
|
||||
isCancellationReason,
|
||||
} from '@/lib/cancellation';
|
||||
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { getStripe, isStripeConfigured } from '@/lib/stripe';
|
||||
import { isStripeConfigured } from '@/lib/stripe';
|
||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* customer has already left the page. Both fields are optional: skipping the
|
||||
* question is allowed and must never stand between someone and cancelling.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
@@ -28,6 +35,22 @@ export async function POST(request: NextRequest) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
// A second limit keyed on the account. The IP-keyed one above is shared by
|
||||
// every mutating route and, without TRUSTED_PROXY_MODE, by every caller,
|
||||
// so it is the wrong thing to lean on for the one action a leaving
|
||||
// customer most needs to succeed.
|
||||
const config = RATE_LIMIT_CONFIGS['billing-cancel'];
|
||||
const limit = await checkRateLimit(session.user.id, 'billing-cancel', config);
|
||||
if (!limit.allowed) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...rateLimitHeaders(limit, config.maxRequests),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return apiErrors.badRequest('Stripe billing is disabled by this host');
|
||||
}
|
||||
@@ -36,46 +59,55 @@ export async function POST(request: NextRequest) {
|
||||
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 body = await request.json().catch(() => null);
|
||||
const rawReason = body?.reason ?? null;
|
||||
if (rawReason !== null && !isCancellationReason(rawReason)) {
|
||||
return apiErrors.badRequest('Unknown cancellation reason');
|
||||
}
|
||||
|
||||
const subscription = await findLiveStripeSubscription(customerId);
|
||||
if (!subscription) {
|
||||
return apiErrors.badRequest('No subscription to cancel');
|
||||
const rawNote = body?.note;
|
||||
if (rawNote !== undefined && rawNote !== null && typeof rawNote !== 'string') {
|
||||
return apiErrors.badRequest('Note must be text');
|
||||
}
|
||||
const trimmedNote = typeof rawNote === 'string' ? rawNote.trim() : '';
|
||||
if (trimmedNote.length > CANCELLATION_NOTE_MAX_LENGTH) {
|
||||
return apiErrors.badRequest(
|
||||
`Note must be at most ${CANCELLATION_NOTE_MAX_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const unpaid = isUnpaidStripeSubscription(subscription);
|
||||
const result = await cancelSubscription({
|
||||
userId: session.user.id,
|
||||
reason: rawReason,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
});
|
||||
|
||||
// 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)
|
||||
: [];
|
||||
|
||||
// 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);
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'ALREADY_CANCELING':
|
||||
return apiErrors.conflict(
|
||||
'Your subscription is already set to end at the close of this period'
|
||||
);
|
||||
case 'STRIPE_REJECTED':
|
||||
return apiErrors.conflict(
|
||||
'Stripe could not find this subscription. Open Manage Subscription to see its current state.'
|
||||
);
|
||||
default:
|
||||
return apiErrors.conflict('There is no active subscription to cancel');
|
||||
}
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
canceledImmediately: unpaid,
|
||||
status: canceled.status,
|
||||
cancelAt: canceled.cancel_at ? new Date(canceled.cancel_at * 1000).toISOString() : null,
|
||||
voidedInvoices,
|
||||
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');
|
||||
} catch (error) {
|
||||
logError('Error canceling Stripe subscription:', error);
|
||||
logError('billing.cancel', error);
|
||||
return apiErrors.internalError('Failed to cancel subscription');
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -1,7 +1,12 @@
|
||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getBillingOverview, getOpenInvoiceForCustomer } 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';
|
||||
@@ -17,8 +22,7 @@ export async function GET() {
|
||||
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.
|
||||
// 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;
|
||||
@@ -30,6 +34,11 @@ export async function GET() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const cancelable =
|
||||
isStripeConfigured() && billing.subscription.stripeCustomerId
|
||||
? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId)
|
||||
: null;
|
||||
|
||||
const response = successResponse({
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
@@ -42,20 +51,15 @@ export async function GET() {
|
||||
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,
|
||||
// An already scheduled unpaid subscription still needs immediate cancellation.
|
||||
// A different unscheduled subscription may also remain after an earlier cancel.
|
||||
cancelAvailable: Boolean(cancelable),
|
||||
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,
|
||||
cancelIsImmediate: Boolean(
|
||||
cancelable &&
|
||||
(isUnpaidStripeSubscription(cancelable) ||
|
||||
['canceled', 'incomplete_expired'].includes(cancelable.status))
|
||||
),
|
||||
openInvoice: openInvoice
|
||||
? {
|
||||
id: openInvoice.id,
|
||||
|
||||
Reference in New Issue
Block a user