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
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from 'vitest';
import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { createSubscribedUser, createUser } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const DAY = 24 * 60 * 60;
function unix(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
function cancelRequest(body?: unknown) {
return apiRequest('/api/billing/cancel', {
method: 'POST',
headers: ORIGIN_HEADERS,
body: body ?? {},
});
}
/**
* Stands in for `stripe.subscriptions.update`, echoing back the subscription
* the way Stripe does: same id, `cancel_at_period_end` flipped, period end
* intact. The echo matters because the route syncs that object into the user
* row without waiting for the webhook.
*/
function stubStripeUpdate(
options: { customer?: string | null; periodEnd?: number | null; status?: string } = {}
) {
const periodEnd = options.periodEnd === undefined ? unix(20 * DAY) : options.periodEnd;
const update = vi.fn(async (id: string, params: Record<string, unknown>) => ({
id,
// The sync looks the user up by this, so a stub that names the wrong
// customer leaves the user row untouched and the webhook to fix it later.
customer: options.customer ?? 'cus_test_cancel',
status: options.status ?? 'active',
created: unix(-30 * DAY),
cancel_at_period_end: params.cancel_at_period_end === true,
cancel_at: null,
trial_end: null,
// Where the pinned API version reports the period: on the item, not on the
// subscription. A stub that puts it at the top level hides a null date.
items: { data: [{ price: { id: ENTITLED_PRICE_ID }, current_period_end: periodEnd }] },
}));
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: { update, list: vi.fn(async () => ({ data: [] })) },
});
return update;
}
describe('POST /api/billing/cancel', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(401);
});
it('rejects a cross-origin request', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const response = await callRoute(
cancelRoute,
apiRequest('/api/billing/cancel', {
method: 'POST',
headers: { origin: 'https://evil.test' },
body: {},
})
);
expect(response.status).toBe(403);
});
it('refuses when there is no active subscription to cancel', async () => {
const trialUser = await createUser();
signedInAs(trialUser);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(409);
expect(update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it('refuses a second cancellation of a subscription already set to end', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(409);
expect(update).not.toHaveBeenCalled();
});
it('rejects an unknown reason without touching Stripe', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'RAGE_QUIT' }));
expect(response.status).toBe(400);
expect(await readError(response)).toMatch(/reason/i);
expect(update).not.toHaveBeenCalled();
});
it('rejects a note longer than the column allows', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'x'.repeat(501) })
);
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
// The whole point: one Stripe write with the answer attached, one row that
// keeps the answer on our side, and the user row updated before any webhook.
it('schedules the cancellation, records the reason and syncs the user row', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const periodEnd = unix(12 * DAY);
const update = stubStripeUpdate({ customer: user.stripeCustomerId, periodEnd });
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload for 16x9 and 9x16. ' })
);
expect(response.status).toBe(200);
const data = await readData<{ cancelAtPeriodEnd: boolean; periodEnd: string | null }>(response);
expect(data.cancelAtPeriodEnd).toBe(true);
expect(data.periodEnd).toBe(new Date(periodEnd * 1000).toISOString());
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'missing_features' },
});
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
stripeSubscriptionId: user.stripeSubscriptionId,
reason: 'MISSING_FEATURE',
note: 'Bulk upload for 16x9 and 9x16.',
});
expect(rows[0].periodEnd?.getTime()).toBe(periodEnd * 1000);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
});
// Skipping the question is allowed and must still cancel. The row is kept
// with no reason so the admin tally can count how often the question is
// skipped rather than pretending those cancellations never happened.
it('cancels with no reason given and records the skip', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({}));
expect(response.status).toBe(200);
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: {},
});
const row = await db.subscriptionCancellation.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.reason).toBeNull();
expect(row.note).toBeNull();
});
// A note under an answer that does not ask for one is still accepted by the
// API; the dialog is what hides the box, and the route must not depend on it.
it('keeps a note on any reason and drops an empty one', async () => {
const user = await createSubscribedUser();
signedInAs(user);
stubStripeUpdate();
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PROJECT_ENDED', note: ' ' })
);
expect(response.status).toBe(200);
const row = await db.subscriptionCancellation.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.reason).toBe('PROJECT_ENDED');
expect(row.note).toBeNull();
});
it('rejects a note that is not text', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate();
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER', note: 42 }));
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
// Two requests racing for the same subscription must produce one Stripe
// write and one reason row, or the admin tally counts a churn twice.
it('lets only one of two concurrent requests through', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const update = stubStripeUpdate({ customer: user.stripeCustomerId });
const [first, second] = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect([first.status, second.status].sort()).toEqual([200, 409]);
expect(update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
});
// The reason row and the local flag must survive a sync that blows up: the
// webhook rewrites the same state later, the answer would be gone for good.
it('keeps the cancellation and the reason when the local sync fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
// No `items` at all: the sync reads `items.data` and throws.
update: vi.fn(async (id: string) => ({ id, customer: user.stripeCustomerId })),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }));
expect(response.status).toBe(200);
const row = await db.subscriptionCancellation.findFirstOrThrow({ where: { userId: user.id } });
expect(row.reason).toBe('PRICE_OR_BILLING');
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
});
// A subscription Stripe no longer knows is the customer's state, not a
// server fault: a 409 with a pointer to the portal, and the claim handed back
// so the button still works once the webhook catches up.
it('answers 409 and releases the claim when Stripe rejects the subscription id', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update: vi.fn(async () => {
throw Object.assign(new Error('No such subscription'), {
type: 'StripeInvalidRequestError',
});
}),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(409);
expect(await readError(response)).toMatch(/Manage Subscription/);
expect(await db.subscriptionCancellation.count()).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(false);
});
it('does not record a reason and releases the claim when Stripe is down', async () => {
const user = await createSubscribedUser();
signedInAs(user);
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update: vi.fn(async () => {
throw new Error('No such subscription');
}),
list: vi.fn(async () => ({ data: [] })),
},
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(500);
expect(await db.subscriptionCancellation.count()).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(false);
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
function renderDialog(
overrides: { periodEnd?: string | null; isTrial?: boolean; confirmResult?: boolean } = {}
) {
const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
const onOpenChange = vi.fn();
render(
<CancelSubscriptionDialog
open
onOpenChange={onOpenChange}
periodEnd={
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
}
isTrial={overrides.isTrial ?? false}
onConfirm={onConfirm}
/>
);
return { onConfirm, onOpenChange };
}
describe('CancelSubscriptionDialog', () => {
it('lists every answer with none selected', () => {
renderDialog();
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(5);
for (const radio of radios) {
expect(radio).toHaveAttribute('aria-checked', 'false');
}
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
// The question is skippable: the destructive button works with nothing
// chosen, and the handler receives an explicit null rather than a default.
it('cancels with no reason when the question is skipped', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({ reason: null, note: null });
});
it('opens the note box only under the answers that ask for detail', async () => {
renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'I am not using it enough' }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
expect(screen.getByRole('textbox')).toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', { name: 'It is missing something I need' }));
expect(screen.getByLabelText(/What was missing\?/)).toBeInTheDocument();
});
it('sends the chosen reason with a trimmed note', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), ' Moved the client to Frame.io ');
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({
reason: 'OTHER',
note: 'Moved the client to Frame.io',
});
});
// A note typed under "Something else" must not travel with an answer that
// never showed the box, or the admin reads a comment about nothing.
it('drops the note when the answer changes to one without a note box', async () => {
const { onConfirm } = renderDialog();
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), 'Some detail');
await userEvent.click(screen.getByRole('radio', { name: 'The project or client work ended' }));
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledWith({ reason: 'PROJECT_ENDED', note: null });
});
// A failed request must not cost the customer the answer they typed.
it('keeps the answer on screen when the confirmation fails', async () => {
const { onConfirm } = renderDialog({ confirmResult: false });
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
await userEvent.type(screen.getByRole('textbox'), 'Kept this');
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(screen.getByRole('radio', { name: 'Something else' })).toHaveAttribute(
'aria-checked',
'true'
);
expect(screen.getByRole('textbox')).toHaveValue('Kept this');
});
it('closes without confirming from the keep button', async () => {
const { onConfirm, onOpenChange } = renderDialog();
await userEvent.click(screen.getByRole('button', { name: 'Keep subscription' }));
expect(onConfirm).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('names the trial instead of the subscription while still trialing', () => {
renderDialog({ isTrial: true });
expect(screen.getByRole('heading', { name: 'Cancel your trial?' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel trial' })).toBeInTheDocument();
});
});
+6 -1
View File
@@ -127,7 +127,12 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
...actual,
getStripe: vi.fn(() => ({
customers: { create: vi.fn(async () => ({ id: 'cus_test_default' })) },
subscriptions: { list: vi.fn(async () => ({ data: [] })) },
subscriptions: {
list: vi.fn(async () => ({ data: [] })),
update: vi.fn(() => {
throw new Error('stripe.subscriptions.update was not stubbed for this test');
}),
},
checkout: {
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/checkout' })) },
},
+1
View File
@@ -70,6 +70,7 @@ const REVIEWED_MIGRATIONS = [
'20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
'20260908120000_add_subscription_cancellations',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */