mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
|
||||
|
||||
function renderDialog(
|
||||
overrides: {
|
||||
periodEnd?: string | null;
|
||||
isTrial?: boolean;
|
||||
canceledImmediately?: boolean;
|
||||
confirmResult?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<CancelSubscriptionDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
periodEnd={
|
||||
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
|
||||
}
|
||||
isTrial={overrides.isTrial ?? false}
|
||||
canceledImmediately={overrides.canceledImmediately}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
return { onConfirm, onOpenChange };
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionDialog', () => {
|
||||
it('lists every answer with none selected', () => {
|
||||
renderDialog();
|
||||
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios).toHaveLength(5);
|
||||
for (const radio of radios) {
|
||||
expect(radio).toHaveAttribute('aria-checked', 'false');
|
||||
}
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The question is skippable: the destructive button works with nothing
|
||||
// chosen, and the handler receives an explicit null rather than a default.
|
||||
it('cancels with no reason when the question is skipped', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ reason: null, note: null });
|
||||
});
|
||||
|
||||
it('opens the note box only under the answers that ask for detail', async () => {
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'I am not using it enough' }));
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'It is missing something I need' }));
|
||||
expect(screen.getByLabelText(/What was missing\?/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the chosen reason with a trimmed note', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), ' Moved the client to Frame.io ');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
reason: 'OTHER',
|
||||
note: 'Moved the client to Frame.io',
|
||||
});
|
||||
});
|
||||
|
||||
// A note typed under "Something else" must not travel with an answer that
|
||||
// never showed the box, or the admin reads a comment about nothing.
|
||||
it('drops the note when the answer changes to one without a note box', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Some detail');
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'The project or client work ended' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ reason: 'PROJECT_ENDED', note: null });
|
||||
});
|
||||
|
||||
// A failed request must not cost the customer the answer they typed.
|
||||
it('keeps the answer on screen when the confirmation fails', async () => {
|
||||
const { onConfirm } = renderDialog({ confirmResult: false });
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Kept this');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('radio', { name: 'Something else' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Kept this');
|
||||
});
|
||||
|
||||
it('closes without confirming from the keep button', async () => {
|
||||
const { onConfirm, onOpenChange } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Keep subscription' }));
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => {
|
||||
renderDialog({ canceledImmediately: true });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent(
|
||||
'Canceling does not extend access to your workspaces.'
|
||||
);
|
||||
expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent(
|
||||
'charges for prior service and other items may still be owed.'
|
||||
);
|
||||
expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('retains the scheduled period-end explanation', () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByText(/Everything stays on until/)).toHaveTextContent(
|
||||
new Date('2026-10-01T00:00:00.000Z').toLocaleDateString()
|
||||
);
|
||||
expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the trial instead of the subscription while still trialing', () => {
|
||||
renderDialog({ isTrial: true });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Cancel your trial?' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel trial' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
|
||||
|
||||
function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') {
|
||||
@@ -70,3 +71,86 @@ describe('scheduled cancellation in billing settings', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPastDue(hasBillingAccess: boolean) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url !== '/api/billing') return { ok: false };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
isEnabled: true,
|
||||
isConfigured: true,
|
||||
checkoutAvailable: false,
|
||||
portalAvailable: true,
|
||||
cancelAvailable: true,
|
||||
cancelIsImmediate: true,
|
||||
needsPaymentFix: true,
|
||||
openInvoice: null,
|
||||
workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false },
|
||||
subscription: {
|
||||
status: 'PAST_DUE',
|
||||
label: 'Past due',
|
||||
hasActiveSubscription: false,
|
||||
hasRecoverableSubscription: true,
|
||||
hasActiveTrial: false,
|
||||
hasBillingAccess,
|
||||
currentPeriodEnd: '2026-10-08T12:00:00Z',
|
||||
trialEndsAt: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
render(<SettingsPage billingOnly />);
|
||||
}
|
||||
|
||||
describe('past-due access in billing settings', () => {
|
||||
it('opens immediate unpaid cancellation copy and waits for confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPastDue(true);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
const dialog = within(screen.getByRole('dialog'));
|
||||
expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent(
|
||||
'Canceling does not extend access to your workspaces.'
|
||||
);
|
||||
expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent(
|
||||
'charges for prior service and other items may still be owed.'
|
||||
);
|
||||
expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument();
|
||||
expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled();
|
||||
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
|
||||
|
||||
await user.click(dialog.getByRole('button', { name: 'Keep subscription' }));
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows continued workspace access during payment grace without an active trial', async () => {
|
||||
renderPastDue(true);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Workspace access remains available while you resolve your payment.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows access has ended when payment grace has expired and no trial remains', async () => {
|
||||
renderPastDue(false);
|
||||
|
||||
expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Workspace access remains available while you resolve your payment.')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -127,7 +127,12 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
|
||||
...actual,
|
||||
getStripe: vi.fn(() => ({
|
||||
customers: { create: vi.fn(async () => ({ id: 'cus_test_default' })) },
|
||||
subscriptions: { list: vi.fn(async () => ({ data: [] })) },
|
||||
subscriptions: {
|
||||
list: vi.fn(async () => ({ data: [] })),
|
||||
update: vi.fn(() => {
|
||||
throw new Error('stripe.subscriptions.update was not stubbed for this test');
|
||||
}),
|
||||
},
|
||||
checkout: {
|
||||
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/checkout' })) },
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260818120000_add_upload_reservation_purpose',
|
||||
'20260820120000_add_comment_images',
|
||||
'20260822120000_add_video_subtitles',
|
||||
'20260908120000_add_subscription_cancellations',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user