diff --git a/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.test.ts b/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.test.ts new file mode 100644 index 0000000000..2f6fc286e0 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.test.ts @@ -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; + +type InvoicePage = { + data: Array<{ id: string; status: InvoiceStatus }>; + has_more: boolean; +}; + +function createStripeInvoiceClient(): StripeCollectibleInvoiceClient & { + invoices: { + list: jest.MockedFunction; + update: jest.MockedFunction; + voidInvoice: jest.MockedFunction; + retrieve: jest.MockedFunction; + }; +} { + return { + invoices: { + list: jest.fn(), + update: jest.fn(), + voidInvoice: jest.fn(), + retrieve: jest.fn(), + }, + }; +} + +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'); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.ts b/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.ts new file mode 100644 index 0000000000..443e4048d5 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/abandon-collectible-invoices.ts @@ -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 = new Set([ + 'void', + 'paid', + 'uncollectible', +]); + +export type CollectibleInvoice = Pick; + +export type StripeCollectibleInvoiceClient = { + invoices: { + list: ( + params: Stripe.InvoiceListParams + ) => PromiseLike<{ data: CollectibleInvoice[]; has_more: boolean }>; + update: (invoiceId: string, params: Stripe.InvoiceUpdateParams) => Promise; + voidInvoice: (invoiceId: string) => Promise; + retrieve: (invoiceId: string) => PromiseLike>; + }; +}; + +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 { + 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 { + await params.stripe.invoices.update(params.invoiceId, { auto_advance: false }); +} + +async function ignoreIfAlreadyAbandoned(params: { + stripe: StripeCollectibleInvoiceClient; + invoiceId: string; + error: unknown; +}): Promise { + 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 { + 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 { + 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 }); + } + } +} diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 6cbfbf7e9a..390b59bb37 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -90,6 +90,9 @@ type StripeMock = { }; invoices: { list: ReturnType; + update: ReturnType; + voidInvoice: ReturnType; + retrieve: ReturnType; }; }; @@ -374,6 +377,9 @@ jest.mock('@/lib/stripe-client', () => { }, invoices: { list: jest.fn(), + update: jest.fn(), + voidInvoice: jest.fn(), + retrieve: jest.fn(), }, }; @@ -491,6 +497,9 @@ function expectNoStripeManagementCalls(stripeMock: StripeMock): void { expect(stripeMock.subscriptionSchedules.update).not.toHaveBeenCalled(); expect(stripeMock.subscriptionSchedules.release).not.toHaveBeenCalled(); expect(stripeMock.invoices.list).not.toHaveBeenCalled(); + expect(stripeMock.invoices.update).not.toHaveBeenCalled(); + expect(stripeMock.invoices.voidInvoice).not.toHaveBeenCalled(); + expect(stripeMock.invoices.retrieve).not.toHaveBeenCalled(); } async function insertBaseCreditsIssuance(params: { @@ -684,6 +693,10 @@ describe('kiloPassRouter', () => { stripeMock.checkout.sessions.retrieve.mockReset(); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.invoices.list.mockReset(); + stripeMock.invoices.list.mockResolvedValue({ data: [], has_more: false }); + stripeMock.invoices.update.mockReset(); + stripeMock.invoices.voidInvoice.mockReset(); + stripeMock.invoices.retrieve.mockReset(); getAppStoreVerifierMock().verifyAppleKiloPassTransactionJws.mockReset(); getStoreCompletionMock().completeStoreKiloPassPurchase.mockReset(); getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockReset(); @@ -3819,6 +3832,16 @@ describe('kiloPassRouter', () => { expect(stripeMock.subscriptions.update).toHaveBeenCalledWith('sub_test_cancel_me', { cancel_at_period_end: true, }); + expect(stripeMock.invoices.list).toHaveBeenCalledWith({ + subscription: 'sub_test_cancel_me', + status: 'open', + limit: 100, + }); + expect(stripeMock.invoices.list).toHaveBeenCalledWith({ + subscription: 'sub_test_cancel_me', + status: 'draft', + limit: 100, + }); const updated = await db.query.kilo_pass_subscriptions.findFirst({ columns: { status: true, cancel_at_period_end: true }, @@ -3828,6 +3851,88 @@ describe('kiloPassRouter', () => { expect(updated?.cancel_at_period_end).toBe(true); }); + it('voids open invoices and disables auto-advance on draft invoices so Stripe stops collection', async () => { + const stripeMock = getStripeMock(); + stripeMock.subscriptions.update.mockResolvedValue({}); + stripeMock.invoices.list.mockImplementation(async (params: { status?: string }) => { + if (params.status === 'open') { + return { data: [{ id: 'in_open_failed', status: 'open' }], has_more: false }; + } + if (params.status === 'draft') { + return { data: [{ id: 'in_draft_pending', status: 'draft' }], has_more: false }; + } + return { data: [], has_more: false }; + }); + stripeMock.invoices.update.mockResolvedValue({}); + stripeMock.invoices.voidInvoice.mockResolvedValue({}); + + const user = await insertTestUser({ + google_user_email: 'kilo-pass-cancel-void-invoices@example.com', + }); + await insertSubscription({ + kiloUserId: user.id, + stripeSubscriptionId: 'sub_test_cancel_void', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + status: 'active', + cancelAtPeriodEnd: false, + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.cancelSubscription(); + + expect(result).toEqual({ success: true }); + expect(stripeMock.invoices.voidInvoice).toHaveBeenCalledWith('in_open_failed'); + expect(stripeMock.invoices.update).toHaveBeenCalledWith('in_draft_pending', { + auto_advance: false, + }); + expect(stripeMock.invoices.voidInvoice).not.toHaveBeenCalledWith('in_draft_pending'); + expect(stripeMock.subscriptions.update).toHaveBeenCalledWith('sub_test_cancel_void', { + cancel_at_period_end: true, + }); + + const updated = await db.query.kilo_pass_subscriptions.findFirst({ + columns: { cancel_at_period_end: true }, + where: eq(kilo_pass_subscriptions.stripe_subscription_id, 'sub_test_cancel_void'), + }); + expect(updated?.cancel_at_period_end).toBe(true); + }); + + it('does not persist cancellation when voiding collectible invoices fails', async () => { + const stripeMock = getStripeMock(); + stripeMock.subscriptions.update.mockResolvedValue({}); + stripeMock.invoices.list.mockImplementation(async (params: { status?: string }) => { + if (params.status === 'open') { + return { data: [{ id: 'in_open_failed', status: 'open' }], has_more: false }; + } + return { data: [], has_more: false }; + }); + stripeMock.invoices.voidInvoice.mockRejectedValue(new Error('stripe void failed')); + stripeMock.invoices.retrieve.mockResolvedValue({ status: 'open' }); + + const user = await insertTestUser({ + google_user_email: 'kilo-pass-cancel-void-fails@example.com', + }); + await insertSubscription({ + kiloUserId: user.id, + stripeSubscriptionId: 'sub_test_cancel_void_fail', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + cancelAtPeriodEnd: false, + }); + + const caller = await createCallerForUser(user.id); + await expect(caller.kiloPass.cancelSubscription()).rejects.toThrow('stripe void failed'); + expect(stripeMock.subscriptions.update).not.toHaveBeenCalled(); + + const updated = await db.query.kilo_pass_subscriptions.findFirst({ + columns: { cancel_at_period_end: true }, + where: eq(kilo_pass_subscriptions.stripe_subscription_id, 'sub_test_cancel_void_fail'), + }); + expect(updated?.cancel_at_period_end).toBe(false); + }); + it('rejects active Google Play subscriptions without canceling in Stripe', async () => { const stripeMock = getStripeMock(); const user = await insertTestUser({ diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 1ac6664601..eacac0ca48 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -78,6 +78,7 @@ import type Stripe from 'stripe'; import { dayjs } from '@/lib/kilo-pass/dayjs'; import { computeChurnkeyAuthHash } from '@/lib/churnkey/auth'; import { closePauseEvent } from '@/lib/kilo-pass/pause-events'; +import { abandonCollectibleInvoicesForStripeSubscription } from '@/lib/kilo-pass/abandon-collectible-invoices'; import { getAllMobileStoreKiloPassProducts, getMobileStoreKiloPassProductByAppleProductId, @@ -2067,6 +2068,11 @@ export const kiloPassRouter = createTRPCRouter({ }); } + await abandonCollectibleInvoicesForStripeSubscription({ + stripe, + stripeSubscriptionId: subscription.stripeSubscriptionId, + }); + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true, });