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
+409
View File
@@ -0,0 +1,409 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import {
findCancelableStripeSubscription,
isCurrentSubscriptionInvoice,
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
const stripe = vi.hoisted(() => ({
subscriptions: { list: vi.fn(), retrieve: vi.fn() },
invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() },
}));
vi.mock('@/lib/db', () => ({ db: {} }));
vi.mock('@/lib/stripe', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/stripe')>()),
getStripe: () => stripe,
}));
const START = 1_800_000_000;
const END = 1_802_592_000;
function subscription(overrides: Record<string, unknown> = {}): Stripe.Subscription {
return {
id: 'sub_target',
customer: 'cus_target',
status: 'past_due',
created: 100,
latest_invoice: 'in_current',
cancel_at: null,
cancel_at_period_end: false,
items: {
data: [
{
price: { id: 'price_plan' },
current_period_start: START,
current_period_end: END,
},
],
},
...overrides,
} as unknown as Stripe.Subscription;
}
function line(overrides: Record<string, unknown> = {}) {
return {
id: 'il_plan',
amount: 1900,
period: { start: START, end: END },
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_target', proration: false },
},
pricing: { price_details: { price: 'price_plan' } },
...overrides,
};
}
function invoice(overrides: Record<string, unknown> = {}): Stripe.Invoice {
return {
id: 'in_current',
customer: 'cus_target',
status: 'open',
amount_paid: 0,
amount_due: 1900,
billing_reason: 'subscription_cycle',
auto_advance: true,
parent: { subscription_details: { subscription: 'sub_target' } },
lines: { data: [line()], has_more: false },
...overrides,
} as unknown as Stripe.Invoice;
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('STRIPE_PRICE_ID', 'price_plan');
stripe.subscriptions.retrieve.mockResolvedValue(subscription());
stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false });
stripe.invoices.list.mockResolvedValue({ data: [], has_more: false });
stripe.invoices.update.mockResolvedValue({});
stripe.invoices.voidInvoice.mockResolvedValue({});
});
describe('subscription invoice cleanup', () => {
it.each(['subscription_cycle', 'subscription_create'])(
'voids a complete unpaid current %s invoice and returns its id',
async (billingReason) => {
const current = invoice({ billing_reason: billingReason });
stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false });
expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true);
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([
'in_current',
]);
expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target');
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', {
auto_advance: false,
});
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
}
);
const retainedInvoices: [string, () => Stripe.Invoice][] = [
[
'a different start with the current end',
() =>
invoice({
lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false },
}),
],
[
'a different end with the current start',
() =>
invoice({
lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false },
}),
],
['an older invoice id', () => invoice({ id: 'in_old' })],
[
'an older service period',
() =>
invoice({
lines: {
data: [line({ period: { start: START - 2_592_000, end: START } })],
has_more: false,
},
}),
],
[
'a mixed invoice containing a manual charge',
() =>
invoice({
lines: {
data: [
line(),
line({
id: 'il_manual',
parent: { type: 'invoice_item_details', invoice_item_details: {} },
}),
],
has_more: false,
},
}),
],
[
'a proration',
() =>
invoice({
lines: {
data: [
line({
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_target', proration: true },
},
}),
],
has_more: false,
},
}),
],
['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })],
['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })],
[
'a different price',
() =>
invoice({
lines: {
data: [line({ pricing: { price_details: { price: 'price_other' } } })],
has_more: false,
},
}),
],
[
'a line belonging to a different subscription',
() =>
invoice({
lines: {
data: [
line({
parent: {
type: 'subscription_item_details',
subscription_item_details: { subscription: 'sub_other', proration: false },
},
}),
],
has_more: false,
},
}),
],
['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })],
['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })],
];
it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => {
const retained = makeInvoice();
stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false });
expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false);
await expect(
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
).resolves.toEqual([]);
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, {
auto_advance: false,
});
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled();
});
it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => {
stripe.invoices.list
.mockResolvedValueOnce({
data: [
invoice({ id: 'in_old' }),
invoice({
id: 'in_foreign',
parent: { subscription_details: { subscription: 'sub_other' } },
}),
],
has_more: true,
})
.mockResolvedValueOnce({ data: [invoice()], has_more: false });
await expect(
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
).resolves.toEqual(['in_current']);
expect(stripe.invoices.list.mock.calls).toEqual([
[{ customer: 'cus_target', status: 'open', limit: 100 }],
[{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }],
]);
expect(stripe.invoices.update.mock.calls).toEqual([
['in_old', { auto_advance: false }],
['in_current', { auto_advance: false }],
]);
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
});
it('voids a current invoice even when collection was already paused before a retry', async () => {
stripe.invoices.list.mockResolvedValue({
data: [invoice({ auto_advance: false })],
has_more: false,
});
await expect(
voidOpenSubscriptionInvoices(
'cus_target',
'sub_target',
subscription({
status: 'canceled',
latest_invoice: { id: 'in_current' },
})
)
).resolves.toEqual(['in_current']);
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
});
it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)(
'propagates the Stripe %s failure rather than claiming successful cleanup',
async (operation) => {
const failure = new Error(`Stripe ${operation} failed`);
stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false });
const failingCall =
operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation];
failingCall.mockRejectedValueOnce(failure);
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure);
expect(failingCall).toHaveBeenCalledTimes(1);
if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
}
);
it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => {
await expect(
voidOpenSubscriptionInvoices(
'cus_target',
'sub_target',
subscription({
customer: { id: 'cus_other' },
})
)
).rejects.toThrow('Subscription customer mismatch');
expect(stripe.invoices.list).not.toHaveBeenCalled();
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
});
});
describe('cancellation candidate selection', () => {
it.each([
{ cancel_at: END, cancel_at_period_end: false },
{ cancel_at: null, cancel_at_period_end: true },
])(
'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one',
async (schedule) => {
stripe.subscriptions.list
.mockResolvedValueOnce({
data: [
subscription({
id: 'sub_newer',
status: 'active',
created: 200,
...schedule,
}),
],
has_more: true,
})
.mockResolvedValueOnce({
data: [
subscription({
id: 'sub_older',
status: 'active',
created: 100,
}),
],
has_more: false,
});
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older');
expect(stripe.subscriptions.list.mock.calls).toEqual([
[{ customer: 'cus_target', status: 'all', limit: 100 }],
[{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }],
]);
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it.each(['past_due', 'unpaid', 'incomplete'] as const)(
'still selects a scheduled %s subscription for immediate cancellation',
async (status) => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })],
has_more: false,
});
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it.each([
['a current invoice still awaiting void', { auto_advance: false }],
['an older invoice still collecting', { id: 'in_old', auto_advance: true }],
])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false });
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({
customer: 'cus_target',
status: 'open',
limit: 100,
});
expect(stripe.invoices.update).not.toHaveBeenCalled();
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
});
it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => {
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockResolvedValue({
data: [
invoice({ id: 'in_old', auto_advance: false }),
invoice({
id: 'in_foreign',
parent: { subscription_details: { subscription: 'sub_other' } },
}),
],
has_more: false,
});
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
});
it.each(['active', 'canceled'] as const)(
'ignores a %s subscription for a different product',
async (status) => {
stripe.subscriptions.list.mockResolvedValue({
data: [
subscription({
status,
items: { data: [{ price: { id: 'price_other' } }] },
}),
],
has_more: false,
});
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
expect(stripe.invoices.list).not.toHaveBeenCalled();
}
);
it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => {
const failure = new Error('Stripe invoice lookup failed');
stripe.subscriptions.list.mockResolvedValue({
data: [subscription({ status: 'canceled' })],
has_more: false,
});
stripe.invoices.list.mockRejectedValueOnce(failure);
await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure);
});
});
+22 -36
View File
@@ -449,7 +449,7 @@ describe('hasBillingAccess', () => {
});
describe('getBillingAccessEndDate', () => {
it('prefers billingAccessEndedAt over every other date', () => {
it('keeps an independent trial beyond the subscription cutoff', () => {
const ended = new Date('2026-01-10T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -458,10 +458,10 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
expect(result).toBe(ended);
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => {
it('keeps a longer trial when billing has not been marked ended', () => {
const periodEnd = new Date('2026-02-01T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -469,7 +469,7 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
expect(result).toBe(periodEnd);
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to trialEndsAt when there is no paid period', () => {
@@ -535,36 +535,18 @@ describe('buildBillingAccessWhereInput', () => {
});
describe('buildExpiredBillingWhereInput', () => {
it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
const cutoff = new Date('2025-12-31T00:00:00.000Z');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({
AND: [
{ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cutoff } },
{ AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] },
],
},
],
it('requires the entire trial retention window before deleting an inactive account', () => {
const where = buildExpiredBillingWhereInput(NOW) as {
AND: Array<Record<string, unknown>>;
};
expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
expect(where.AND[1]).toEqual({
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
});
});
// The NOT form this replaced could not express "no access" for a row whose date columns are
// empty, because SQL turns a comparison against NULL into unknown rather than false. Every
// branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
// proves it against a real database; this only guards the shape.
it('admits a null trial and a null period end as expired rather than skipping the row', () => {
const where = buildExpiredBillingWhereInput(NOW) as {
AND: Array<{ OR?: Array<Record<string, unknown>> }>;
};
expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
});
// Real SQL behavior with null dates and future unpaid periods is covered by the
// API cleanup and entitlement suites; the unit check guards the retention boundary.
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
@@ -1818,10 +1800,13 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().billingAccessEndedAt).toBeNull();
expect(updateData().billingAccessEndedAt).toEqual(NOW);
expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual(
new Date(NOW.getTime() + 19 * DAY_MS)
);
});
it('clears a trial that has already run out', async () => {
it('preserves an expired trial for the storage retention calculation', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
@@ -1832,7 +1817,7 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().trialEndsAt).toBeNull();
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
@@ -1896,7 +1881,8 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
expect(updateData().billingAccessEndedAt).toBeNull();
expect(updateData().billingAccessEndedAt).toEqual(NOW);
expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true);
});
it('still ends access when the trial has already run out', async () => {
@@ -1907,7 +1893,7 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBeNull();
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});