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.
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
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 };
|
|
}
|