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,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
@@ -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' })) },
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user