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>