mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
This commit is contained in:
+159
-71
@@ -4,7 +4,6 @@ import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
|
||||
@@ -62,8 +61,8 @@ const UNPAID_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
|
||||
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
|
||||
const RETRYING_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>(['past_due', 'unpaid']);
|
||||
|
||||
// Roughly Stripe's default Smart Retries window. Access follows the retry window rather
|
||||
// than the period Stripe advanced when it issued the invoice that was never paid.
|
||||
// Application grace period, independent of the Stripe retry settings. An unpaid
|
||||
// invoice's future period end does not extend this access window.
|
||||
const UNPAID_ACCESS_GRACE_DAYS = 14;
|
||||
|
||||
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
||||
@@ -146,7 +145,7 @@ export function isPaidTier(
|
||||
}
|
||||
|
||||
// Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
|
||||
// behind on payment. They did once: access stopped at the end of Stripe's retry window
|
||||
// behind on payment. They did once: access stopped at the end of the payment grace window
|
||||
// while this kept saying "paid" for the rest of the period, which left the account with
|
||||
// no banner explaining the lockout and able to create workspaces it could not then see.
|
||||
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
|
||||
@@ -186,7 +185,7 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
|
||||
// Stripe advances the period the moment it issues the renewal invoice, paid or not, and
|
||||
// the period survives cancellation, so on its own it would hand a full free month to
|
||||
// anyone whose renewal fails. This is the bound: a subscription behind on payment is
|
||||
// stamped with the end of Stripe's retry window, a cancelled one with `ended_at`.
|
||||
// stamped with the end of the payment grace window, a cancelled one with `ended_at`.
|
||||
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
@@ -197,15 +196,16 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
|
||||
}
|
||||
|
||||
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
|
||||
if (subject.billingAccessEndedAt) {
|
||||
return subject.billingAccessEndedAt;
|
||||
}
|
||||
|
||||
if (subject.stripeCurrentPeriodEnd) {
|
||||
return subject.stripeCurrentPeriodEnd;
|
||||
}
|
||||
|
||||
return subject.trialEndsAt;
|
||||
const subscriptionEnd =
|
||||
subject.billingAccessEndedAt ??
|
||||
(UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
|
||||
? null
|
||||
: subject.stripeCurrentPeriodEnd);
|
||||
// A trial grants access independently of the subscription cutoff. Retention starts
|
||||
// after the last legitimate entitlement, never from an unpaid invoice's period.
|
||||
if (!subscriptionEnd) return subject.trialEndsAt;
|
||||
if (!subject.trialEndsAt) return subscriptionEnd;
|
||||
return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
|
||||
}
|
||||
|
||||
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
|
||||
@@ -251,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
|
||||
// Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
|
||||
// Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
|
||||
// "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
|
||||
// false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
|
||||
// NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
|
||||
// looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
|
||||
// reports current_period_end on the subscription), so the cleanup silently matched nobody.
|
||||
// Match the same last entitlement date as getBillingAccessEndDate, with explicit
|
||||
// null branches because SQL comparisons against null do not evaluate to false.
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
@@ -265,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
|
||||
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
|
||||
},
|
||||
},
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
|
||||
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
|
||||
{
|
||||
OR: [
|
||||
{ billingAccessEndedAt: { lte: cleanupCutoff } },
|
||||
{
|
||||
AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
|
||||
billingAccessEndedAt: null,
|
||||
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
|
||||
stripeCurrentPeriodEnd: { lte: cleanupCutoff },
|
||||
},
|
||||
{
|
||||
billingAccessEndedAt: null,
|
||||
trialEndsAt: { lte: cleanupCutoff },
|
||||
OR: [
|
||||
{ stripeCurrentPeriodEnd: null },
|
||||
{ subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -872,8 +876,8 @@ function getInactiveBillingAccessEndedAt(
|
||||
return new Date(endedAt * 1000);
|
||||
}
|
||||
|
||||
// Still running, just behind on payment: access ends when Stripe gives up retrying, not
|
||||
// at the period end, which Stripe already advanced to cover the unpaid invoice. The
|
||||
// Still running, just behind on payment: bound access to the application grace period,
|
||||
// not the period end Stripe advanced to cover the unpaid invoice. The
|
||||
// period start is when that invoice was issued, so it is what the window runs from; when
|
||||
// it is missing (a paginated item list, an older payload shape) the window runs from now
|
||||
// instead. Falling through to "ended" here would lock out the customer this branch
|
||||
@@ -886,8 +890,8 @@ function getInactiveBillingAccessEndedAt(
|
||||
return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
|
||||
}
|
||||
|
||||
// A pause is not a non-payment: the period behind it was paid for, so it runs out
|
||||
// normally. Stripe's portal pauses keep the status `active`, but the API can set this.
|
||||
// Preserve the existing period-based access policy for paused subscriptions.
|
||||
// The paused status itself is not evidence that this period was paid.
|
||||
if (subscription.status === 'paused' && currentPeriodEnd) {
|
||||
return new Date(currentPeriodEnd * 1000);
|
||||
}
|
||||
@@ -953,7 +957,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
// subscription created after the cardless trial shipped, and this fallback is
|
||||
// what stops an abandoned or failed checkout from erasing the days the account
|
||||
// still had. Legacy card-backed trials keep arriving through the branch above.
|
||||
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
|
||||
const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
|
||||
// The reported period is not proof of payment: Stripe advances it when it issues the
|
||||
// renewal invoice, paid or not, and it survives cancellation. Access therefore follows
|
||||
// the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
|
||||
@@ -974,18 +978,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
hasEntitledPrice && trialEnd
|
||||
? (user.billingTrialConsumedAt ?? new Date())
|
||||
: user.billingTrialConsumedAt,
|
||||
// A live trial means access has not ended, whatever the subscription says.
|
||||
// Stamping an end date here while the trial runs would date the storage
|
||||
// cleanup from today and tell the user their work dies before their trial
|
||||
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
|
||||
// that has already elapsed is a reason to stamp the end date, not to skip it.
|
||||
billingAccessEndedAt:
|
||||
hasAccess || hasActiveTrial(preservedTrialEnd)
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(
|
||||
subscription,
|
||||
hasEntitledPrice ? currentPeriodEnd : null
|
||||
),
|
||||
// Preserve the subscription cutoff even during a trial. The trial has its own
|
||||
// access branch; clearing this cutoff would resurrect an unpaid period later.
|
||||
billingAccessEndedAt: hasAccess
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1099,7 +1096,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
// Losing the subscription does not retract a trial that has not run out. The
|
||||
// account keeps the days it was given and lands back on the trial's own end
|
||||
// date, which is also what the cancellation copy in settings promises.
|
||||
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
|
||||
const preservedTrialEnd = user.trialEndsAt ?? null;
|
||||
|
||||
const updated = await db.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -1111,9 +1108,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
|
||||
stripeCancelAtPeriodEnd: false,
|
||||
stripeCancelAt: null,
|
||||
billingAccessEndedAt: preservedTrialEnd
|
||||
? null
|
||||
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
|
||||
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1181,42 +1176,135 @@ export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
|
||||
return UNPAID_STRIPE_STATUSES.has(subscription.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelling a subscription in Stripe does not stop collection on invoices that were
|
||||
* already issued; they keep retrying on their own until they are paid or voided. Voiding
|
||||
* them is what actually stops the card being charged after someone has cancelled.
|
||||
*
|
||||
* Note this writes off a real receivable, not only an unserved one: a `past_due` customer
|
||||
* has had access for up to `UNPAID_ACCESS_GRACE_DAYS` before they get here. That is a
|
||||
* deliberate trade, on the grounds that chasing a single month of a small subscription
|
||||
* costs more than it recovers and that the customer is leaving anyway. `markUncollectible`
|
||||
* is the one-line change if the receivable should be kept on the books instead.
|
||||
*/
|
||||
export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
|
||||
const stripe = getStripe();
|
||||
const { data: invoices } = await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
|
||||
export function isCurrentSubscriptionInvoice(
|
||||
invoice: Stripe.Invoice,
|
||||
subscription: Stripe.Subscription
|
||||
): boolean {
|
||||
const latestId =
|
||||
typeof subscription.latest_invoice === 'string'
|
||||
? subscription.latest_invoice
|
||||
: subscription.latest_invoice?.id;
|
||||
const start = getSubscriptionPeriodStart(subscription);
|
||||
const end = getSubscriptionPeriodEnd(subscription);
|
||||
if (
|
||||
invoice.id !== latestId ||
|
||||
invoice.status !== 'open' ||
|
||||
invoice.amount_paid !== 0 ||
|
||||
!['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
|
||||
getInvoiceSubscriptionId(invoice) !== subscription.id ||
|
||||
start === null ||
|
||||
end === null ||
|
||||
!invoice.lines ||
|
||||
invoice.lines.has_more ||
|
||||
invoice.lines.data.length === 0
|
||||
)
|
||||
return false;
|
||||
return invoice.lines.data.every((line) => {
|
||||
const details = line.parent?.subscription_item_details;
|
||||
return (
|
||||
line.parent?.type === 'subscription_item_details' &&
|
||||
details?.subscription === subscription.id &&
|
||||
details.proration === false &&
|
||||
line.pricing?.price_details?.price === getStripePriceId() &&
|
||||
line.period.start === start &&
|
||||
line.period.end === end
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
|
||||
const invoices: Stripe.Invoice[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
while (true) {
|
||||
const page = await getStripe().invoices.list({
|
||||
customer: customerId,
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
invoices.push(
|
||||
...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
|
||||
);
|
||||
if (!page.has_more || page.data.length === 0) break;
|
||||
startingAfter = page.data[page.data.length - 1].id;
|
||||
}
|
||||
return invoices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic collection on all open invoices for this subscription. Older or
|
||||
* mixed invoices remain receivables; only a complete current renewal is voided.
|
||||
* Failures propagate so callers can report and retry unfinished cleanup.
|
||||
*/
|
||||
export async function voidOpenSubscriptionInvoices(
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
subscriptionSnapshot?: Stripe.Subscription
|
||||
) {
|
||||
const stripe = getStripe();
|
||||
const subscription =
|
||||
subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
|
||||
const customer =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
if (customer !== customerId) throw new Error('Subscription customer mismatch');
|
||||
const voided: string[] = [];
|
||||
|
||||
for (const invoice of invoices) {
|
||||
if (!invoice.id) continue;
|
||||
if (getInvoiceSubscriptionId(invoice) !== subscriptionId) continue;
|
||||
|
||||
try {
|
||||
for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
|
||||
// Immediate cancellation normally pauses collection too. Explicitly keep retained
|
||||
// receivables paused, including when retrying a partly completed cancellation.
|
||||
if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
|
||||
if (isCurrentSubscriptionInvoice(invoice, subscription)) {
|
||||
await stripe.invoices.voidInvoice(invoice.id);
|
||||
voided.push(invoice.id);
|
||||
} catch (error) {
|
||||
logError(`Failed to void Stripe invoice ${invoice.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return voided;
|
||||
}
|
||||
|
||||
/** Cancellation candidates differ from the subscription granting access. */
|
||||
export async function findCancelableStripeSubscription(customerId: string) {
|
||||
const subscriptions: Stripe.Subscription[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
while (true) {
|
||||
const page = await getStripe().subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
subscriptions.push(...page.data);
|
||||
if (!page.has_more || page.data.length === 0) break;
|
||||
startingAfter = page.data[page.data.length - 1].id;
|
||||
}
|
||||
const candidate = selectAuthoritativeSubscription(
|
||||
subscriptions.filter(
|
||||
(subscription) =>
|
||||
hasEntitledPrice(subscription, getStripePriceId()) &&
|
||||
LIVE_STRIPE_STATUSES.has(subscription.status) &&
|
||||
(isUnpaidStripeSubscription(subscription) ||
|
||||
(!subscription.cancel_at && !subscription.cancel_at_period_end))
|
||||
)
|
||||
);
|
||||
if (candidate) return candidate;
|
||||
// A failed invoice write must remain reachable after Stripe accepted cancellation.
|
||||
for (const subscription of subscriptions) {
|
||||
if (
|
||||
!['canceled', 'incomplete_expired'].includes(subscription.status) ||
|
||||
!hasEntitledPrice(subscription, getStripePriceId())
|
||||
)
|
||||
continue;
|
||||
const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
|
||||
if (
|
||||
invoices.some(
|
||||
(invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
|
||||
)
|
||||
) {
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
|
||||
* is: a customer can carry an open invoice left behind by a subscription they no longer
|
||||
|
||||
@@ -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,239 @@
|
||||
import type Stripe from 'stripe';
|
||||
import type { CancellationReason } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getStripe } from '@/lib/stripe';
|
||||
import {
|
||||
getSubscriptionPeriodEnd,
|
||||
findCancelableStripeSubscription,
|
||||
isUnpaidStripeSubscription,
|
||||
syncStripeCustomerSubscriptions,
|
||||
voidOpenSubscriptionInvoices,
|
||||
} 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;
|
||||
canceledImmediately: boolean;
|
||||
voidedInvoices: string[];
|
||||
status: Stripe.Subscription.Status;
|
||||
cancelAt: 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'
|
||||
);
|
||||
}
|
||||
|
||||
/** Expire every open Checkout session for this incomplete subscription, including later pages. */
|
||||
async function expireSubscriptionCheckout(customerId: string, subscriptionId: string) {
|
||||
const stripe = getStripe();
|
||||
let startingAfter: string | undefined;
|
||||
let expired = false;
|
||||
do {
|
||||
const sessions = await stripe.checkout.sessions.list({
|
||||
customer: customerId,
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
const matching = sessions.data.filter((session) => {
|
||||
const owner = typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
const id =
|
||||
typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
|
||||
return owner === customerId && id === subscriptionId && session.status === 'open';
|
||||
});
|
||||
await Promise.all(matching.map((session) => stripe.checkout.sessions.expire(session.id)));
|
||||
expired ||= matching.length > 0;
|
||||
startingAfter = sessions.has_more ? sessions.data.at(-1)?.id : undefined;
|
||||
} while (startingAfter);
|
||||
return expired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paid subscriptions end at period end; unpaid subscriptions end immediately.
|
||||
* Record the reason before invoice cleanup so a failed cleanup can be retried
|
||||
* on the canceled subscription without losing or duplicating the answer.
|
||||
*/
|
||||
export async function cancelSubscription(params: {
|
||||
userId: string;
|
||||
reason: CancellationReason | null;
|
||||
note: string | null;
|
||||
}): Promise<CancelSubscriptionResult> {
|
||||
const requestStartedAt = new Date();
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
stripeSubscriptionId: true,
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
if (!user?.stripeCustomerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
|
||||
|
||||
const customerId = user.stripeCustomerId;
|
||||
const original = await findCancelableStripeSubscription(customerId);
|
||||
if (!original) return { ok: false, code: 'NO_SUBSCRIPTION' };
|
||||
const owner = typeof original.customer === 'string' ? original.customer : original.customer.id;
|
||||
if (owner !== customerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
|
||||
|
||||
const subscriptionId = original.id;
|
||||
const cleanupRetry = original.status === 'canceled' || original.status === 'incomplete_expired';
|
||||
const canceledImmediately = cleanupRetry || isUnpaidStripeSubscription(original);
|
||||
if (!canceledImmediately && original.status !== 'active' && original.status !== 'trialing') {
|
||||
return { ok: false, code: 'NO_SUBSCRIPTION' };
|
||||
}
|
||||
if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) {
|
||||
return { ok: false, code: 'ALREADY_CANCELING' };
|
||||
}
|
||||
|
||||
// Retain the paid mirror's conditional claim for double-clicks. It cannot
|
||||
// guard an unpaid cancellation, cleanup retry, or a different subscription.
|
||||
const claimPaidMirror = !canceledImmediately && user.stripeSubscriptionId === subscriptionId;
|
||||
if (claimPaidMirror) {
|
||||
const claimed = await db.user.updateMany({
|
||||
where: {
|
||||
id: params.userId,
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripeCancelAtPeriodEnd: false,
|
||||
},
|
||||
data: { stripeCancelAtPeriodEnd: true },
|
||||
});
|
||||
if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' };
|
||||
}
|
||||
|
||||
let subscription = original;
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const cancellationDetails = params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {};
|
||||
if (!cleanupRetry) {
|
||||
if (!canceledImmediately) {
|
||||
subscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
cancellation_details: cancellationDetails,
|
||||
});
|
||||
} else if (
|
||||
original.status === 'incomplete' &&
|
||||
(await expireSubscriptionCheckout(customerId, subscriptionId))
|
||||
) {
|
||||
// Checkout owns incomplete subscriptions it created. Expiration cancels
|
||||
// them; retrieving gives the response the actual resulting Stripe state.
|
||||
subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
|
||||
throw new Error('Checkout expiration did not end the subscription');
|
||||
}
|
||||
} else {
|
||||
subscription = await stripe.subscriptions.cancel(subscriptionId, {
|
||||
cancellation_details: cancellationDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (claimPaidMirror) {
|
||||
await db.user.updateMany({
|
||||
where: { id: params.userId, stripeSubscriptionId: subscriptionId },
|
||||
data: { stripeCancelAtPeriodEnd: false },
|
||||
});
|
||||
}
|
||||
if (isStripeInvalidRequest(error)) {
|
||||
logError('billing.cancel.rejected', error);
|
||||
return { ok: false, code: 'STRIPE_REJECTED' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const periodEndUnix = getSubscriptionPeriodEnd(original);
|
||||
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
|
||||
await db.$transaction(async (tx) => {
|
||||
// The paid mirror's claim does not cover other subscriptions. Serialize every
|
||||
// reason write and reuse only a row written during this request, so a resumed
|
||||
// subscription can record another cancellation without duplicating concurrent calls.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${subscriptionId}))`;
|
||||
// Cleanup can be retried long after the request that canceled the subscription.
|
||||
// Match that period and, when Stripe reports it, the terminal transition time.
|
||||
// An incomplete expiration may have no ended_at, so its period is the fallback.
|
||||
const existing = await tx.subscriptionCancellation.findFirst({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
...(cleanupRetry
|
||||
? {
|
||||
periodEnd,
|
||||
...(original.ended_at
|
||||
? { createdAt: { gte: new Date(original.ended_at * 1000) } }
|
||||
: {}),
|
||||
}
|
||||
: { createdAt: { gte: requestStartedAt } }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!existing) {
|
||||
await tx.subscriptionCancellation.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
reason: params.reason,
|
||||
note: params.note,
|
||||
periodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let voidedInvoices: string[] = [];
|
||||
try {
|
||||
if (canceledImmediately) {
|
||||
// Eligibility must use the pre-cancellation period, not a shortened one.
|
||||
// Failures propagate; the selector exposes canceled cleanup candidates.
|
||||
voidedInvoices = await voidOpenSubscriptionInvoices(customerId, subscriptionId, original);
|
||||
}
|
||||
} finally {
|
||||
// Reconcile the whole customer even if cleanup failed. Another subscription
|
||||
// may still provide access. Webhooks can repair a failed local sync.
|
||||
try {
|
||||
await syncStripeCustomerSubscriptions(customerId);
|
||||
} catch (error) {
|
||||
logError('billing.cancel.sync', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
periodEnd,
|
||||
canceledImmediately,
|
||||
voidedInvoices,
|
||||
status: subscription.status,
|
||||
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
|
||||
};
|
||||
}
|
||||
@@ -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