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
@@ -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}