mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user