mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
825 lines
33 KiB
TypeScript
825 lines
33 KiB
TypeScript
import { beforeEach, 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 { syncStripeCustomerSubscriptions } from '@/lib/billing';
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cancellation analytics through the route', () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
|
});
|
|
|
|
it.each(['canceled subscription', 'empty customer'] as const)(
|
|
'records paid cancellation before sync and deduplicates %s deletion in the same cycle',
|
|
async (deletion) => {
|
|
const user = await createSubscribedUser();
|
|
signedInAs(user);
|
|
const original = subscription(user);
|
|
const stripe = stubStripe([original]);
|
|
const response = await callRoute(
|
|
cancelRoute,
|
|
cancelRequest({ reason: 'OTHER', note: 'Leaving after this project' })
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(original.id, {
|
|
cancel_at_period_end: true,
|
|
cancellation_details: { feedback: 'other' },
|
|
});
|
|
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
|
|
userId: user.id,
|
|
stripeSubscriptionId: original.id,
|
|
reason: 'OTHER',
|
|
note: 'Leaving after this project',
|
|
});
|
|
const where = { userId: user.id, name: 'SUBSCRIPTION_CANCELED' as const };
|
|
// This must exist before any later transition can conceal the missing event.
|
|
const accepted = await db.analyticsEvent.findMany({ where });
|
|
expect(accepted).toHaveLength(1);
|
|
expect(accepted[0].dedupeKey).toBe(
|
|
`SUBSCRIPTION_CANCELED:${original.id}:${original.items.data[0].current_period_end * 1000}`
|
|
);
|
|
|
|
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
|
|
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
|
|
stripe.list.mockResolvedValue({
|
|
data:
|
|
deletion === 'empty customer'
|
|
? []
|
|
: [{ ...original, status: 'canceled', cancel_at_period_end: false }],
|
|
has_more: false,
|
|
});
|
|
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
|
|
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
|
|
const replayed = await db.analyticsEvent.findMany({ where });
|
|
expect(replayed.map((event) => event.id)).toEqual([accepted[0].id]);
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
|
|
BillingSubscriptionStatus.CANCELED
|
|
);
|
|
}
|
|
);
|
|
|
|
it('records cancellation of a paid subscription that does not drive the customer mirror', async () => {
|
|
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
|
|
signedInAs(user);
|
|
const authoritative = subscription(user);
|
|
const other = subscription(user, {
|
|
id: 'sub_analytics_other',
|
|
cancel_at_period_end: false,
|
|
created: unix(-60 * DAY),
|
|
});
|
|
const stripe = stubStripe([authoritative, other]);
|
|
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
|
|
expect(response.status).toBe(200);
|
|
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(other.id, expect.anything());
|
|
const events = await db.analyticsEvent.findMany({
|
|
where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' },
|
|
});
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].dedupeKey).toBe(
|
|
`SUBSCRIPTION_CANCELED:sub_analytics_other:${other.items.data[0].current_period_end * 1000}`
|
|
);
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
|
|
authoritative.id
|
|
);
|
|
});
|
|
|
|
it('records no cancellation event when Stripe rejects the paid cancellation', async () => {
|
|
const user = await createSubscribedUser();
|
|
signedInAs(user);
|
|
const stripe = stubStripe([subscription(user)]);
|
|
stripe.update.mockRejectedValueOnce(
|
|
Object.assign(new Error('Stripe rejected cancellation'), {
|
|
type: 'StripeInvalidRequestError',
|
|
})
|
|
);
|
|
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
|
|
expect(response.status).toBe(409);
|
|
expect(stripe.update).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
await db.analyticsEvent.count({ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' } })
|
|
).toBe(0);
|
|
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(0);
|
|
expect(
|
|
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
|
|
).toBe(false);
|
|
});
|
|
|
|
it('keeps the cancellation and reason when analytics recording fails', async () => {
|
|
const user = await createSubscribedUser();
|
|
signedInAs(user);
|
|
const original = subscription(user);
|
|
const stripe = stubStripe([original]);
|
|
const recording = vi
|
|
.spyOn(db.analyticsEvent, 'createMany')
|
|
.mockRejectedValue(new Error('Analytics unavailable'));
|
|
try {
|
|
const response = await callRoute(
|
|
cancelRoute,
|
|
cancelRequest({ reason: 'OTHER', note: 'Keep this answer' })
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(stripe.update).toHaveBeenCalledTimes(1);
|
|
expect(recording).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: [expect.objectContaining({ name: 'SUBSCRIPTION_CANCELED', userId: user.id })],
|
|
})
|
|
);
|
|
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
|
|
reason: 'OTHER',
|
|
note: 'Keep this answer',
|
|
});
|
|
expect(
|
|
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
|
|
).toBe(true);
|
|
} finally {
|
|
recording.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('still cancels without recording analytics when the feature is disabled', async () => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
|
const user = await createSubscribedUser();
|
|
signedInAs(user);
|
|
const stripe = stubStripe([subscription(user)]);
|
|
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }))).status).toBe(200);
|
|
expect(stripe.update).toHaveBeenCalledTimes(1);
|
|
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
|
|
expect(await db.analyticsEvent.count({ where: { userId: user.id } })).toBe(0);
|
|
});
|
|
});
|