Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions apps/web/src/lib/kilo-pass/abandon-collectible-invoices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, it, jest } from '@jest/globals';
import type Stripe from 'stripe';

import {
abandonCollectibleInvoicesForStripeSubscription,
type StripeCollectibleInvoiceClient,
} from './abandon-collectible-invoices';

type InvoiceStatus = NonNullable<Stripe.Invoice['status']>;

type InvoicePage = {
data: Array<{ id: string; status: InvoiceStatus }>;
has_more: boolean;
};

function createStripeInvoiceClient(): StripeCollectibleInvoiceClient & {
invoices: {
list: jest.MockedFunction<StripeCollectibleInvoiceClient['invoices']['list']>;
update: jest.MockedFunction<StripeCollectibleInvoiceClient['invoices']['update']>;
voidInvoice: jest.MockedFunction<StripeCollectibleInvoiceClient['invoices']['voidInvoice']>;
retrieve: jest.MockedFunction<StripeCollectibleInvoiceClient['invoices']['retrieve']>;
};
} {
return {
invoices: {
list: jest.fn<StripeCollectibleInvoiceClient['invoices']['list']>(),
update: jest.fn<StripeCollectibleInvoiceClient['invoices']['update']>(),
voidInvoice: jest.fn<StripeCollectibleInvoiceClient['invoices']['voidInvoice']>(),
retrieve: jest.fn<StripeCollectibleInvoiceClient['invoices']['retrieve']>(),
},
};
}

describe('abandonCollectibleInvoicesForStripeSubscription', () => {
it('voids open invoices and disables auto-advance on draft invoices for the subscription', async () => {
const stripe = createStripeInvoiceClient();
stripe.invoices.list.mockImplementation(async params => {
if (params.status === 'open') {
return {
data: [
{ id: 'in_open_1', status: 'open' },
{ id: 'in_open_2', status: 'open' },
],
has_more: false,
} satisfies InvoicePage;
}
if (params.status === 'draft') {
return {
data: [{ id: 'in_draft_1', status: 'draft' }],
has_more: false,
} satisfies InvoicePage;
}
return { data: [], has_more: false } satisfies InvoicePage;
});
stripe.invoices.update.mockResolvedValue({});
stripe.invoices.voidInvoice.mockResolvedValue({});

await abandonCollectibleInvoicesForStripeSubscription({
stripe,
stripeSubscriptionId: 'sub_kilo_pass',
});

expect(stripe.invoices.list).toHaveBeenCalledWith({
subscription: 'sub_kilo_pass',
status: 'open',
limit: 100,
});
expect(stripe.invoices.list).toHaveBeenCalledWith({
subscription: 'sub_kilo_pass',
status: 'draft',
limit: 100,
});
expect(stripe.invoices.voidInvoice).toHaveBeenCalledWith('in_open_1');
expect(stripe.invoices.voidInvoice).toHaveBeenCalledWith('in_open_2');
expect(stripe.invoices.update).toHaveBeenCalledWith('in_draft_1', { auto_advance: false });
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalledWith('in_draft_1');
expect(stripe.invoices.retrieve).not.toHaveBeenCalled();
});

it('pages through collectible invoices', async () => {
const stripe = createStripeInvoiceClient();
stripe.invoices.list.mockImplementation(async params => {
if (params.status === 'open' && params.starting_after == null) {
return {
data: [{ id: 'in_open_page_1', status: 'open' }],
has_more: true,
} satisfies InvoicePage;
}
if (params.status === 'open' && params.starting_after === 'in_open_page_1') {
return {
data: [{ id: 'in_open_page_2', status: 'open' }],
has_more: false,
} satisfies InvoicePage;
}
return { data: [], has_more: false } satisfies InvoicePage;
});
stripe.invoices.voidInvoice.mockResolvedValue({});

await abandonCollectibleInvoicesForStripeSubscription({
stripe,
stripeSubscriptionId: 'sub_paged',
});

expect(stripe.invoices.list).toHaveBeenCalledWith({
subscription: 'sub_paged',
status: 'open',
limit: 100,
starting_after: 'in_open_page_1',
});
expect(stripe.invoices.voidInvoice).toHaveBeenCalledWith('in_open_page_1');
expect(stripe.invoices.voidInvoice).toHaveBeenCalledWith('in_open_page_2');
});

it('ignores invoices that are already paid, void, or missing', async () => {
const stripe = createStripeInvoiceClient();
stripe.invoices.list.mockImplementation(async params => {
if (params.status === 'open') {
return {
data: [
{ id: 'in_paid_race', status: 'open' },
{ id: 'in_already_void', status: 'open' },
{ id: 'in_missing', status: 'open' },
],
has_more: false,
} satisfies InvoicePage;
}
return { data: [], has_more: false } satisfies InvoicePage;
});
stripe.invoices.voidInvoice.mockImplementation(async (invoiceId: string) => {
if (invoiceId === 'in_missing') {
throw { code: 'resource_missing' };
}
throw new Error('invoice cannot be voided');
});
stripe.invoices.retrieve.mockImplementation(async (invoiceId: string) => {
if (invoiceId === 'in_paid_race') return { status: 'paid' };
if (invoiceId === 'in_already_void') return { status: 'void' };
throw { code: 'resource_missing' };
});

await abandonCollectibleInvoicesForStripeSubscription({
stripe,
stripeSubscriptionId: 'sub_races',
});
});

it('voids a listed draft that is already open when auto-advance cannot be disabled', async () => {
const stripe = createStripeInvoiceClient();
stripe.invoices.list.mockImplementation(async params => {
if (params.status === 'draft') {
return {
data: [{ id: 'in_draft_raced_open', status: 'draft' }],
has_more: false,
} satisfies InvoicePage;
}
return { data: [], has_more: false } satisfies InvoicePage;
});
stripe.invoices.update.mockRejectedValue(new Error('invoice already finalized'));
stripe.invoices.retrieve.mockResolvedValue({ status: 'open' });
stripe.invoices.voidInvoice.mockResolvedValue({});

await abandonCollectibleInvoicesForStripeSubscription({
stripe,
stripeSubscriptionId: 'sub_draft_race',
});

expect(stripe.invoices.voidInvoice).toHaveBeenCalledWith('in_draft_raced_open');
});

it('rethrows unexpected void failures', async () => {
const stripe = createStripeInvoiceClient();
stripe.invoices.list.mockImplementation(async params => {
if (params.status === 'open') {
return {
data: [{ id: 'in_open_fail', status: 'open' }],
has_more: false,
} satisfies InvoicePage;
}
return { data: [], has_more: false } satisfies InvoicePage;
});
stripe.invoices.voidInvoice.mockRejectedValue(new Error('stripe down'));
stripe.invoices.retrieve.mockResolvedValue({ status: 'open' });

await expect(
abandonCollectibleInvoicesForStripeSubscription({
stripe,
stripeSubscriptionId: 'sub_fail',
})
).rejects.toThrow('stripe down');
});
});
127 changes: 127 additions & 0 deletions apps/web/src/lib/kilo-pass/abandon-collectible-invoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import 'server-only';

import type Stripe from 'stripe';

const COLLECTIBLE_INVOICE_STATUSES = ['open', 'draft'] as const;
const ALREADY_ABANDONED_INVOICE_STATUSES: ReadonlySet<string> = new Set([
'void',
'paid',
'uncollectible',
]);

export type CollectibleInvoice = Pick<Stripe.Invoice, 'id' | 'status'>;

export type StripeCollectibleInvoiceClient = {
invoices: {
list: (
params: Stripe.InvoiceListParams
) => PromiseLike<{ data: CollectibleInvoice[]; has_more: boolean }>;
update: (invoiceId: string, params: Stripe.InvoiceUpdateParams) => Promise<unknown>;
voidInvoice: (invoiceId: string) => Promise<unknown>;
retrieve: (invoiceId: string) => PromiseLike<Pick<Stripe.Invoice, 'status'>>;
};
};

function isStripeResourceMissing(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'resource_missing'
);
}

async function listInvoicesByStatus(params: {
stripe: StripeCollectibleInvoiceClient;
stripeSubscriptionId: string;
status: (typeof COLLECTIBLE_INVOICE_STATUSES)[number];
}): Promise<CollectibleInvoice[]> {
const invoices: CollectibleInvoice[] = [];
let startingAfter: string | undefined;

do {
const page = await params.stripe.invoices.list({
subscription: params.stripeSubscriptionId,
status: params.status,
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
invoices.push(...page.data);
const lastInvoice = page.data.at(-1);
startingAfter = page.has_more && lastInvoice?.id ? lastInvoice.id : undefined;
} while (startingAfter);

return invoices;
}

async function disableDraftAutoAdvance(params: {
stripe: StripeCollectibleInvoiceClient;
invoiceId: string;
}): Promise<void> {
await params.stripe.invoices.update(params.invoiceId, { auto_advance: false });
}

async function ignoreIfAlreadyAbandoned(params: {
stripe: StripeCollectibleInvoiceClient;
invoiceId: string;
error: unknown;
}): Promise<void> {
if (isStripeResourceMissing(params.error)) return;

let status: Stripe.Invoice.Status | null | undefined;
try {
status = (await params.stripe.invoices.retrieve(params.invoiceId)).status;
} catch (retrieveError) {
if (isStripeResourceMissing(retrieveError)) return;
throw params.error;
}

if (status != null && ALREADY_ABANDONED_INVOICE_STATUSES.has(status)) return;
if (status === 'open') {
await params.stripe.invoices.voidInvoice(params.invoiceId);
return;
}
if (status === 'draft') {
await disableDraftAutoAdvance({ stripe: params.stripe, invoiceId: params.invoiceId });
return;
}
throw params.error;
}

async function abandonInvoice(params: {
stripe: StripeCollectibleInvoiceClient;
invoice: CollectibleInvoice;
}): Promise<void> {
const invoiceId = params.invoice.id;
if (!invoiceId) return;

try {
if (params.invoice.status === 'draft') {
await disableDraftAutoAdvance({ stripe: params.stripe, invoiceId });
return;
}
await params.stripe.invoices.voidInvoice(invoiceId);
} catch (error) {
await ignoreIfAlreadyAbandoned({
stripe: params.stripe,
invoiceId,
error,
});
}
}

export async function abandonCollectibleInvoicesForStripeSubscription(params: {
stripe: StripeCollectibleInvoiceClient;
stripeSubscriptionId: string;
}): Promise<void> {
for (const status of COLLECTIBLE_INVOICE_STATUSES) {
const invoices = await listInvoicesByStatus({
stripe: params.stripe,
stripeSubscriptionId: params.stripeSubscriptionId,
status,
});
for (const invoice of invoices) {
await abandonInvoice({ stripe: params.stripe, invoice });
}
}
}
Loading