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:
+21
-4
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user