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:
2026-09-08 15:49:37 +03:00
25 changed files with 3097 additions and 933 deletions
@@ -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>
+10
View File
@@ -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>
);
}
+70 -38
View File
@@ -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
View File
@@ -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,
+584 -684
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { format } from 'date-fns';
import { UserX } from 'lucide-react';
import { db } from '@/lib/db';
import { CANCELLATION_REASONS, getCancellationReasonLabel } from '@/lib/cancellation-reasons';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const RECENT_LIMIT = 15;
/**
* The answers to the one question asked on the way out, newest first, with an
* all-time tally per answer above them.
*
* Only in-app cancellations appear here. Someone who cancels inside the Stripe
* portal, or whose card simply stops working, never sees the question, so the
* tally undercounts churn and says nothing about the accounts that pay and go
* silent. Read it as "what people said", not "why people leave".
*/
export async function CancellationReasonsCard() {
const [recent, tally] = await Promise.all([
db.subscriptionCancellation.findMany({
orderBy: { createdAt: 'desc' },
take: RECENT_LIMIT,
select: {
id: true,
reason: true,
note: true,
periodEnd: true,
createdAt: true,
user: { select: { name: true, email: true } },
},
}),
db.subscriptionCancellation.groupBy({
by: ['reason'],
_count: { _all: true },
}),
]);
const countByReason = new Map(tally.map((row) => [row.reason, row._count._all]));
const total = tally.reduce((sum, row) => sum + row._count._all, 0);
const skipped = countByReason.get(null) ?? 0;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Why people cancelled</CardTitle>
<UserX className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="space-y-4">
{total === 0 ? (
<p className="text-sm text-muted-foreground">
No in-app cancellations yet. Cancellations made in the Stripe portal do not show up
here.
</p>
) : (
<>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
{CANCELLATION_REASONS.map((entry) => (
<span key={entry.value} className="text-muted-foreground">
{entry.label}:{' '}
<span className="font-medium text-foreground">
{countByReason.get(entry.value) ?? 0}
</span>
</span>
))}
<span className="text-muted-foreground">
Skipped the question: <span className="font-medium text-foreground">{skipped}</span>
</span>
</div>
<ul className="divide-y">
{recent.map((row) => (
<li key={row.id} className="py-2 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="font-medium">
{row.user.name || 'Anonymous'}{' '}
<span className="font-normal text-xs text-muted-foreground">
{row.user.email}
</span>
</span>
<span className="text-xs text-muted-foreground">
{format(row.createdAt, 'MMM dd, yyyy')}
{row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''}
</span>
</div>
<p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p>
{row.note ? (
<p className="mt-0.5 whitespace-pre-wrap break-words">{row.note}</p>
) : null}
</li>
))}
</ul>
{total > RECENT_LIMIT ? (
<p className="text-xs text-muted-foreground">
Showing the latest {RECENT_LIMIT} of {total}.
</p>
) : null}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,169 @@
'use client';
import { useCallback, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import {
CANCELLATION_NOTE_MAX_LENGTH,
CANCELLATION_REASONS,
type CancellationReason,
} from '@/lib/cancellation-reasons';
interface CancelSubscriptionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When access ends if the cancellation goes through, or null when unknown. */
periodEnd: string | null;
/** True for a subscription that is still inside its Stripe trial. */
isTrial: boolean;
/** Unpaid subscriptions end now; cancellation does not extend access. */
canceledImmediately?: boolean;
/** Resolves true once the cancellation went through; false keeps the dialog and its answer. */
onConfirm: (input: {
reason: CancellationReason | null;
note: string | null;
}) => Promise<boolean>;
}
/**
* One question, five answers, no default, all of it skippable.
*
* The answer is the whole reason this dialog exists instead of a plain confirm,
* and the way to get honest answers is to make them cheap: one click, no
* required field, and a cancel button that works with nothing selected. A
* free-text box appears only under the two answers where the detail is worth
* more than the category.
*/
export function CancelSubscriptionDialog({
open,
onOpenChange,
periodEnd,
isTrial,
canceledImmediately = false,
onConfirm,
}: CancelSubscriptionDialogProps) {
const [reason, setReason] = useState<CancellationReason | null>(null);
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const selected = CANCELLATION_REASONS.find((entry) => entry.value === reason) ?? null;
const showNote = selected?.askForDetail ?? false;
const handleOpenChange = useCallback(
(next: boolean) => {
if (submitting) return;
if (!next) {
setReason(null);
setNote('');
}
onOpenChange(next);
},
[onOpenChange, submitting]
);
const handleConfirm = useCallback(async () => {
setSubmitting(true);
try {
const trimmed = note.trim();
const done = await onConfirm({
reason,
note: showNote && trimmed.length > 0 ? trimmed : null,
});
// A failed request keeps the answer on screen. Wiping a typed note
// because Stripe timed out is the fastest way to never get it back.
if (done) {
setReason(null);
setNote('');
}
} finally {
setSubmitting(false);
}
}, [note, onConfirm, reason, showNote]);
const endsOn = periodEnd ? new Date(periodEnd).toLocaleDateString() : null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel your {isTrial ? 'trial' : 'subscription'}?</DialogTitle>
<DialogDescription>
{canceledImmediately
? 'This subscription ends immediately. Canceling does not extend access to your workspaces. Automatic collection stops for its open invoices. Eligible current-period subscription invoices are canceled; charges for prior service and other items may still be owed.'
: endsOn
? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.`
: 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm font-medium">
What is the main reason?{' '}
<span className="font-normal text-muted-foreground">(optional)</span>
</p>
<RadioGroup
value={reason ?? ''}
onValueChange={(value) => setReason(value as CancellationReason)}
disabled={submitting}
>
{CANCELLATION_REASONS.map((entry) => (
<Label
key={entry.value}
htmlFor={`cancel-reason-${entry.value}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-sm font-normal transition-colors hover:bg-accent/50 has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5"
>
<RadioGroupItem value={entry.value} id={`cancel-reason-${entry.value}`} />
{entry.label}
</Label>
))}
</RadioGroup>
{showNote ? (
<div className="space-y-1.5">
<Label htmlFor="cancel-reason-note" className="text-sm">
{reason === 'MISSING_FEATURE' ? 'What was missing?' : 'Tell us more'}{' '}
<span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="cancel-reason-note"
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={CANCELLATION_NOTE_MAX_LENGTH}
rows={3}
disabled={submitting}
className="text-sm md:text-sm"
/>
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
Keep {isTrial ? 'trial' : 'subscription'}
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Cancelling...
</>
) : (
`Cancel ${isTrial ? 'trial' : 'subscription'}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-[state=checked]:bg-primary data-[state=checked]:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
+159 -71
View File
@@ -4,7 +4,6 @@ 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';
@@ -62,8 +61,8 @@ const UNPAID_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
const RETRYING_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>(['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.
// Application grace period, independent of the Stripe retry settings. An unpaid
// invoice's future period end does not extend this access window.
const UNPAID_ACCESS_GRACE_DAYS = 14;
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
@@ -146,7 +145,7 @@ export function isPaidTier(
}
// 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
// behind on payment. They did once: access stopped at the end of the payment grace 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()) {
@@ -186,7 +185,7 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
// 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`.
// stamped with the end of the payment grace window, a cancelled one with `ended_at`.
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
return false;
}
@@ -197,15 +196,16 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
if (subject.billingAccessEndedAt) {
return subject.billingAccessEndedAt;
}
if (subject.stripeCurrentPeriodEnd) {
return subject.stripeCurrentPeriodEnd;
}
return subject.trialEndsAt;
const subscriptionEnd =
subject.billingAccessEndedAt ??
(UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
? null
: subject.stripeCurrentPeriodEnd);
// A trial grants access independently of the subscription cutoff. Retention starts
// after the last legitimate entitlement, never from an unpaid invoice's period.
if (!subscriptionEnd) return subject.trialEndsAt;
if (!subject.trialEndsAt) return subscriptionEnd;
return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
}
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
@@ -251,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
return { id: { in: [] } };
}
// Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
// Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
// "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
// false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
// NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
// looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
// reports current_period_end on the subscription), so the cleanup silently matched nobody.
// Match the same last entitlement date as getBillingAccessEndDate, with explicit
// null branches because SQL comparisons against null do not evaluate to false.
return {
AND: [
{
@@ -265,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
billingAccessEndedAt: null,
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
stripeCurrentPeriodEnd: { lte: cleanupCutoff },
},
{
billingAccessEndedAt: null,
trialEndsAt: { lte: cleanupCutoff },
OR: [
{ stripeCurrentPeriodEnd: null },
{ subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
],
},
],
},
@@ -872,8 +876,8 @@ function getInactiveBillingAccessEndedAt(
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. The
// Still running, just behind on payment: bound access to the application grace period,
// not the period end Stripe 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
@@ -886,8 +890,8 @@ function getInactiveBillingAccessEndedAt(
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.
// Preserve the existing period-based access policy for paused subscriptions.
// The paused status itself is not evidence that this period was paid.
if (subscription.status === 'paused' && currentPeriodEnd) {
return new Date(currentPeriodEnd * 1000);
}
@@ -953,7 +957,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// subscription created after the cardless trial shipped, and this fallback is
// 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 preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
// 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`,
@@ -974,18 +978,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
// A live trial means access has not ended, whatever the subscription says.
// Stamping an end date here while the trial runs would date the storage
// cleanup from today and tell the user their work dies before their trial
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
// that has already elapsed is a reason to stamp the end date, not to skip it.
billingAccessEndedAt:
hasAccess || hasActiveTrial(preservedTrialEnd)
? null
: getInactiveBillingAccessEndedAt(
subscription,
hasEntitledPrice ? currentPeriodEnd : null
),
// Preserve the subscription cutoff even during a trial. The trial has its own
// access branch; clearing this cutoff would resurrect an unpaid period later.
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
@@ -1099,7 +1096,7 @@ export async function markSubscriptionCanceledByCustomerId(
// Losing the subscription does not retract a trial that has not run out. The
// account keeps the days it was given and lands back on the trial's own end
// date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
const preservedTrialEnd = user.trialEndsAt ?? null;
const updated = await db.user.update({
where: { id: user.id },
@@ -1111,9 +1108,7 @@ export async function markSubscriptionCanceledByCustomerId(
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
billingAccessEndedAt: preservedTrialEnd
? null
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
@@ -1181,42 +1176,135 @@ 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. 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();
const { data: invoices } = await stripe.invoices.list({
customer: customerId,
status: 'open',
limit: 100,
/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
export function isCurrentSubscriptionInvoice(
invoice: Stripe.Invoice,
subscription: Stripe.Subscription
): boolean {
const latestId =
typeof subscription.latest_invoice === 'string'
? subscription.latest_invoice
: subscription.latest_invoice?.id;
const start = getSubscriptionPeriodStart(subscription);
const end = getSubscriptionPeriodEnd(subscription);
if (
invoice.id !== latestId ||
invoice.status !== 'open' ||
invoice.amount_paid !== 0 ||
!['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
getInvoiceSubscriptionId(invoice) !== subscription.id ||
start === null ||
end === null ||
!invoice.lines ||
invoice.lines.has_more ||
invoice.lines.data.length === 0
)
return false;
return invoice.lines.data.every((line) => {
const details = line.parent?.subscription_item_details;
return (
line.parent?.type === 'subscription_item_details' &&
details?.subscription === subscription.id &&
details.proration === false &&
line.pricing?.price_details?.price === getStripePriceId() &&
line.period.start === start &&
line.period.end === end
);
});
}
async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
const invoices: Stripe.Invoice[] = [];
let startingAfter: string | undefined;
while (true) {
const page = await getStripe().invoices.list({
customer: customerId,
status: 'open',
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
invoices.push(
...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
);
if (!page.has_more || page.data.length === 0) break;
startingAfter = page.data[page.data.length - 1].id;
}
return invoices;
}
/**
* Stop automatic collection on all open invoices for this subscription. Older or
* mixed invoices remain receivables; only a complete current renewal is voided.
* Failures propagate so callers can report and retry unfinished cleanup.
*/
export async function voidOpenSubscriptionInvoices(
customerId: string,
subscriptionId: string,
subscriptionSnapshot?: Stripe.Subscription
) {
const stripe = getStripe();
const subscription =
subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
const customer =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
if (customer !== customerId) throw new Error('Subscription customer mismatch');
const voided: string[] = [];
for (const invoice of invoices) {
if (!invoice.id) continue;
if (getInvoiceSubscriptionId(invoice) !== subscriptionId) continue;
try {
for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
// Immediate cancellation normally pauses collection too. Explicitly keep retained
// receivables paused, including when retrying a partly completed cancellation.
if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
if (isCurrentSubscriptionInvoice(invoice, subscription)) {
await stripe.invoices.voidInvoice(invoice.id);
voided.push(invoice.id);
} catch (error) {
logError(`Failed to void Stripe invoice ${invoice.id}:`, error);
}
}
return voided;
}
/** Cancellation candidates differ from the subscription granting access. */
export async function findCancelableStripeSubscription(customerId: string) {
const subscriptions: Stripe.Subscription[] = [];
let startingAfter: string | undefined;
while (true) {
const page = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
subscriptions.push(...page.data);
if (!page.has_more || page.data.length === 0) break;
startingAfter = page.data[page.data.length - 1].id;
}
const candidate = selectAuthoritativeSubscription(
subscriptions.filter(
(subscription) =>
hasEntitledPrice(subscription, getStripePriceId()) &&
LIVE_STRIPE_STATUSES.has(subscription.status) &&
(isUnpaidStripeSubscription(subscription) ||
(!subscription.cancel_at && !subscription.cancel_at_period_end))
)
);
if (candidate) return candidate;
// A failed invoice write must remain reachable after Stripe accepted cancellation.
for (const subscription of subscriptions) {
if (
!['canceled', 'incomplete_expired'].includes(subscription.status) ||
!hasEntitledPrice(subscription, getStripePriceId())
)
continue;
const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
if (
invoices.some(
(invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
)
) {
return subscription;
}
}
return null;
}
/**
* 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
+43
View File
@@ -0,0 +1,43 @@
// Shared by the cancellation dialog (client) and the cancel route (server), so
// nothing in here may pull in the database or Stripe.
import type { CancellationReason } from '@prisma/client';
export type { CancellationReason };
/** Longest note the cancellation dialog accepts. Matches the column width. */
export const CANCELLATION_NOTE_MAX_LENGTH = 500;
/**
* The one question asked on the way out, and the order it is asked in.
*
* Five answers, no default. The list is short so the answer takes one click,
* and it is ordered by how often the pattern has shown up in customer replies:
* paying accounts that never ran a single real delivery outnumber every other
* kind of churn, so "not using it" comes first.
*/
export const CANCELLATION_REASONS: ReadonlyArray<{
value: CancellationReason;
label: string;
/** Whether the dialog opens a free-text field under this answer. */
askForDetail: boolean;
}> = [
{ value: 'NOT_USING', label: 'I am not using it enough', askForDetail: false },
{ value: 'MISSING_FEATURE', label: 'It is missing something I need', askForDetail: true },
{
value: 'PRICE_OR_BILLING',
label: 'The price or billing did not work for me',
askForDetail: false,
},
{ value: 'PROJECT_ENDED', label: 'The project or client work ended', askForDetail: false },
{ value: 'OTHER', label: 'Something else', askForDetail: true },
];
export function isCancellationReason(value: unknown): value is CancellationReason {
return CANCELLATION_REASONS.some((entry) => entry.value === value);
}
export function getCancellationReasonLabel(reason: CancellationReason | null): string {
if (!reason) return 'No reason given';
return CANCELLATION_REASONS.find((entry) => entry.value === reason)?.label ?? reason;
}
+239
View File
@@ -0,0 +1,239 @@
import type Stripe from 'stripe';
import type { CancellationReason } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getSubscriptionPeriodEnd,
findCancelableStripeSubscription,
isUnpaidStripeSubscription,
syncStripeCustomerSubscriptions,
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
import { logError } from '@/lib/logger';
export {
CANCELLATION_NOTE_MAX_LENGTH,
CANCELLATION_REASONS,
getCancellationReasonLabel,
isCancellationReason,
} from '@/lib/cancellation-reasons';
// Stripe keeps its own fixed list of cancellation feedback values. Mirroring
// ours onto it costs nothing and puts the category next to the subscription in
// the Stripe dashboard, where it is read during a refund or a support reply.
// The free-text note deliberately stays on our side: the dialog does not say
// the text leaves the product, so it does not.
const STRIPE_FEEDBACK: Record<
CancellationReason,
Stripe.SubscriptionUpdateParams.CancellationDetails.Feedback
> = {
NOT_USING: 'unused',
MISSING_FEATURE: 'missing_features',
PRICE_OR_BILLING: 'too_expensive',
PROJECT_ENDED: 'other',
OTHER: 'other',
};
export type CancelSubscriptionResult =
| {
ok: true;
periodEnd: Date | null;
canceledImmediately: boolean;
voidedInvoices: string[];
status: Stripe.Subscription.Status;
cancelAt: Date | null;
}
| { ok: false; code: 'NO_SUBSCRIPTION' | 'ALREADY_CANCELING' | 'STRIPE_REJECTED' };
function isStripeInvalidRequest(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'type' in error &&
(error as { type?: unknown }).type === 'StripeInvalidRequestError'
);
}
/** Expire every open Checkout session for this incomplete subscription, including later pages. */
async function expireSubscriptionCheckout(customerId: string, subscriptionId: string) {
const stripe = getStripe();
let startingAfter: string | undefined;
let expired = false;
do {
const sessions = await stripe.checkout.sessions.list({
customer: customerId,
status: 'open',
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
const matching = sessions.data.filter((session) => {
const owner = typeof session.customer === 'string' ? session.customer : session.customer?.id;
const id =
typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
return owner === customerId && id === subscriptionId && session.status === 'open';
});
await Promise.all(matching.map((session) => stripe.checkout.sessions.expire(session.id)));
expired ||= matching.length > 0;
startingAfter = sessions.has_more ? sessions.data.at(-1)?.id : undefined;
} while (startingAfter);
return expired;
}
/**
* Paid subscriptions end at period end; unpaid subscriptions end immediately.
* Record the reason before invoice cleanup so a failed cleanup can be retried
* on the canceled subscription without losing or duplicating the answer.
*/
export async function cancelSubscription(params: {
userId: string;
reason: CancellationReason | null;
note: string | null;
}): Promise<CancelSubscriptionResult> {
const requestStartedAt = new Date();
const user = await db.user.findUnique({
where: { id: params.userId },
select: {
stripeCustomerId: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
},
});
if (!user?.stripeCustomerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
const customerId = user.stripeCustomerId;
const original = await findCancelableStripeSubscription(customerId);
if (!original) return { ok: false, code: 'NO_SUBSCRIPTION' };
const owner = typeof original.customer === 'string' ? original.customer : original.customer.id;
if (owner !== customerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
const subscriptionId = original.id;
const cleanupRetry = original.status === 'canceled' || original.status === 'incomplete_expired';
const canceledImmediately = cleanupRetry || isUnpaidStripeSubscription(original);
if (!canceledImmediately && original.status !== 'active' && original.status !== 'trialing') {
return { ok: false, code: 'NO_SUBSCRIPTION' };
}
if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) {
return { ok: false, code: 'ALREADY_CANCELING' };
}
// Retain the paid mirror's conditional claim for double-clicks. It cannot
// guard an unpaid cancellation, cleanup retry, or a different subscription.
const claimPaidMirror = !canceledImmediately && user.stripeSubscriptionId === subscriptionId;
if (claimPaidMirror) {
const claimed = await db.user.updateMany({
where: {
id: params.userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripeCancelAtPeriodEnd: false,
},
data: { stripeCancelAtPeriodEnd: true },
});
if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' };
}
let subscription = original;
try {
const stripe = getStripe();
const cancellationDetails = params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {};
if (!cleanupRetry) {
if (!canceledImmediately) {
subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
cancellation_details: cancellationDetails,
});
} else if (
original.status === 'incomplete' &&
(await expireSubscriptionCheckout(customerId, subscriptionId))
) {
// Checkout owns incomplete subscriptions it created. Expiration cancels
// them; retrieving gives the response the actual resulting Stripe state.
subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
throw new Error('Checkout expiration did not end the subscription');
}
} else {
subscription = await stripe.subscriptions.cancel(subscriptionId, {
cancellation_details: cancellationDetails,
});
}
}
} catch (error) {
if (claimPaidMirror) {
await db.user.updateMany({
where: { id: params.userId, stripeSubscriptionId: subscriptionId },
data: { stripeCancelAtPeriodEnd: false },
});
}
if (isStripeInvalidRequest(error)) {
logError('billing.cancel.rejected', error);
return { ok: false, code: 'STRIPE_REJECTED' };
}
throw error;
}
const periodEndUnix = getSubscriptionPeriodEnd(original);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
await db.$transaction(async (tx) => {
// The paid mirror's claim does not cover other subscriptions. Serialize every
// reason write and reuse only a row written during this request, so a resumed
// subscription can record another cancellation without duplicating concurrent calls.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${subscriptionId}))`;
// Cleanup can be retried long after the request that canceled the subscription.
// Match that period and, when Stripe reports it, the terminal transition time.
// An incomplete expiration may have no ended_at, so its period is the fallback.
const existing = await tx.subscriptionCancellation.findFirst({
where: {
userId: params.userId,
stripeSubscriptionId: subscriptionId,
...(cleanupRetry
? {
periodEnd,
...(original.ended_at
? { createdAt: { gte: new Date(original.ended_at * 1000) } }
: {}),
}
: { createdAt: { gte: requestStartedAt } }),
},
orderBy: { createdAt: 'desc' },
});
if (!existing) {
await tx.subscriptionCancellation.create({
data: {
userId: params.userId,
stripeSubscriptionId: subscriptionId,
reason: params.reason,
note: params.note,
periodEnd,
},
});
}
});
let voidedInvoices: string[] = [];
try {
if (canceledImmediately) {
// Eligibility must use the pre-cancellation period, not a shortened one.
// Failures propagate; the selector exposes canceled cleanup candidates.
voidedInvoices = await voidOpenSubscriptionInvoices(customerId, subscriptionId, original);
}
} finally {
// Reconcile the whole customer even if cleanup failed. Another subscription
// may still provide access. Webhooks can repair a failed local sync.
try {
await syncStripeCustomerSubscriptions(customerId);
} catch (error) {
logError('billing.cancel.sync', error);
}
}
return {
ok: true,
periodEnd,
canceledImmediately,
voidedInvoices,
status: subscription.status,
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
};
}
+1
View File
@@ -60,6 +60,7 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
'billing-cancel': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour per account
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
@@ -0,0 +1,22 @@
-- One row per in-app cancellation, carrying the single answer the customer
-- gave on the way out. Written when the request is made, before Stripe
-- confirms it, so a reason is never lost to a webhook that arrives late.
CREATE TYPE "CancellationReason" AS ENUM ('NOT_USING', 'MISSING_FEATURE', 'PRICE_OR_BILLING', 'PROJECT_ENDED', 'OTHER');
CREATE TABLE "subscription_cancellations" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"stripeSubscriptionId" TEXT NOT NULL,
"reason" "CancellationReason",
"note" VARCHAR(500),
"periodEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_cancellations_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "subscription_cancellations_userId_createdAt_idx" ON "subscription_cancellations"("userId", "createdAt" DESC);
CREATE INDEX "subscription_cancellations_createdAt_idx" ON "subscription_cancellations"("createdAt" DESC);
ALTER TABLE "subscription_cancellations" ADD CONSTRAINT "subscription_cancellations_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+31
View File
@@ -54,6 +54,7 @@ model User {
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
subscriptionCancellations SubscriptionCancellation[]
@@map("users")
}
@@ -609,6 +610,36 @@ model UserFeedback {
@@map("user_feedback")
}
enum CancellationReason {
NOT_USING
MISSING_FEATURE
PRICE_OR_BILLING
PROJECT_ENDED
OTHER
}
// One row per in-app cancellation, written the moment the customer asks for
// it, not when Stripe later confirms it. The reason is the whole point of the
// row and it is optional on purpose: the question can be skipped, and a skipped
// answer still counts as a cancellation whose reason is unknown rather than a
// cancellation that never happened. Cancellations made in the Stripe portal
// never produce a row here; the funnel event still records those.
model SubscriptionCancellation {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stripeSubscriptionId String
reason CancellationReason?
note String? @db.VarChar(500)
// When access was due to end at the time of the request.
periodEnd DateTime?
createdAt DateTime @default(now())
@@index([userId, createdAt(sort: Desc)])
@@index([createdAt(sort: Desc)])
@@map("subscription_cancellations")
}
model UserFeedbackScreenshot {
id String @id @default(cuid())
feedbackId String
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* Re-reads every Stripe customer's live subscription and writes it back onto the user
* Re-reads each Stripe customer's authoritative 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
@@ -76,7 +76,7 @@ async function main() {
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} Without a subscription: ${withoutSubscription}`);
console.log(`${TAG} Failed: ${failed}`);
}
+679
View File
@@ -0,0 +1,679 @@
import { describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus, type User } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
import { GET as billingRoute } from '@/app/api/billing/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { createSubscribedUser, createUser } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const DAY = 24 * 60 * 60;
const unix = (offsetSeconds: number) => Math.floor(Date.now() / 1000) + offsetSeconds;
function cancelRequest(body: unknown = {}) {
return apiRequest('/api/billing/cancel', {
method: 'POST',
headers: ORIGIN_HEADERS,
body,
});
}
function subscription(user: User, overrides: Partial<Stripe.Subscription> = {}) {
return {
id: user.stripeSubscriptionId ?? 'sub_unmirrored',
customer: user.stripeCustomerId,
status: 'active',
created: unix(-30 * DAY),
cancel_at_period_end: user.stripeCancelAtPeriodEnd,
cancel_at: null,
trial_end: null,
latest_invoice: 'in_renewal',
items: {
data: [
{
id: 'si_plan',
price: { id: ENTITLED_PRICE_ID },
current_period_start: unix(-10 * DAY),
current_period_end: unix(20 * DAY),
},
],
},
...overrides,
} as Stripe.Subscription;
}
function renewal(sub: Stripe.Subscription, overrides: Partial<Stripe.Invoice> = {}) {
return {
id: 'in_renewal',
customer: sub.customer,
status: 'open',
auto_advance: true,
amount_paid: 0,
billing_reason: 'subscription_cycle',
period_start: sub.items.data[0].current_period_start,
period_end: sub.items.data[0].current_period_end,
parent: { type: 'subscription_details', subscription_details: { subscription: sub.id } },
lines: {
has_more: false,
data: [
{
id: 'il_renewal',
amount: 2000,
period: {
start: sub.items.data[0].current_period_start,
end: sub.items.data[0].current_period_end,
},
parent: {
type: 'subscription_item_details',
subscription_item_details: {
subscription: sub.id,
subscription_item: 'si_plan',
proration: false,
},
},
pricing: { type: 'price_details', price_details: { price: ENTITLED_PRICE_ID } },
},
],
},
...overrides,
} as Stripe.Invoice;
}
// Only Stripe is replaced. Selection, invoice eligibility, reason persistence,
// paid claims and customer-wide sync use their real implementation and test DB.
function stubStripe(initial: Stripe.Subscription[], invoices: Stripe.Invoice[] = []) {
const subscriptions = initial.map((sub) => structuredClone(sub));
const replace = (id: string, patch: Partial<Stripe.Subscription>) => {
const index = subscriptions.findIndex((sub) => sub.id === id);
if (index < 0) throw new Error(`Unknown fixture subscription ${id}`);
subscriptions[index] = { ...subscriptions[index], ...patch };
return structuredClone(subscriptions[index]);
};
const update = vi.fn(async (id: string, params: Stripe.SubscriptionUpdateParams) =>
replace(id, { cancel_at_period_end: params.cancel_at_period_end })
);
const cancel = vi.fn(async (id: string, params: Stripe.SubscriptionCancelParams) => {
void params;
return replace(id, {
status: 'canceled',
cancel_at_period_end: false,
canceled_at: unix(0),
ended_at: unix(0),
});
});
const list = vi.fn(async (params: Stripe.SubscriptionListParams) => ({
data: structuredClone(subscriptions.filter((sub) => sub.customer === params.customer)),
has_more: false,
}));
const sessions: Stripe.Checkout.Session[] = [];
const sessionList = vi.fn(async (params: Stripe.Checkout.SessionListParams) => ({
data: sessions.filter(
(session) => session.status === 'open' && session.customer === params.customer
),
has_more: false,
}));
const expire = vi.fn(async (id: string) => {
const session = sessions.find((item) => item.id === id)!;
session.status = 'expired';
const subId =
typeof session.subscription === 'string' ? session.subscription : session.subscription!.id;
replace(subId, { status: 'incomplete_expired' });
return session;
});
const voidInvoice = vi.fn(async (id: string) => {
const invoice = invoices.find((item) => item.id === id)!;
invoice.status = 'void';
return invoice;
});
const invoiceUpdate = vi.fn(async (id: string, params: Stripe.InvoiceUpdateParams) => {
const invoice = invoices.find((item) => item.id === id)!;
invoice.auto_advance = params.auto_advance ?? invoice.auto_advance;
return invoice;
});
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update,
cancel,
list,
retrieve: vi.fn(async (id: string) =>
structuredClone(subscriptions.find((sub) => sub.id === id))
),
},
checkout: { sessions: { list: sessionList, expire } },
invoices: {
list: vi.fn(async (params: Stripe.InvoiceListParams) => ({
data: invoices.filter(
(invoice) => invoice.customer === params.customer && invoice.status === 'open'
),
has_more: false,
})),
listLineItems: vi.fn(async (id: string) => invoices.find((item) => item.id === id)!.lines),
voidInvoice,
update: invoiceUpdate,
},
});
return { update, cancel, list, sessionList, expire, sessions, voidInvoice, invoiceUpdate };
}
describe('POST /api/billing/cancel', () => {
it('returns 401 without a session and leaves subscription and reasons untouched', async () => {
const user = await createSubscribedUser();
const stripe = stubStripe([subscription(user)]);
signedOut();
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(401);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects a cross-origin request without changing billing state', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const response = await callRoute(
cancelRoute,
apiRequest('/api/billing/cancel', {
method: 'POST',
headers: { origin: 'https://evil.test' },
body: {},
})
);
expect(response.status).toBe(403);
expect(stripe.update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('refuses an account with no Stripe subscription', async () => {
const user = await createUser();
signedInAs(user);
const stripe = stubStripe([]);
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it('does not cancel another customer subscription referenced by a stale local mirror or request body', async () => {
const owner = await createSubscribedUser();
const caller = await createSubscribedUser({ stripeSubscriptionId: 'sub_foreign_stale' });
signedInAs(caller);
const stripe = stubStripe([subscription(owner, { id: 'sub_foreign_stale' })]);
const response = await callRoute(
cancelRoute,
cancelRequest({
reason: 'OTHER',
customerId: owner.stripeCustomerId,
subscriptionId: owner.stripeSubscriptionId,
})
);
expect(response.status).toBe(409);
expect(stripe.list).toHaveBeenCalledWith(
expect.objectContaining({ customer: caller.stripeCustomerId })
);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects a candidate whose Stripe customer does not match the signed-in account', async () => {
const user = await createSubscribedUser();
const owner = await createSubscribedUser();
signedInAs(user);
const foreign = subscription(owner);
const stripe = stubStripe([foreign]);
stripe.list.mockResolvedValueOnce({ data: [foreign], has_more: false });
expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects an already scheduled paid subscription without a reason write', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it.each([
{ reason: 'RAGE_QUIT' },
{ reason: 'OTHER', note: 'x'.repeat(501) },
{ reason: 'OTHER', note: 42 },
])('rejects malformed cancellation input %# before Stripe writes', async (body) => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(400);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it.each(['active', 'trialing'] as const)(
'schedules %s, persists the trimmed reason and syncs the user',
async (status) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status });
const stripe = stubStripe([original]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload. ' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
cancelAtPeriodEnd: true,
canceledImmediately: false,
voidedInvoices: [],
status,
periodEnd: new Date(original.items.data[0].current_period_end * 1000).toISOString(),
});
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'missing_features' },
});
expect(stripe.cancel).not.toHaveBeenCalled();
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
stripeSubscriptionId: original.id,
reason: 'MISSING_FEATURE',
note: 'Bulk upload.',
});
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect(after.subscriptionStatus).toBe(
status === 'active' ? BillingSubscriptionStatus.ACTIVE : BillingSubscriptionStatus.TRIALING
);
expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(
original.items.data[0].current_period_end * 1000
);
}
);
it('finds an unscheduled paid subscription behind an already scheduled authoritative one', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const scheduled = subscription(user);
const other = subscription(user, {
id: 'sub_other_paid',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([scheduled, other]);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: false,
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PROJECT_ENDED' }));
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(
other.id,
expect.objectContaining({ cancel_at_period_end: true })
);
expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
other.id
);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
scheduled.id
);
});
it.each(['past_due', 'unpaid', 'incomplete'] as const)(
'cancels %s immediately, stops collection and records the reason',
async (status) => {
const user = await createSubscribedUser({
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
});
signedInAs(user);
const original = subscription(user, { status });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PRICE_OR_BILLING', note: ' Stop billing. ' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
cancelAtPeriodEnd: false,
voidedInvoices: [invoice.id],
status: 'canceled',
});
expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(original.id, {
cancellation_details: { feedback: 'too_expensive' },
});
expect(stripe.update).not.toHaveBeenCalled();
expect(invoice.status).toBe('void');
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'PRICE_OR_BILLING',
note: 'Stop billing.',
stripeSubscriptionId: original.id,
});
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.CANCELED);
expect(after.stripeCancelAtPeriodEnd).toBe(false);
}
);
it('uses the original period to void the renewal when cancellation shortens the response period', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'past_due' });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
const cancel = stripe.cancel.getMockImplementation()!;
stripe.cancel.mockImplementationOnce(async (id, params) => ({
...(await cancel(id, params)),
items: {
...original.items,
data: original.items.data.map((item) => ({ ...item, current_period_end: unix(0) })),
},
}));
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({ voidedInvoices: [invoice.id] });
expect(invoice.status).toBe('void');
});
it.each(['past_due', 'unpaid'] as const)(
'offers cancellation for already scheduled %s and preserves another paid subscription',
async (status) => {
const user = await createSubscribedUser({
stripeCancelAtPeriodEnd: true,
subscriptionStatus:
status === 'past_due'
? BillingSubscriptionStatus.PAST_DUE
: BillingSubscriptionStatus.UNPAID,
});
signedInAs(user);
const unpaid = subscription(user, { status });
const paid = subscription(user, { id: 'sub_still_paid', cancel_at_period_end: true });
const stripe = stubStripe([unpaid, paid]);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: true,
});
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(
200
);
expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(unpaid.id, expect.anything());
expect(stripe.update).not.toHaveBeenCalled();
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
expect(after.stripeSubscriptionId).toBe(paid.id);
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
unpaid.id
);
}
);
it('expires only the incomplete subscription matching an owned open Checkout session', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'incomplete' });
const other = subscription(user, { id: 'sub_other_checkout', status: 'incomplete' });
const stripe = stubStripe([original, other]);
stripe.sessions.push(
{
id: 'cs_other',
customer: user.stripeCustomerId,
subscription: other.id,
status: 'open',
} as Stripe.Checkout.Session,
{
id: 'cs_match',
customer: user.stripeCustomerId,
subscription: { id: original.id },
status: 'open',
} as Stripe.Checkout.Session
);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Checkout abandoned' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
status: 'incomplete_expired',
});
expect(stripe.sessionList).toHaveBeenCalledWith(
expect.objectContaining({ customer: user.stripeCustomerId, status: 'open' })
);
expect(stripe.expire).toHaveBeenCalledExactlyOnceWith('cs_match');
expect(stripe.cancel).not.toHaveBeenCalled();
expect(stripe.sessions[0].status).toBe('open');
expect((await db.subscriptionCancellation.findFirstOrThrow()).note).toBe('Checkout abandoned');
});
it.each(['past_due', 'incomplete'] as const)(
'retries failed %s cleanup without recanceling or overwriting its reason',
async (status) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
if (status === 'incomplete') {
stripe.sessions.push({
id: 'cs_retry',
customer: user.stripeCustomerId,
subscription: original.id,
status: 'open',
} as Stripe.Checkout.Session);
}
stripe.voidInvoice.mockRejectedValueOnce(new Error('Invoice cleanup unavailable'));
const first = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Keep my answer' })
);
expect(first.status).toBe(500);
expect(invoice.status).toBe('open');
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
status === 'incomplete'
? BillingSubscriptionStatus.INCOMPLETE_EXPIRED
: BillingSubscriptionStatus.CANCELED
);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: true,
});
const second = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(second.status).toBe(200);
expect(await readData(second)).toMatchObject({
canceledImmediately: true,
voidedInvoices: [invoice.id],
});
expect(stripe.cancel).toHaveBeenCalledTimes(status === 'incomplete' ? 0 : 1);
expect(stripe.expire).toHaveBeenCalledTimes(status === 'incomplete' ? 1 : 0);
expect(stripe.voidInvoice).toHaveBeenCalledTimes(2);
expect(invoice.status).toBe('void');
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ reason: 'OTHER', note: 'Keep my answer' });
}
);
it('stops retrying a retained receivable without voiding it', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'unpaid' });
const invoice = renewal(original, { billing_reason: 'manual' });
const stripe = stubStripe([original], [invoice]);
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
voidedInvoices: [],
});
expect(stripe.voidInvoice).not.toHaveBeenCalled();
expect(stripe.invoiceUpdate).toHaveBeenCalledWith(
invoice.id,
expect.objectContaining({ auto_advance: false })
);
expect(invoice.status).toBe('open');
expect(invoice.auto_advance).toBe(false);
});
it.each([{}, { reason: 'PROJECT_ENDED', note: ' ' }])(
'allows skipping feedback and trims empty notes %#',
async (body) => {
const user = await createSubscribedUser();
signedInAs(user);
stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(200);
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'reason' in body ? body.reason : null,
note: null,
});
}
);
it('lets only one of two concurrent paid requests through', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const results = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect(results.map((response) => response.status).sort()).toEqual([200, 409]);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
});
it('keeps the cancellation and reason when customer-wide sync fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
stripe.list
.mockResolvedValueOnce({ data: [original], has_more: false })
.mockRejectedValueOnce(new Error('Sync unavailable'));
expect(
(await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }))).status
).toBe(200);
expect((await db.subscriptionCancellation.findFirstOrThrow()).reason).toBe('PRICE_OR_BILLING');
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(true);
});
it.each([true, false])(
'releases the paid claim when Stripe rejects the update (invalid request: %s)',
async (invalidRequest) => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const error = invalidRequest
? Object.assign(new Error('No such subscription'), { type: 'StripeInvalidRequestError' })
: new Error('Stripe unavailable');
stripe.update.mockRejectedValueOnce(error);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(invalidRequest ? 409 : 500);
if (invalidRequest) expect(await readError(response)).toMatch(/Manage Subscription/);
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
}
);
});
describe('repeated and concurrent cancellation reasons', () => {
it('records a new immediate cancellation after an earlier scheduled cancellation was resumed', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'past_due' });
const stripe = stubStripe([original]);
await db.subscriptionCancellation.create({
data: {
userId: user.id,
stripeSubscriptionId: original.id,
reason: 'PRICE_OR_BILLING',
note: 'Previous canceled cycle',
createdAt: new Date(Date.now() - 40 * DAY * 1000),
periodEnd: new Date(Date.now() - 30 * DAY * 1000),
},
});
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PROJECT_ENDED', note: 'Current cancellation' })
);
expect(response.status).toBe(200);
expect(stripe.cancel).toHaveBeenCalledTimes(1);
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(2);
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }),
])
);
});
it('records only one reason when concurrent requests cancel a nonmirrored paid subscription', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const scheduled = subscription(user);
const other = subscription(user, {
id: 'sub_other_paid',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([scheduled, other]);
const update = stripe.update.getMockImplementation()!;
let entered = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const timeout = setTimeout(release, 1000);
stripe.update.mockImplementation(async (id, params) => {
entered += 1;
if (entered === 2) release();
await barrier;
return update(id, params);
});
try {
const responses = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect(entered).toBe(2);
expect(responses.map((response) => response.status)).toEqual([200, 200]);
expect(
await db.subscriptionCancellation.count({
where: { userId: user.id, stripeSubscriptionId: other.id },
})
).toBe(1);
} finally {
clearTimeout(timeout);
}
});
});
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import {
buildBillingAccessWhereInput,
buildExpiredBillingWhereInput,
getBillingAccessEndDate,
getStorageCleanupEligibleAt,
hasBillingAccess,
isPaidTier,
startCardlessTrial,
syncStripeCustomerSubscriptions,
} from '@/lib/billing';
import { getStripe } from '@/lib/stripe';
import { db } from '../helpers/db';
import { createUser } from '../factories';
// Uses the API project's real database and reset hooks. Run only when no other API suite uses it.
const CUSTOMER_ID = 'cus_entitlement_regression';
const SUBSCRIPTION_ID = 'sub_entitlement_regression';
const PRICE_ID = 'price_entitlement_regression';
const TRIAL_START = new Date('2026-10-01T00:00:00.000Z');
const TRIAL_END = new Date('2026-10-08T00:00:00.000Z');
const CANCELED_AT = new Date('2026-10-02T00:00:00.000Z');
const REPORTED_PERIOD_END = new Date('2026-11-01T00:00:00.000Z');
function subscription(overrides: Partial<Stripe.Subscription> = {}): Stripe.Subscription {
return {
id: SUBSCRIPTION_ID,
customer: CUSTOMER_ID,
status: 'canceled',
created: Date.parse('2026-09-01T00:00:00.000Z') / 1000,
trial_end: null,
ended_at: CANCELED_AT.getTime() / 1000,
canceled_at: CANCELED_AT.getTime() / 1000,
cancel_at: null,
cancel_at_period_end: false,
// Deliberately no top-level period: the regression depends on the item-only payload.
items: {
data: [
{
price: { id: PRICE_ID },
current_period_start: TRIAL_START.getTime() / 1000,
current_period_end: REPORTED_PERIOD_END.getTime() / 1000,
},
],
},
...overrides,
} as Stripe.Subscription;
}
function stubSubscription(value: Stripe.Subscription) {
const list = vi.fn(async () => ({ data: [value] }));
vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
return list;
}
async function startDeferredTrial() {
const user = await createUser({
subscriptionStatus: 'PAST_DUE',
stripeCustomerId: CUSTOMER_ID,
stripeSubscriptionId: SUBSCRIPTION_ID,
stripePriceId: PRICE_ID,
stripeCurrentPeriodEnd: REPORTED_PERIOD_END,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
expect(await startCardlessTrial(user.id, TRIAL_START)).toBe(true);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
return user.id;
}
async function matchingAccessUsers(userId: string, now: Date) {
return db.user.findMany({
where: { AND: [{ id: userId }, buildBillingAccessWhereInput(now)] },
select: { id: true },
});
}
async function matchingCleanupUsers(userId: string, now: Date) {
return db.user.findMany({
where: { AND: [{ id: userId }, buildExpiredBillingWhereInput(now)] },
select: { id: true },
});
}
describe('billing entitlement and retention after subscription sync', () => {
beforeEach(() => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', PRICE_ID);
// Mock only Date so PostgreSQL sockets and query timers keep running normally.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(TRIAL_START);
});
afterEach(() => {
vi.useRealTimers();
});
// Catches restoring `hasAccess || hasActiveTrial(preservedTrialEnd)` when writing the cutoff.
it('preserves a deferred trial without granting paid access to the canceled unpaid period', async () => {
const userId = await startDeferredTrial();
const list = stubSubscription(subscription());
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
expect(list).toHaveBeenCalledWith({ customer: CUSTOMER_ID, status: 'all', limit: 100 });
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.subscriptionStatus).toBe('CANCELED');
expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
await Promise.all(
[
{ now: CANCELED_AT, expected: true },
{ now: new Date('2026-10-07T23:59:59.999Z'), expected: true },
{ now: TRIAL_END, expected: false },
{ now: new Date('2026-10-09T00:00:00.000Z'), expected: false },
].map(async ({ now, expected }) => {
expect(isPaidTier(stored, now)).toBe(false);
expect(hasBillingAccess(stored, now)).toBe(expected);
expect(await matchingAccessUsers(userId, now)).toEqual(expected ? [{ id: userId }] : []);
})
);
});
// Catches choosing the raw unpaid period, choosing the earlier expiry, or requiring that raw period to lapse in SQL.
it.each([
{
label: 'trial outlasts the subscription',
subscriptionEnd: CANCELED_AT,
lastEntitlementEnd: TRIAL_END,
cleanupAt: new Date('2026-10-23T00:00:00.000Z'),
},
{
label: 'subscription outlasts the trial',
subscriptionEnd: new Date('2026-10-12T00:00:00.000Z'),
lastEntitlementEnd: new Date('2026-10-12T00:00:00.000Z'),
cleanupAt: new Date('2026-10-27T00:00:00.000Z'),
},
])('retains storage until the last legitimate expiry plus 15 days: $label', async (scenario) => {
const userId = await startDeferredTrial();
stubSubscription(
subscription({
ended_at: scenario.subscriptionEnd.getTime() / 1000,
canceled_at: scenario.subscriptionEnd.getTime() / 1000,
})
);
vi.setSystemTime(scenario.subscriptionEnd);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.billingAccessEndedAt).toEqual(scenario.subscriptionEnd);
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
expect(getBillingAccessEndDate(stored)).toEqual(scenario.lastEntitlementEnd);
expect(getStorageCleanupEligibleAt(stored)).toEqual(scenario.cleanupAt);
expect(hasBillingAccess(stored, scenario.cleanupAt)).toBe(false);
const [before, at] = await Promise.all([
matchingCleanupUsers(userId, new Date(scenario.cleanupAt.getTime() - 1)),
matchingCleanupUsers(userId, scenario.cleanupAt),
]);
expect(before).toEqual([]);
expect(at).toEqual([{ id: userId }]);
});
// Catches replacing persisted trial history with keepUnexpiredTrial on a terminal resync.
it('keeps expired trial history and the retention deadline across repeated terminal syncs', async () => {
const userId = await startDeferredTrial();
stubSubscription(subscription());
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
for (const now of ['2026-10-09T00:00:00.000Z', '2026-10-20T00:00:00.000Z']) {
vi.setSystemTime(new Date(now));
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
expect(isPaidTier(stored)).toBe(false);
expect(hasBillingAccess(stored)).toBe(false);
expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-10-23T00:00:00.000Z'));
expect(await matchingCleanupUsers(userId, new Date(now))).toEqual([]);
}
expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([
{ id: userId },
]);
});
// Catches treating scheduled cancellation as immediate termination of a paid subscription.
it('keeps a paid scheduled cancellation accessible after the cardless trial expires', async () => {
const userId = await startDeferredTrial();
stubSubscription(
subscription({
status: 'active',
ended_at: null,
cancel_at_period_end: true,
cancel_at: REPORTED_PERIOD_END.getTime() / 1000,
})
);
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
const afterTrial = new Date('2026-10-09T00:00:00.000Z');
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeCancelAtPeriodEnd).toBe(true);
expect(stored.billingAccessEndedAt).toBeNull();
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(isPaidTier(stored, afterTrial)).toBe(true);
expect(hasBillingAccess(stored, afterTrial)).toBe(true);
expect(await matchingAccessUsers(userId, afterTrial)).toEqual([{ id: userId }]);
expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([]);
expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-11-16T00:00:00.000Z'));
});
});
@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
function renderDialog(
overrides: {
periodEnd?: string | null;
isTrial?: boolean;
canceledImmediately?: boolean;
confirmResult?: boolean;
} = {}
) {
const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
const onOpenChange = vi.fn();
render(
<CancelSubscriptionDialog
open
onOpenChange={onOpenChange}
periodEnd={
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
}
isTrial={overrides.isTrial ?? false}
canceledImmediately={overrides.canceledImmediately}
onConfirm={onConfirm}
/>
);
return { onConfirm, onOpenChange };
}
describe('CancelSubscriptionDialog', () => {
it('lists every answer with none selected', () => {
renderDialog();
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(5);
for (const radio of radios) {
expect(radio).toHaveAttribute('aria-checked', 'false');
}
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
// The question is skippable: the destructive button works with nothing
// chosen, and the handler receives an explicit null rather than a default.
it('cancels with no reason when the question is skipped', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({ reason: null, note: null });
});
it('opens the note box only under the answers that ask for detail', async () => {
renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'I am not using it enough' }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
expect(screen.getByRole('textbox')).toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', { name: 'It is missing something I need' }));
expect(screen.getByLabelText(/What was missing\?/)).toBeInTheDocument();
});
it('sends the chosen reason with a trimmed note', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), ' Moved the client to Frame.io ');
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({
reason: 'OTHER',
note: 'Moved the client to Frame.io',
});
});
// A note typed under "Something else" must not travel with an answer that
// never showed the box, or the admin reads a comment about nothing.
it('drops the note when the answer changes to one without a note box', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), 'Some detail');
await userEvent.click(screen.getByRole('radio', { name: 'The project or client work ended' }));
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({ reason: 'PROJECT_ENDED', note: null });
});
// A failed request must not cost the customer the answer they typed.
it('keeps the answer on screen when the confirmation fails', async () => {
const { onConfirm } = renderDialog({ confirmResult: false });
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), 'Kept this');
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(screen.getByRole('radio', { name: 'Something else' })).toHaveAttribute(
'aria-checked',
'true'
);
expect(screen.getByRole('textbox')).toHaveValue('Kept this');
});
it('closes without confirming from the keep button', async () => {
const { onConfirm, onOpenChange } = renderDialog();
await userEvent.click(screen.getByRole('button', { name: 'Keep subscription' }));
expect(onConfirm).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => {
renderDialog({ canceledImmediately: true });
expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument();
expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent(
'Canceling does not extend access to your workspaces.'
);
expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent(
'charges for prior service and other items may still be owed.'
);
expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument();
expect(screen.getAllByRole('radio')).toHaveLength(5);
});
it('retains the scheduled period-end explanation', () => {
renderDialog();
expect(screen.getByText(/Everything stays on until/)).toHaveTextContent(
new Date('2026-10-01T00:00:00.000Z').toLocaleDateString()
);
expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument();
});
it('names the trial instead of the subscription while still trialing', () => {
renderDialog({ isTrial: true });
expect(screen.getByRole('heading', { name: 'Cancel your trial?' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel trial' })).toBeInTheDocument();
});
});
+85 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') {
@@ -70,3 +71,86 @@ describe('scheduled cancellation in billing settings', () => {
);
});
});
function renderPastDue(hasBillingAccess: boolean) {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url !== '/api/billing') return { ok: false };
return {
ok: true,
json: async () => ({
data: {
isEnabled: true,
isConfigured: true,
checkoutAvailable: false,
portalAvailable: true,
cancelAvailable: true,
cancelIsImmediate: true,
needsPaymentFix: true,
openInvoice: null,
workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false },
subscription: {
status: 'PAST_DUE',
label: 'Past due',
hasActiveSubscription: false,
hasRecoverableSubscription: true,
hasActiveTrial: false,
hasBillingAccess,
currentPeriodEnd: '2026-10-08T12:00:00Z',
trialEndsAt: null,
cancelAtPeriodEnd: false,
cancelAt: null,
},
},
}),
};
})
);
render(<SettingsPage billingOnly />);
}
describe('past-due access in billing settings', () => {
it('opens immediate unpaid cancellation copy and waits for confirmation', async () => {
const user = userEvent.setup();
renderPastDue(true);
await user.click(await screen.findByRole('button', { name: 'Cancel subscription' }));
const dialog = within(screen.getByRole('dialog'));
expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent(
'Canceling does not extend access to your workspaces.'
);
expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent(
'charges for prior service and other items may still be owed.'
);
expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument();
expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled();
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
await user.click(dialog.getByRole('button', { name: 'Keep subscription' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
});
it('shows continued workspace access during payment grace without an active trial', async () => {
renderPastDue(true);
expect(
await screen.findByText('Workspace access remains available while you resolve your payment.')
).toBeInTheDocument();
expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument();
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
});
it('shows access has ended when payment grace has expired and no trial remains', async () => {
renderPastDue(false);
expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument();
expect(
screen.queryByText('Workspace access remains available while you resolve your payment.')
).not.toBeInTheDocument();
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
});
});
+6 -1
View File
@@ -127,7 +127,12 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
...actual,
getStripe: vi.fn(() => ({
customers: { create: vi.fn(async () => ({ id: 'cus_test_default' })) },
subscriptions: { list: vi.fn(async () => ({ data: [] })) },
subscriptions: {
list: vi.fn(async () => ({ data: [] })),
update: vi.fn(() => {
throw new Error('stripe.subscriptions.update was not stubbed for this test');
}),
},
checkout: {
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/checkout' })) },
},
+1
View File
@@ -70,6 +70,7 @@ const REVIEWED_MIGRATIONS = [
'20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
'20260908120000_add_subscription_cancellations',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+409
View File
@@ -0,0 +1,409 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import {
findCancelableStripeSubscription,
isCurrentSubscriptionInvoice,
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
const stripe = vi.hoisted(() => ({
subscriptions: { list: vi.fn(), retrieve: vi.fn() },
invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() },
}));
vi.mock('@/lib/db', () => ({ db: {} }));
vi.mock('@/lib/stripe', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/stripe')>()),
getStripe: () => stripe,
}));
const START = 1_800_000_000;
const END = 1_802_592_000;
function subscription(overrides: Record<string, unknown> = {}): Stripe.Subscription {
return {
id: 'sub_target',
customer: 'cus_target',
status: 'past_due',
created: 100,
latest_invoice: 'in_current',
cancel_at: null,
cancel_at_period_end: false,
items: {
data: [
{
price: { id: 'price_plan' },
current_period_start: START,
current_period_end: END,
},
],
},
...overrides,
} as unknown as Stripe.Subscription;
}
function line(overrides: Record<string, unknown> = {}) {
return {
id: 'il_plan',
amount: 1900,
period: { start: START, end: END },
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_target', proration: false },
},
pricing: { price_details: { price: 'price_plan' } },
...overrides,
};
}
function invoice(overrides: Record<string, unknown> = {}): Stripe.Invoice {
return {
id: 'in_current',
customer: 'cus_target',
status: 'open',
amount_paid: 0,
amount_due: 1900,
billing_reason: 'subscription_cycle',
auto_advance: true,
parent: { subscription_details: { subscription: 'sub_target' } },
lines: { data: [line()], has_more: false },
...overrides,
} as unknown as Stripe.Invoice;
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('STRIPE_PRICE_ID', 'price_plan');
stripe.subscriptions.retrieve.mockResolvedValue(subscription());
stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false });
stripe.invoices.list.mockResolvedValue({ data: [], has_more: false });
stripe.invoices.update.mockResolvedValue({});
stripe.invoices.voidInvoice.mockResolvedValue({});
});
describe('subscription invoice cleanup', () => {
it.each(['subscription_cycle', 'subscription_create'])(
'voids a complete unpaid current %s invoice and returns its id',
async (billingReason) => {
const current = invoice({ billing_reason: billingReason });
stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false });
expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true);
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([
'in_current',
]);
expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target');
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', {
auto_advance: false,
});
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
}
);
const retainedInvoices: [string, () => Stripe.Invoice][] = [
[
'a different start with the current end',
() =>
invoice({
lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false },
}),
],
[
'a different end with the current start',
() =>
invoice({
lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false },
}),
],
['an older invoice id', () => invoice({ id: 'in_old' })],
[
'an older service period',
() =>
invoice({
lines: {
data: [line({ period: { start: START - 2_592_000, end: START } })],
has_more: false,
},
}),
],
[
'a mixed invoice containing a manual charge',
() =>
invoice({
lines: {
data: [
line(),
line({
id: 'il_manual',
parent: { type: 'invoice_item_details', invoice_item_details: {} },
}),
],
has_more: false,
},
}),
],
[
'a proration',
() =>
invoice({
lines: {
data: [
line({
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_target', proration: true },
},
}),
],
has_more: false,
},
}),
],
['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })],
['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })],
[
'a different price',
() =>
invoice({
lines: {
data: [line({ pricing: { price_details: { price: 'price_other' } } })],
has_more: false,
},
}),
],
[
'a line belonging to a different subscription',
() =>
invoice({
lines: {
data: [
line({
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_other', proration: false },
},
}),
],
has_more: false,
},
}),
],
['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })],
['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })],
];
it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => {
const retained = makeInvoice();
stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false });
expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false);
await expect(
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
).resolves.toEqual([]);
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, {
auto_advance: false,
});
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled();
});
it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => {
stripe.invoices.list
.mockResolvedValueOnce({
data: [
invoice({ id: 'in_old' }),
invoice({
id: 'in_foreign',
parent: { subscription_details: { subscription: 'sub_other' } },
}),
],
has_more: true,
})
.mockResolvedValueOnce({ data: [invoice()], has_more: false });
await expect(
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
).resolves.toEqual(['in_current']);
expect(stripe.invoices.list.mock.calls).toEqual([
[{ customer: 'cus_target', status: 'open', limit: 100 }],
[{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }],
]);
expect(stripe.invoices.update.mock.calls).toEqual([
['in_old', { auto_advance: false }],
['in_current', { auto_advance: false }],
]);
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
});
it('voids a current invoice even when collection was already paused before a retry', async () => {
stripe.invoices.list.mockResolvedValue({
data: [invoice({ auto_advance: false })],
has_more: false,
});
await expect(
voidOpenSubscriptionInvoices(
'cus_target',
'sub_target',
subscription({
status: 'canceled',
latest_invoice: { id: 'in_current' },
})
)
).resolves.toEqual(['in_current']);
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
});
it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)(
'propagates the Stripe %s failure rather than claiming successful cleanup',
async (operation) => {
const failure = new Error(`Stripe ${operation} failed`);
stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false });
const failingCall =
operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation];
failingCall.mockRejectedValueOnce(failure);
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure);
expect(failingCall).toHaveBeenCalledTimes(1);
if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
}
);
it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => {
await expect(
voidOpenSubscriptionInvoices(
'cus_target',
'sub_target',
subscription({
customer: { id: 'cus_other' },
})
)
).rejects.toThrow('Subscription customer mismatch');
expect(stripe.invoices.list).not.toHaveBeenCalled();
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
});
});
describe('cancellation candidate selection', () => {
it.each([
{ cancel_at: END, cancel_at_period_end: false },
{ cancel_at: null, cancel_at_period_end: true },
])(
'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one',
async (schedule) => {
stripe.subscriptions.list
.mockResolvedValueOnce({
data: [
subscription({
id: 'sub_newer',
status: 'active',
created: 200,
...schedule,
}),
],
has_more: true,
})
.mockResolvedValueOnce({
data: [
subscription({
id: 'sub_older',
status: 'active',
created: 100,
}),
],
has_more: false,
});
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older');
expect(stripe.subscriptions.list.mock.calls).toEqual([
[{ customer: 'cus_target', status: 'all', limit: 100 }],
[{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }],
]);
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it.each(['past_due', 'unpaid', 'incomplete'] as const)(
'still selects a scheduled %s subscription for immediate cancellation',
async (status) => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })],
has_more: false,
});
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it.each([
['a current invoice still awaiting void', { auto_advance: false }],
['an older invoice still collecting', { id: 'in_old', auto_advance: true }],
])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false });
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({
customer: 'cus_target',
status: 'open',
limit: 100,
});
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
});
it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockResolvedValue({
data: [
invoice({ id: 'in_old', auto_advance: false }),
invoice({
id: 'in_foreign',
parent: { subscription_details: { subscription: 'sub_other' } },
}),
],
has_more: false,
});
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
});
it.each(['active', 'canceled'] as const)(
'ignores a %s subscription for a different product',
async (status) => {
stripe.subscriptions.list.mockResolvedValue({
data: [
subscription({
status,
items: { data: [{ price: { id: 'price_other' } }] },
}),
],
has_more: false,
});
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => {
const failure = new Error('Stripe invoice lookup failed');
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockRejectedValueOnce(failure);
await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure);
});
});
+22 -36
View File
@@ -449,7 +449,7 @@ describe('hasBillingAccess', () => {
});
describe('getBillingAccessEndDate', () => {
it('prefers billingAccessEndedAt over every other date', () => {
it('keeps an independent trial beyond the subscription cutoff', () => {
const ended = new Date('2026-01-10T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -458,10 +458,10 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
expect(result).toBe(ended);
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => {
it('keeps a longer trial when billing has not been marked ended', () => {
const periodEnd = new Date('2026-02-01T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -469,7 +469,7 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
expect(result).toBe(periodEnd);
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to trialEndsAt when there is no paid period', () => {
@@ -535,36 +535,18 @@ describe('buildBillingAccessWhereInput', () => {
});
describe('buildExpiredBillingWhereInput', () => {
it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
const cutoff = new Date('2025-12-31T00:00:00.000Z');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({
AND: [
{ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cutoff } },
{ AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] },
],
},
],
it('requires the entire trial retention window before deleting an inactive account', () => {
const where = buildExpiredBillingWhereInput(NOW) as {
AND: Array<Record<string, unknown>>;
};
expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
expect(where.AND[1]).toEqual({
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
});
});
// The NOT form this replaced could not express "no access" for a row whose date columns are
// empty, because SQL turns a comparison against NULL into unknown rather than false. Every
// branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
// proves it against a real database; this only guards the shape.
it('admits a null trial and a null period end as expired rather than skipping the row', () => {
const where = buildExpiredBillingWhereInput(NOW) as {
AND: Array<{ OR?: Array<Record<string, unknown>> }>;
};
expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
});
// Real SQL behavior with null dates and future unpaid periods is covered by the
// API cleanup and entitlement suites; the unit check guards the retention boundary.
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
@@ -1818,10 +1800,13 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().billingAccessEndedAt).toBeNull();
expect(updateData().billingAccessEndedAt).toEqual(NOW);
expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual(
new Date(NOW.getTime() + 19 * DAY_MS)
);
});
it('clears a trial that has already run out', async () => {
it('preserves an expired trial for the storage retention calculation', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
@@ -1832,7 +1817,7 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().trialEndsAt).toBeNull();
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
@@ -1896,7 +1881,8 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
expect(updateData().billingAccessEndedAt).toBeNull();
expect(updateData().billingAccessEndedAt).toEqual(NOW);
expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true);
});
it('still ends access when the trial has already run out', async () => {
@@ -1907,7 +1893,7 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBeNull();
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});