mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user