mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
118 lines
4.6 KiB
TypeScript
118 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|