feat(billing): cancel in-app with a one-question reason

Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
This commit is contained in:
Yusuf İpek
2026-09-08 14:05:16 +03:00
parent d5d2f0535e
commit 7aeda83eb6
16 changed files with 1147 additions and 8 deletions
@@ -30,6 +30,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
interface NotificationSettings {
telegramChatId: string | null;
@@ -148,7 +150,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [testing, setTesting] = useState<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(null);
const [billingLoading, setBillingLoading] = useState(true);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null);
const [billingAction, setBillingAction] = useState<
'checkout' | 'portal' | 'trial' | 'cancel' | null
>(null);
const [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);
@@ -302,6 +307,47 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
}
}, [showMessage]);
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 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',
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);
}
},
[showMessage]
);
if (loading) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -498,7 +544,24 @@ 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.subscription.hasActiveSubscription &&
billing.portalAvailable &&
!hasScheduledCancellation ? (
<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}>
@@ -534,6 +597,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</CardContent>
</Card>
{billing ? (
<CancelSubscriptionDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
periodEnd={billing.subscription.currentPeriodEnd}
isTrial={billing.subscription.status === 'TRIALING'}
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>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
CANCELLATION_NOTE_MAX_LENGTH,
cancelSubscriptionAtPeriodEnd,
isCancellationReason,
} from '@/lib/cancellation';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
/**
* In-app cancellation: end the subscription at the close of the current
* period and keep the one answer the customer gave about why.
*
* 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');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
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');
}
if (!isStripeConfigured()) {
return apiErrors.internalError('Stripe billing is not configured');
}
const body = await request.json().catch(() => null);
const rawReason = body?.reason ?? null;
if (rawReason !== null && !isCancellationReason(rawReason)) {
return apiErrors.badRequest('Unknown cancellation reason');
}
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 result = await cancelSubscriptionAtPeriodEnd({
userId: session.user.id,
reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null,
});
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({
cancelAtPeriodEnd: true,
periodEnd: result.periodEnd?.toISOString() ?? null,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('billing.cancel', error);
return apiErrors.internalError('Failed to cancel subscription');
}
}
@@ -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,164 @@
'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;
/** 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,
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>
{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}
+21 -4
View File
@@ -746,6 +746,26 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
return subscription.items.data.some((item) => item.price.id === configuredPriceId);
}
/**
* When the current billing period ends, as a Unix timestamp, or null.
*
* The API version this client pins (2026-02-25) reports the period on each
* subscription item rather than on the subscription itself, and every item of
* a single-price subscription carries the same dates. The top-level field is
* still read afterwards so an older fixture or a replayed event body from a
* previous version keeps working.
*/
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
const fromItem = subscription.items?.data?.[0]?.current_period_end;
if (typeof fromItem === 'number') {
return fromItem;
}
return 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? subscription.current_period_end
: null;
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
@@ -768,10 +788,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
return null;
}
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? subscription.current_period_end
: null;
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
const cancelAt =
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
? subscription.cancel_at
+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;
}
+136
View File
@@ -0,0 +1,136 @@
import type Stripe from 'stripe';
import type { CancellationReason } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getSubscriptionPeriodEnd,
hasActiveSubscription,
syncStripeSubscriptionToUser,
} 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 }
| { 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'
);
}
/**
* Schedules the account's subscription to end at the close of the current
* billing period and records why.
*
* The order of the writes is deliberate. The local flag is claimed first with
* a conditional update, so two requests racing for the same subscription (a
* double click, a retried request) cannot both reach Stripe and both write a
* reason row: the second one loses the claim and gets `ALREADY_CANCELING`.
* Stripe goes second because it is the only step that can refuse, and a
* refusal hands the claim back. The reason row goes third, straight after
* Stripe accepts, so it exists even if the sync below throws. The sync goes
* last and is best effort: the webhook for the same update is already on its
* way and will write the identical state, so a failure here only delays what
* the settings page shows, it never loses the cancellation.
*/
export async function cancelSubscriptionAtPeriodEnd(params: {
userId: string;
reason: CancellationReason | null;
note: string | null;
}): Promise<CancelSubscriptionResult> {
const user = await db.user.findUnique({
where: { id: params.userId },
select: {
subscriptionStatus: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
},
});
if (!user?.stripeSubscriptionId || !hasActiveSubscription(user.subscriptionStatus)) {
return { ok: false, code: 'NO_SUBSCRIPTION' };
}
const subscriptionId = user.stripeSubscriptionId;
const claimed = await db.user.updateMany({
where: {
id: params.userId,
stripeSubscriptionId: subscriptionId,
stripeCancelAtPeriodEnd: false,
},
data: { stripeCancelAtPeriodEnd: true },
});
if (claimed.count === 0) {
return { ok: false, code: 'ALREADY_CANCELING' };
}
let subscription: Stripe.Subscription;
try {
subscription = await getStripe().subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
cancellation_details: params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {},
});
} catch (error) {
await db.user.updateMany({
where: { id: params.userId, stripeSubscriptionId: subscriptionId },
data: { stripeCancelAtPeriodEnd: false },
});
// The subscription Stripe knows about is not the one we hold, most often
// because it already ended there and the webhook has not caught up. That
// is the customer's state, not a server fault, and the portal can show it.
if (isStripeInvalidRequest(error)) {
logError('billing.cancel.rejected', error);
return { ok: false, code: 'STRIPE_REJECTED' };
}
throw error;
}
const periodEndUnix = getSubscriptionPeriodEnd(subscription);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
await db.subscriptionCancellation.create({
data: {
userId: params.userId,
stripeSubscriptionId: subscriptionId,
reason: params.reason,
note: params.note,
periodEnd,
},
});
try {
await syncStripeSubscriptionToUser(subscription);
} catch (error) {
logError('billing.cancel.sync', error);
}
return { ok: true, periodEnd };
}
+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
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from 'vitest';
import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as cancelRoute } from '@/app/api/billing/cancel/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;
function unix(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
function cancelRequest(body?: unknown) {
return apiRequest('/api/billing/cancel', {
method: 'POST',
headers: ORIGIN_HEADERS,
body: body ?? {},
});
}
/**
* Stands in for `stripe.subscriptions.update`, echoing back the subscription
* the way Stripe does: same id, `cancel_at_period_end` flipped, period end
* intact. The echo matters because the route syncs that object into the user
* row without waiting for the webhook.
*/
function stubStripeUpdate(
options: { customer?: string | null; periodEnd?: number | null; status?: string } = {}
) {
const periodEnd = options.periodEnd === undefined ? unix(20 * DAY) : options.periodEnd;
const update = vi.fn(async (id: string, params: Record<string, unknown>) => ({
id,
// The sync looks the user up by this, so a stub that names the wrong
// customer leaves the user row untouched and the webhook to fix it later.
customer: options.customer ?? 'cus_test_cancel',
status: options.status ?? 'active',
created: unix(-30 * DAY),
cancel_at_period_end: params.cancel_at_period_end === true,
cancel_at: null,
trial_end: null,
// Where the pinned API version reports the period: on the item, not on the
// subscription. A stub that puts it at the top level hides a null date.
items: { data: [{ price: { id: ENTITLED_PRICE_ID }, current_period_end: periodEnd }] },
}));
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: { update, list: vi.fn(async () => ({ data: [] })) },
});
return update;
}
describe('POST /api/billing/cancel', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(401);
});
it('rejects a cross-origin request', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const response = await callRoute(
cancelRoute,
apiRequest('/api/billing/cancel', {
method: 'POST',
headers: { origin: 'https://evil.test' },
body: {},
})
);
expect(response.status).toBe(403);
});
it('refuses when there is no active subscription to cancel', async () => {
const trialUser = await createUser();
signedInAs(trialUser);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(409);
expect(update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it('refuses a second cancellation of a subscription already set to end', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(409);
expect(update).not.toHaveBeenCalled();
});
it('rejects an unknown reason without touching Stripe', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'RAGE_QUIT' }));
expect(response.status).toBe(400);
expect(await readError(response)).toMatch(/reason/i);
expect(update).not.toHaveBeenCalled();
});
it('rejects a note longer than the column allows', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'x'.repeat(501) })
);
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
// The whole point: one Stripe write with the answer attached, one row that
// keeps the answer on our side, and the user row updated before any webhook.
it('schedules the cancellation, records the reason and syncs the user row', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const periodEnd = unix(12 * DAY);
const update = stubStripeUpdate({ customer: user.stripeCustomerId, periodEnd });
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload for 16x9 and 9x16. ' })
);
expect(response.status).toBe(200);
const data = await readData<{ cancelAtPeriodEnd: boolean; periodEnd: string | null }>(response);
expect(data.cancelAtPeriodEnd).toBe(true);
expect(data.periodEnd).toBe(new Date(periodEnd * 1000).toISOString());
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'missing_features' },
});
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
stripeSubscriptionId: user.stripeSubscriptionId,
reason: 'MISSING_FEATURE',
note: 'Bulk upload for 16x9 and 9x16.',
});
expect(rows[0].periodEnd?.getTime()).toBe(periodEnd * 1000);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
});
// Skipping the question is allowed and must still cancel. The row is kept
// with no reason so the admin tally can count how often the question is
// skipped rather than pretending those cancellations never happened.
it('cancels with no reason given and records the skip', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({}));
expect(response.status).toBe(200);
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: {},
});
const row = await db.subscriptionCancellation.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.reason).toBeNull();
expect(row.note).toBeNull();
});
// A note under an answer that does not ask for one is still accepted by the
// API; the dialog is what hides the box, and the route must not depend on it.
it('keeps a note on any reason and drops an empty one', async () => {
const user = await createSubscribedUser();
signedInAs(user);
stubStripeUpdate();
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PROJECT_ENDED', note: ' ' })
);
expect(response.status).toBe(200);
const row = await db.subscriptionCancellation.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.reason).toBe('PROJECT_ENDED');
expect(row.note).toBeNull();
});
it('rejects a note that is not text', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER', note: 42 }));
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
// Two requests racing for the same subscription must produce one Stripe
// write and one reason row, or the admin tally counts a churn twice.
it('lets only one of two concurrent requests through', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate({ customer: user.stripeCustomerId });
const [first, second] = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect([first.status, second.status].sort()).toEqual([200, 409]);
expect(update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
});
// The reason row and the local flag must survive a sync that blows up: the
// webhook rewrites the same state later, the answer would be gone for good.
it('keeps the cancellation and the reason when the local sync fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
// No `items` at all: the sync reads `items.data` and throws.
update: vi.fn(async (id: string) => ({ id, customer: user.stripeCustomerId })),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }));
expect(response.status).toBe(200);
const row = await db.subscriptionCancellation.findFirstOrThrow({ where: { userId: user.id } });
expect(row.reason).toBe('PRICE_OR_BILLING');
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
});
// A subscription Stripe no longer knows is the customer's state, not a
// server fault: a 409 with a pointer to the portal, and the claim handed back
// so the button still works once the webhook catches up.
it('answers 409 and releases the claim when Stripe rejects the subscription id', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update: vi.fn(async () => {
throw Object.assign(new Error('No such subscription'), {
type: 'StripeInvalidRequestError',
});
}),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(409);
expect(await readError(response)).toMatch(/Manage Subscription/);
expect(await db.subscriptionCancellation.count()).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(false);
});
it('does not record a reason and releases the claim when Stripe is down', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update: vi.fn(async () => {
throw new Error('No such subscription');
}),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(500);
expect(await db.subscriptionCancellation.count()).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(false);
});
});
@@ -0,0 +1,117 @@
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; 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}
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('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();
});
});
+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. */