mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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.
44 lines
1.7 KiB
TypeScript
44 lines
1.7 KiB
TypeScript
// 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;
|
|
}
|