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:
2026-09-08 15:49:37 +03:00
25 changed files with 3097 additions and 933 deletions
+679
View File
@@ -0,0 +1,679 @@
import { describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus, type User } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
import { GET as billingRoute } from '@/app/api/billing/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { createSubscribedUser, createUser } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const DAY = 24 * 60 * 60;
const unix = (offsetSeconds: number) => Math.floor(Date.now() / 1000) + offsetSeconds;
function cancelRequest(body: unknown = {}) {
return apiRequest('/api/billing/cancel', {
method: 'POST',
headers: ORIGIN_HEADERS,
body,
});
}
function subscription(user: User, overrides: Partial<Stripe.Subscription> = {}) {
return {
id: user.stripeSubscriptionId ?? 'sub_unmirrored',
customer: user.stripeCustomerId,
status: 'active',
created: unix(-30 * DAY),
cancel_at_period_end: user.stripeCancelAtPeriodEnd,
cancel_at: null,
trial_end: null,
latest_invoice: 'in_renewal',
items: {
data: [
{
id: 'si_plan',
price: { id: ENTITLED_PRICE_ID },
current_period_start: unix(-10 * DAY),
current_period_end: unix(20 * DAY),
},
],
},
...overrides,
} as Stripe.Subscription;
}
function renewal(sub: Stripe.Subscription, overrides: Partial<Stripe.Invoice> = {}) {
return {
id: 'in_renewal',
customer: sub.customer,
status: 'open',
auto_advance: true,
amount_paid: 0,
billing_reason: 'subscription_cycle',
period_start: sub.items.data[0].current_period_start,
period_end: sub.items.data[0].current_period_end,
parent: { type: 'subscription_details', subscription_details: { subscription: sub.id } },
lines: {
has_more: false,
data: [
{
id: 'il_renewal',
amount: 2000,
period: {
start: sub.items.data[0].current_period_start,
end: sub.items.data[0].current_period_end,
},
parent: {
type: 'subscription_item_details',
subscription_item_details: {
subscription: sub.id,
subscription_item: 'si_plan',
proration: false,
},
},
pricing: { type: 'price_details', price_details: { price: ENTITLED_PRICE_ID } },
},
],
},
...overrides,
} as Stripe.Invoice;
}
// Only Stripe is replaced. Selection, invoice eligibility, reason persistence,
// paid claims and customer-wide sync use their real implementation and test DB.
function stubStripe(initial: Stripe.Subscription[], invoices: Stripe.Invoice[] = []) {
const subscriptions = initial.map((sub) => structuredClone(sub));
const replace = (id: string, patch: Partial<Stripe.Subscription>) => {
const index = subscriptions.findIndex((sub) => sub.id === id);
if (index < 0) throw new Error(`Unknown fixture subscription ${id}`);
subscriptions[index] = { ...subscriptions[index], ...patch };
return structuredClone(subscriptions[index]);
};
const update = vi.fn(async (id: string, params: Stripe.SubscriptionUpdateParams) =>
replace(id, { cancel_at_period_end: params.cancel_at_period_end })
);
const cancel = vi.fn(async (id: string, params: Stripe.SubscriptionCancelParams) => {
void params;
return replace(id, {
status: 'canceled',
cancel_at_period_end: false,
canceled_at: unix(0),
ended_at: unix(0),
});
});
const list = vi.fn(async (params: Stripe.SubscriptionListParams) => ({
data: structuredClone(subscriptions.filter((sub) => sub.customer === params.customer)),
has_more: false,
}));
const sessions: Stripe.Checkout.Session[] = [];
const sessionList = vi.fn(async (params: Stripe.Checkout.SessionListParams) => ({
data: sessions.filter(
(session) => session.status === 'open' && session.customer === params.customer
),
has_more: false,
}));
const expire = vi.fn(async (id: string) => {
const session = sessions.find((item) => item.id === id)!;
session.status = 'expired';
const subId =
typeof session.subscription === 'string' ? session.subscription : session.subscription!.id;
replace(subId, { status: 'incomplete_expired' });
return session;
});
const voidInvoice = vi.fn(async (id: string) => {
const invoice = invoices.find((item) => item.id === id)!;
invoice.status = 'void';
return invoice;
});
const invoiceUpdate = vi.fn(async (id: string, params: Stripe.InvoiceUpdateParams) => {
const invoice = invoices.find((item) => item.id === id)!;
invoice.auto_advance = params.auto_advance ?? invoice.auto_advance;
return invoice;
});
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
subscriptions: {
update,
cancel,
list,
retrieve: vi.fn(async (id: string) =>
structuredClone(subscriptions.find((sub) => sub.id === id))
),
},
checkout: { sessions: { list: sessionList, expire } },
invoices: {
list: vi.fn(async (params: Stripe.InvoiceListParams) => ({
data: invoices.filter(
(invoice) => invoice.customer === params.customer && invoice.status === 'open'
),
has_more: false,
})),
listLineItems: vi.fn(async (id: string) => invoices.find((item) => item.id === id)!.lines),
voidInvoice,
update: invoiceUpdate,
},
});
return { update, cancel, list, sessionList, expire, sessions, voidInvoice, invoiceUpdate };
}
describe('POST /api/billing/cancel', () => {
it('returns 401 without a session and leaves subscription and reasons untouched', async () => {
const user = await createSubscribedUser();
const stripe = stubStripe([subscription(user)]);
signedOut();
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(401);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects a cross-origin request without changing billing state', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const response = await callRoute(
cancelRoute,
apiRequest('/api/billing/cancel', {
method: 'POST',
headers: { origin: 'https://evil.test' },
body: {},
})
);
expect(response.status).toBe(403);
expect(stripe.update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('refuses an account with no Stripe subscription', async () => {
const user = await createUser();
signedInAs(user);
const stripe = stubStripe([]);
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it('does not cancel another customer subscription referenced by a stale local mirror or request body', async () => {
const owner = await createSubscribedUser();
const caller = await createSubscribedUser({ stripeSubscriptionId: 'sub_foreign_stale' });
signedInAs(caller);
const stripe = stubStripe([subscription(owner, { id: 'sub_foreign_stale' })]);
const response = await callRoute(
cancelRoute,
cancelRequest({
reason: 'OTHER',
customerId: owner.stripeCustomerId,
subscriptionId: owner.stripeSubscriptionId,
})
);
expect(response.status).toBe(409);
expect(stripe.list).toHaveBeenCalledWith(
expect.objectContaining({ customer: caller.stripeCustomerId })
);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects a candidate whose Stripe customer does not match the signed-in account', async () => {
const user = await createSubscribedUser();
const owner = await createSubscribedUser();
signedInAs(user);
const foreign = subscription(owner);
const stripe = stubStripe([foreign]);
stripe.list.mockResolvedValueOnce({ data: [foreign], has_more: false });
expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('rejects an already scheduled paid subscription without a reason write', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it.each([
{ reason: 'RAGE_QUIT' },
{ reason: 'OTHER', note: 'x'.repeat(501) },
{ reason: 'OTHER', note: 42 },
])('rejects malformed cancellation input %# before Stripe writes', async (body) => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(400);
expect(stripe.update).not.toHaveBeenCalled();
expect(stripe.cancel).not.toHaveBeenCalled();
expect(await db.subscriptionCancellation.count()).toBe(0);
});
it.each(['active', 'trialing'] as const)(
'schedules %s, persists the trimmed reason and syncs the user',
async (status) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status });
const stripe = stubStripe([original]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload. ' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
cancelAtPeriodEnd: true,
canceledImmediately: false,
voidedInvoices: [],
status,
periodEnd: new Date(original.items.data[0].current_period_end * 1000).toISOString(),
});
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(user.stripeSubscriptionId, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'missing_features' },
});
expect(stripe.cancel).not.toHaveBeenCalled();
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
stripeSubscriptionId: original.id,
reason: 'MISSING_FEATURE',
note: 'Bulk upload.',
});
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect(after.subscriptionStatus).toBe(
status === 'active' ? BillingSubscriptionStatus.ACTIVE : BillingSubscriptionStatus.TRIALING
);
expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(
original.items.data[0].current_period_end * 1000
);
}
);
it('finds an unscheduled paid subscription behind an already scheduled authoritative one', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const scheduled = subscription(user);
const other = subscription(user, {
id: 'sub_other_paid',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([scheduled, other]);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: false,
});
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PROJECT_ENDED' }));
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(
other.id,
expect.objectContaining({ cancel_at_period_end: true })
);
expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
other.id
);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
scheduled.id
);
});
it.each(['past_due', 'unpaid', 'incomplete'] as const)(
'cancels %s immediately, stops collection and records the reason',
async (status) => {
const user = await createSubscribedUser({
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
});
signedInAs(user);
const original = subscription(user, { status });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PRICE_OR_BILLING', note: ' Stop billing. ' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
cancelAtPeriodEnd: false,
voidedInvoices: [invoice.id],
status: 'canceled',
});
expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(original.id, {
cancellation_details: { feedback: 'too_expensive' },
});
expect(stripe.update).not.toHaveBeenCalled();
expect(invoice.status).toBe('void');
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'PRICE_OR_BILLING',
note: 'Stop billing.',
stripeSubscriptionId: original.id,
});
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.CANCELED);
expect(after.stripeCancelAtPeriodEnd).toBe(false);
}
);
it('uses the original period to void the renewal when cancellation shortens the response period', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'past_due' });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
const cancel = stripe.cancel.getMockImplementation()!;
stripe.cancel.mockImplementationOnce(async (id, params) => ({
...(await cancel(id, params)),
items: {
...original.items,
data: original.items.data.map((item) => ({ ...item, current_period_end: unix(0) })),
},
}));
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({ voidedInvoices: [invoice.id] });
expect(invoice.status).toBe('void');
});
it.each(['past_due', 'unpaid'] as const)(
'offers cancellation for already scheduled %s and preserves another paid subscription',
async (status) => {
const user = await createSubscribedUser({
stripeCancelAtPeriodEnd: true,
subscriptionStatus:
status === 'past_due'
? BillingSubscriptionStatus.PAST_DUE
: BillingSubscriptionStatus.UNPAID,
});
signedInAs(user);
const unpaid = subscription(user, { status });
const paid = subscription(user, { id: 'sub_still_paid', cancel_at_period_end: true });
const stripe = stubStripe([unpaid, paid]);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: true,
});
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(
200
);
expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(unpaid.id, expect.anything());
expect(stripe.update).not.toHaveBeenCalled();
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
expect(after.stripeSubscriptionId).toBe(paid.id);
expect(after.stripeCancelAtPeriodEnd).toBe(true);
expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
unpaid.id
);
}
);
it('expires only the incomplete subscription matching an owned open Checkout session', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'incomplete' });
const other = subscription(user, { id: 'sub_other_checkout', status: 'incomplete' });
const stripe = stubStripe([original, other]);
stripe.sessions.push(
{
id: 'cs_other',
customer: user.stripeCustomerId,
subscription: other.id,
status: 'open',
} as Stripe.Checkout.Session,
{
id: 'cs_match',
customer: user.stripeCustomerId,
subscription: { id: original.id },
status: 'open',
} as Stripe.Checkout.Session
);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Checkout abandoned' })
);
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
status: 'incomplete_expired',
});
expect(stripe.sessionList).toHaveBeenCalledWith(
expect.objectContaining({ customer: user.stripeCustomerId, status: 'open' })
);
expect(stripe.expire).toHaveBeenCalledExactlyOnceWith('cs_match');
expect(stripe.cancel).not.toHaveBeenCalled();
expect(stripe.sessions[0].status).toBe('open');
expect((await db.subscriptionCancellation.findFirstOrThrow()).note).toBe('Checkout abandoned');
});
it.each(['past_due', 'incomplete'] as const)(
'retries failed %s cleanup without recanceling or overwriting its reason',
async (status) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status });
const invoice = renewal(original);
const stripe = stubStripe([original], [invoice]);
if (status === 'incomplete') {
stripe.sessions.push({
id: 'cs_retry',
customer: user.stripeCustomerId,
subscription: original.id,
status: 'open',
} as Stripe.Checkout.Session);
}
stripe.voidInvoice.mockRejectedValueOnce(new Error('Invoice cleanup unavailable'));
const first = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Keep my answer' })
);
expect(first.status).toBe(500);
expect(invoice.status).toBe('open');
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
status === 'incomplete'
? BillingSubscriptionStatus.INCOMPLETE_EXPIRED
: BillingSubscriptionStatus.CANCELED
);
const overview = await billingRoute();
expect(overview.status).toBe(200);
expect(await readData(overview)).toMatchObject({
cancelAvailable: true,
cancelIsImmediate: true,
});
const second = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(second.status).toBe(200);
expect(await readData(second)).toMatchObject({
canceledImmediately: true,
voidedInvoices: [invoice.id],
});
expect(stripe.cancel).toHaveBeenCalledTimes(status === 'incomplete' ? 0 : 1);
expect(stripe.expire).toHaveBeenCalledTimes(status === 'incomplete' ? 1 : 0);
expect(stripe.voidInvoice).toHaveBeenCalledTimes(2);
expect(invoice.status).toBe('void');
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ reason: 'OTHER', note: 'Keep my answer' });
}
);
it('stops retrying a retained receivable without voiding it', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'unpaid' });
const invoice = renewal(original, { billing_reason: 'manual' });
const stripe = stubStripe([original], [invoice]);
const response = await callRoute(cancelRoute, cancelRequest());
expect(response.status).toBe(200);
expect(await readData(response)).toMatchObject({
canceledImmediately: true,
voidedInvoices: [],
});
expect(stripe.voidInvoice).not.toHaveBeenCalled();
expect(stripe.invoiceUpdate).toHaveBeenCalledWith(
invoice.id,
expect.objectContaining({ auto_advance: false })
);
expect(invoice.status).toBe('open');
expect(invoice.auto_advance).toBe(false);
});
it.each([{}, { reason: 'PROJECT_ENDED', note: ' ' }])(
'allows skipping feedback and trims empty notes %#',
async (body) => {
const user = await createSubscribedUser();
signedInAs(user);
stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(200);
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'reason' in body ? body.reason : null,
note: null,
});
}
);
it('lets only one of two concurrent paid requests through', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const results = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect(results.map((response) => response.status).sort()).toEqual([200, 409]);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
});
it('keeps the cancellation and reason when customer-wide sync fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
stripe.list
.mockResolvedValueOnce({ data: [original], has_more: false })
.mockRejectedValueOnce(new Error('Sync unavailable'));
expect(
(await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }))).status
).toBe(200);
expect((await db.subscriptionCancellation.findFirstOrThrow()).reason).toBe('PRICE_OR_BILLING');
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(true);
});
it.each([true, false])(
'releases the paid claim when Stripe rejects the update (invalid request: %s)',
async (invalidRequest) => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
const error = invalidRequest
? Object.assign(new Error('No such subscription'), { type: 'StripeInvalidRequestError' })
: new Error('Stripe unavailable');
stripe.update.mockRejectedValueOnce(error);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
expect(response.status).toBe(invalidRequest ? 409 : 500);
if (invalidRequest) expect(await readError(response)).toMatch(/Manage Subscription/);
expect(await db.subscriptionCancellation.count()).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
}
);
});
describe('repeated and concurrent cancellation reasons', () => {
it('records a new immediate cancellation after an earlier scheduled cancellation was resumed', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user, { status: 'past_due' });
const stripe = stubStripe([original]);
await db.subscriptionCancellation.create({
data: {
userId: user.id,
stripeSubscriptionId: original.id,
reason: 'PRICE_OR_BILLING',
note: 'Previous canceled cycle',
createdAt: new Date(Date.now() - 40 * DAY * 1000),
periodEnd: new Date(Date.now() - 30 * DAY * 1000),
},
});
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'PROJECT_ENDED', note: 'Current cancellation' })
);
expect(response.status).toBe(200);
expect(stripe.cancel).toHaveBeenCalledTimes(1);
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
expect(rows).toHaveLength(2);
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }),
])
);
});
it('records only one reason when concurrent requests cancel a nonmirrored paid subscription', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const scheduled = subscription(user);
const other = subscription(user, {
id: 'sub_other_paid',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([scheduled, other]);
const update = stripe.update.getMockImplementation()!;
let entered = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const timeout = setTimeout(release, 1000);
stripe.update.mockImplementation(async (id, params) => {
entered += 1;
if (entered === 2) release();
await barrier;
return update(id, params);
});
try {
const responses = await Promise.all([
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
]);
expect(entered).toBe(2);
expect(responses.map((response) => response.status)).toEqual([200, 200]);
expect(
await db.subscriptionCancellation.count({
where: { userId: user.id, stripeSubscriptionId: other.id },
})
).toBe(1);
} finally {
clearTimeout(timeout);
}
});
});
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import {
buildBillingAccessWhereInput,
buildExpiredBillingWhereInput,
getBillingAccessEndDate,
getStorageCleanupEligibleAt,
hasBillingAccess,
isPaidTier,
startCardlessTrial,
syncStripeCustomerSubscriptions,
} from '@/lib/billing';
import { getStripe } from '@/lib/stripe';
import { db } from '../helpers/db';
import { createUser } from '../factories';
// Uses the API project's real database and reset hooks. Run only when no other API suite uses it.
const CUSTOMER_ID = 'cus_entitlement_regression';
const SUBSCRIPTION_ID = 'sub_entitlement_regression';
const PRICE_ID = 'price_entitlement_regression';
const TRIAL_START = new Date('2026-10-01T00:00:00.000Z');
const TRIAL_END = new Date('2026-10-08T00:00:00.000Z');
const CANCELED_AT = new Date('2026-10-02T00:00:00.000Z');
const REPORTED_PERIOD_END = new Date('2026-11-01T00:00:00.000Z');
function subscription(overrides: Partial<Stripe.Subscription> = {}): Stripe.Subscription {
return {
id: SUBSCRIPTION_ID,
customer: CUSTOMER_ID,
status: 'canceled',
created: Date.parse('2026-09-01T00:00:00.000Z') / 1000,
trial_end: null,
ended_at: CANCELED_AT.getTime() / 1000,
canceled_at: CANCELED_AT.getTime() / 1000,
cancel_at: null,
cancel_at_period_end: false,
// Deliberately no top-level period: the regression depends on the item-only payload.
items: {
data: [
{
price: { id: PRICE_ID },
current_period_start: TRIAL_START.getTime() / 1000,
current_period_end: REPORTED_PERIOD_END.getTime() / 1000,
},
],
},
...overrides,
} as Stripe.Subscription;
}
function stubSubscription(value: Stripe.Subscription) {
const list = vi.fn(async () => ({ data: [value] }));
vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
return list;
}
async function startDeferredTrial() {
const user = await createUser({
subscriptionStatus: 'PAST_DUE',
stripeCustomerId: CUSTOMER_ID,
stripeSubscriptionId: SUBSCRIPTION_ID,
stripePriceId: PRICE_ID,
stripeCurrentPeriodEnd: REPORTED_PERIOD_END,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
expect(await startCardlessTrial(user.id, TRIAL_START)).toBe(true);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
return user.id;
}
async function matchingAccessUsers(userId: string, now: Date) {
return db.user.findMany({
where: { AND: [{ id: userId }, buildBillingAccessWhereInput(now)] },
select: { id: true },
});
}
async function matchingCleanupUsers(userId: string, now: Date) {
return db.user.findMany({
where: { AND: [{ id: userId }, buildExpiredBillingWhereInput(now)] },
select: { id: true },
});
}
describe('billing entitlement and retention after subscription sync', () => {
beforeEach(() => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', PRICE_ID);
// Mock only Date so PostgreSQL sockets and query timers keep running normally.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(TRIAL_START);
});
afterEach(() => {
vi.useRealTimers();
});
// Catches restoring `hasAccess || hasActiveTrial(preservedTrialEnd)` when writing the cutoff.
it('preserves a deferred trial without granting paid access to the canceled unpaid period', async () => {
const userId = await startDeferredTrial();
const list = stubSubscription(subscription());
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
expect(list).toHaveBeenCalledWith({ customer: CUSTOMER_ID, status: 'all', limit: 100 });
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.subscriptionStatus).toBe('CANCELED');
expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
await Promise.all(
[
{ now: CANCELED_AT, expected: true },
{ now: new Date('2026-10-07T23:59:59.999Z'), expected: true },
{ now: TRIAL_END, expected: false },
{ now: new Date('2026-10-09T00:00:00.000Z'), expected: false },
].map(async ({ now, expected }) => {
expect(isPaidTier(stored, now)).toBe(false);
expect(hasBillingAccess(stored, now)).toBe(expected);
expect(await matchingAccessUsers(userId, now)).toEqual(expected ? [{ id: userId }] : []);
})
);
});
// Catches choosing the raw unpaid period, choosing the earlier expiry, or requiring that raw period to lapse in SQL.
it.each([
{
label: 'trial outlasts the subscription',
subscriptionEnd: CANCELED_AT,
lastEntitlementEnd: TRIAL_END,
cleanupAt: new Date('2026-10-23T00:00:00.000Z'),
},
{
label: 'subscription outlasts the trial',
subscriptionEnd: new Date('2026-10-12T00:00:00.000Z'),
lastEntitlementEnd: new Date('2026-10-12T00:00:00.000Z'),
cleanupAt: new Date('2026-10-27T00:00:00.000Z'),
},
])('retains storage until the last legitimate expiry plus 15 days: $label', async (scenario) => {
const userId = await startDeferredTrial();
stubSubscription(
subscription({
ended_at: scenario.subscriptionEnd.getTime() / 1000,
canceled_at: scenario.subscriptionEnd.getTime() / 1000,
})
);
vi.setSystemTime(scenario.subscriptionEnd);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.billingAccessEndedAt).toEqual(scenario.subscriptionEnd);
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
expect(getBillingAccessEndDate(stored)).toEqual(scenario.lastEntitlementEnd);
expect(getStorageCleanupEligibleAt(stored)).toEqual(scenario.cleanupAt);
expect(hasBillingAccess(stored, scenario.cleanupAt)).toBe(false);
const [before, at] = await Promise.all([
matchingCleanupUsers(userId, new Date(scenario.cleanupAt.getTime() - 1)),
matchingCleanupUsers(userId, scenario.cleanupAt),
]);
expect(before).toEqual([]);
expect(at).toEqual([{ id: userId }]);
});
// Catches replacing persisted trial history with keepUnexpiredTrial on a terminal resync.
it('keeps expired trial history and the retention deadline across repeated terminal syncs', async () => {
const userId = await startDeferredTrial();
stubSubscription(subscription());
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
for (const now of ['2026-10-09T00:00:00.000Z', '2026-10-20T00:00:00.000Z']) {
vi.setSystemTime(new Date(now));
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
expect(isPaidTier(stored)).toBe(false);
expect(hasBillingAccess(stored)).toBe(false);
expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-10-23T00:00:00.000Z'));
expect(await matchingCleanupUsers(userId, new Date(now))).toEqual([]);
}
expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([
{ id: userId },
]);
});
// Catches treating scheduled cancellation as immediate termination of a paid subscription.
it('keeps a paid scheduled cancellation accessible after the cardless trial expires', async () => {
const userId = await startDeferredTrial();
stubSubscription(
subscription({
status: 'active',
ended_at: null,
cancel_at_period_end: true,
cancel_at: REPORTED_PERIOD_END.getTime() / 1000,
})
);
vi.setSystemTime(CANCELED_AT);
await syncStripeCustomerSubscriptions(CUSTOMER_ID);
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
const afterTrial = new Date('2026-10-09T00:00:00.000Z');
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeCancelAtPeriodEnd).toBe(true);
expect(stored.billingAccessEndedAt).toBeNull();
expect(stored.trialEndsAt).toEqual(TRIAL_END);
expect(isPaidTier(stored, afterTrial)).toBe(true);
expect(hasBillingAccess(stored, afterTrial)).toBe(true);
expect(await matchingAccessUsers(userId, afterTrial)).toEqual([{ id: userId }]);
expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([]);
expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-11-16T00:00:00.000Z'));
});
});