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
+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');
}
}