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
+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