diff --git a/apps/web/src/lib/kilo-pass-org/stripe-adapter.test.ts b/apps/web/src/lib/kilo-pass-org/stripe-adapter.test.ts index 4e72bee5cd..308a83c947 100644 --- a/apps/web/src/lib/kilo-pass-org/stripe-adapter.test.ts +++ b/apps/web/src/lib/kilo-pass-org/stripe-adapter.test.ts @@ -1,5 +1,29 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals'; import type Stripe from 'stripe'; +import { SEAT_PRODUCT_IDS } from '@/lib/organizations/stripe-seat-line-items'; +import { + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + prepareServiceFeeAssessmentDecision, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_DESCRIPTION, + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + createInvoiceServiceFeeAssessmentKey, + SERVICE_FEE_FAILURE_APPLICATION, +} from '@/lib/service-fees/checkout'; +import type { KiloPassInvoiceCreatedDependencies } from '@/lib/service-fees/invoice-created'; +import { buildServiceFeeLineMetadata } from '@/lib/service-fees/stripe-lines'; +import type { KiloPassServiceFeeSettlementStripe } from '@/lib/service-fees/settlement'; +import type { OrganizationKiloPassSeatCapacityStripe } from '@/lib/kilo-pass-org/stripe-adapter'; const retrieve = jest.fn< @@ -14,6 +38,33 @@ const invoicePaymentsList = jest.fn< (params: Stripe.InvoicePaymentListParams) => Promise> >(); +const invoiceItemCreate = + jest.fn< + (params: Stripe.InvoiceItemCreateParams) => Promise> + >(); +const invoiceItemDel = jest.fn<(id: string) => Promise>(); + +function createdInvoiceItemAssessmentKey(): string | undefined { + const metadata = invoiceItemCreate.mock.calls[0]?.[0]?.metadata; + if (!metadata || typeof metadata !== 'object') return undefined; + const value = metadata.serviceFeeAssessmentKey; + return typeof value === 'string' ? value : undefined; +} +const listLineItems = + jest.fn< + ( + invoiceId: string, + params?: Stripe.InvoiceListLineItemsParams + ) => Promise, 'data' | 'has_more'>> + >(); +const createPreview = + jest.fn< + ( + params: Stripe.InvoiceCreatePreviewParams + ) => Promise< + Pick + > + >(); const select = jest.fn(); const selectOrderBy = jest.fn(); const updateDb = jest.fn(); @@ -28,6 +79,8 @@ jest.mock('@/lib/stripe-client', () => ({ client: { subscriptions: { retrieve, update }, invoicePayments: { list: invoicePaymentsList }, + invoiceItems: { create: invoiceItemCreate, del: invoiceItemDel }, + invoices: { listLineItems, createPreview }, subscriptionSchedules: { create: scheduleCreate, update: scheduleUpdate, @@ -52,9 +105,16 @@ jest.mock('@/lib/kilo-pass/stripe-price-ids.server', () => ({ getKnownStripePriceIdsForKiloPass: () => ['price_pass'], getStripePriceIdForKiloPass: () => 'price_pass', })); -jest.mock('@/lib/organizations/stripe-seat-line-items', () => ({ - isSeatLineItem: (item: { id: string }) => item.id === 'si_seat', -})); +jest.mock('@/lib/organizations/stripe-seat-line-items', () => { + const actual = jest.requireActual('@/lib/organizations/stripe-seat-line-items') as { + isSeatLineItem: (item: { id: string }) => boolean; + SEAT_PRODUCT_IDS: Set; + }; + return { + ...actual, + isSeatLineItem: (item: { id: string }) => item.id === 'si_seat', + }; +}); const subscription = (overrides: Partial = {}) => ({ @@ -124,6 +184,9 @@ describe('organization Kilo Pass Stripe adapter', () => { has_more: false, url: '/v1/invoice_payments', }); + listLineItems.mockResolvedValue({ data: [], has_more: false }); + invoiceItemCreate.mockResolvedValue({ id: 'ii_fee', amount: 245 }); + invoiceItemDel.mockResolvedValue({}); }); test('derives paid capacity and bridge issuance window from immutable invoice lines', async () => { @@ -292,6 +355,68 @@ describe('organization Kilo Pass Stripe adapter', () => { ); }); + test('ignores unused-time proration quantity when remaining time is also on the invoice', async () => { + retrieve.mockResolvedValue( + subscription({ + items: { + data: [ + { + id: 'si_seat', + quantity: 2, + price: { id: 'price_seat', recurring: { interval: 'month' } }, + }, + { + id: 'si_pass', + quantity: 2, + price: { id: 'price_pass', recurring: { interval: 'month' } }, + }, + ], + } as Stripe.ApiList, + }) + ); + const invoice = { + parent: { subscription_details: { subscription: 'sub_1' } }, + lines: { + data: [ + { + id: 'line_seat_unused', + amount: -1_798, + quantity: 1, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_seat' } }, + }, + { + id: 'line_pass_unused', + amount: -1_898, + quantity: 1, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + { + id: 'line_seat_remaining', + amount: 3_595, + quantity: 2, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_seat' } }, + }, + { + id: 'line_pass_remaining', + amount: 3_795, + quantity: 2, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + ], + }, + } as unknown as Stripe.Invoice; + const { handleOrganizationKiloPassInvoicePaid } = await import('./stripe-adapter'); + + await expect(handleOrganizationKiloPassInvoicePaid({ invoice })).resolves.toBe(true); + expect(activatePaidAgreement).toHaveBeenCalledWith( + expect.objectContaining({ paidSeatCount: 2 }) + ); + }); + test('returns a PaymentIntent client secret for payment authentication', async () => { retrieve.mockResolvedValue( subscription({ items: { ...subscription().items, data: [subscription().items.data[0]!] } }) @@ -334,7 +459,7 @@ describe('organization Kilo Pass Stripe adapter', () => { expect.objectContaining({ payment_behavior: 'allow_incomplete', proration_behavior: 'always_invoice', - expand: ['latest_invoice.confirmation_secret'], + expand: ['latest_invoice.confirmation_secret', 'latest_invoice.lines'], }) ); expect(invoicePaymentsList).toHaveBeenCalledWith({ @@ -1077,4 +1202,1488 @@ describe('organization Kilo Pass Stripe adapter', () => { cancellation_effective_at: null, }); }); + + test('binds the persisted provider item instead of the first non-seat item', async () => { + const { resolveOrganizationKiloPassSubscriptionItem, handleOrganizationKiloPassInvoicePaid } = + await import('./stripe-adapter'); + const mixed = subscription({ + items: { + data: [ + subscription().items.data[0]!, + { + id: 'si_other', + quantity: 1, + price: { id: 'price_other', recurring: { interval: 'month' } }, + current_period_start: 1_767_225_600, + current_period_end: 1_769_904_000, + }, + subscription().items.data[1]!, + ], + } as Stripe.ApiList, + }); + expect( + resolveOrganizationKiloPassSubscriptionItem({ + subscription: mixed, + boundProviderItemId: 'si_pass', + })?.id + ).toBe('si_pass'); + + retrieve.mockResolvedValue(mixed); + const invoice = { + parent: { subscription_details: { subscription: 'sub_1' } }, + lines: { + data: [ + { + id: 'line_other', + quantity: 99, + period: { start: 1, end: 2 }, + parent: { subscription_item_details: { subscription_item: 'si_other' } }, + }, + { + id: 'line_pass', + quantity: 2, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + { + id: 'line_seat', + quantity: 5, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_seat' } }, + }, + ], + }, + } as unknown as Stripe.Invoice; + + await expect(handleOrganizationKiloPassInvoicePaid({ invoice })).resolves.toBe(true); + expect(activatePaidAgreement).toHaveBeenCalledWith( + expect.objectContaining({ + paidSeatCount: 5, + paidFrom: new Date('2026-01-04T12:00:00.000Z'), + paidUntil: new Date('2026-02-01T00:00:00.000Z'), + }) + ); + expect(createParentSupplement).toHaveBeenCalledWith( + expect.objectContaining({ providerInvoiceLineId: 'line_pass' }) + ); + }); + + test('falls back to the known Kilo Pass price when the provider item is unbound', async () => { + const { resolveOrganizationKiloPassSubscriptionItem } = await import('./stripe-adapter'); + const unbound = subscription({ + items: { + data: [ + subscription().items.data[0]!, + { + id: 'si_other', + quantity: 1, + price: { id: 'price_other', recurring: { interval: 'month' } }, + }, + subscription().items.data[1]!, + ], + } as Stripe.ApiList, + }); + expect( + resolveOrganizationKiloPassSubscriptionItem({ + subscription: unbound, + boundProviderItemId: 'pending:sub_1', + })?.id + ).toBe('si_pass'); + }); + + test('falls back to item metadata when the Kilo Pass price is unknown', async () => { + const { resolveOrganizationKiloPassSubscriptionItem } = await import('./stripe-adapter'); + const legacy = subscription({ + items: { + data: [ + subscription().items.data[0]!, + { + id: 'si_legacy', + quantity: 9, + price: { id: 'price_legacy_pass', recurring: { interval: 'month' } }, + metadata: { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_19', + cadence: 'monthly', + }, + }, + ], + } as Stripe.ApiList, + }); + expect( + resolveOrganizationKiloPassSubscriptionItem({ + subscription: legacy, + boundProviderItemId: 'pending:sub_1', + })?.id + ).toBe('si_legacy'); + }); + + test('ignores a service-fee item when locating the bound Kilo Pass add-on', async () => { + const { + resolveOrganizationKiloPassSubscriptionItem, + handleOrganizationKiloPassSubscriptionEvent, + } = await import('./stripe-adapter'); + const withFee = subscription({ + items: { + data: [ + subscription().items.data[0]!, + { + id: 'si_fee', + quantity: 1, + price: { + id: 'price_fee', + recurring: { interval: 'month' }, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + subscription().items.data[1]!, + ], + } as Stripe.ApiList, + }); + expect( + resolveOrganizationKiloPassSubscriptionItem({ + subscription: withFee, + boundProviderItemId: 'pending:sub_1', + })?.id + ).toBe('si_pass'); + + await expect(handleOrganizationKiloPassSubscriptionEvent(withFee)).resolves.toBe(true); + expect(bindProviderSeatAddOnItem).not.toHaveBeenCalled(); + }); + + test('does not use a service-fee invoice line for period, quantity, or capacity', async () => { + retrieve.mockResolvedValue(subscription()); + const invoice = { + parent: { subscription_details: { subscription: 'sub_1' } }, + lines: { + data: [ + { + id: 'line_fee', + quantity: 99, + amount: 245, + period: { start: 10, end: 20 }, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: 'invoice:in_1', + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + pricing: { price_details: { price: 'price_pass', product: 'prod_fee' } }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + { + id: 'line_pass', + quantity: 2, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + { + id: 'line_seat', + quantity: 5, + period: { start: 1_767_528_000, end: 1_769_904_000 }, + parent: { subscription_item_details: { subscription_item: 'si_seat' } }, + }, + ], + }, + } as unknown as Stripe.Invoice; + const { handleOrganizationKiloPassInvoicePaid } = await import('./stripe-adapter'); + + await expect(handleOrganizationKiloPassInvoicePaid({ invoice })).resolves.toBe(true); + expect(activatePaidAgreement).toHaveBeenCalledWith( + expect.objectContaining({ + paidSeatCount: 5, + paidFrom: new Date('2026-01-04T12:00:00.000Z'), + paidUntil: new Date('2026-02-01T00:00:00.000Z'), + }) + ); + expect(createParentSupplement).toHaveBeenCalledWith( + expect.objectContaining({ providerInvoiceLineId: 'line_pass', paidSeatCount: 5 }) + ); + }); + + test('checkout still adds the pass when current tax is unapproved', async () => { + retrieve.mockResolvedValue( + subscription({ items: { ...subscription().items, data: [subscription().items.data[0]!] } }) + ); + createPendingAgreement.mockResolvedValue({ agreementId: 'agreement_1', created: true }); + update.mockResolvedValue({ + ...subscription(), + latest_invoice: { + id: 'in_draft', + status: 'draft', + amount_due: 4_900, + currency: 'usd', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + confirmation_secret: { type: 'payment_intent', client_secret: 'pi_secret_1' }, + parent: { subscription_details: { subscription: 'sub_1' } }, + lines: { + data: [ + { + id: 'line_pass', + amount: 4_900, + currency: 'usd', + period: { start: 1_767_528_000, end: 1_769_904_000 }, + pricing: { price_details: { price: 'price_pass', product: 'prod_pass' } }, + parent: { subscription_item_details: { subscription_item: 'si_pass' } }, + }, + ], + has_more: false, + }, + } as unknown as Stripe.Invoice, + }); + invoicePaymentsList.mockResolvedValue({ + object: 'list', + data: [ + { + status: 'open', + payment: { + type: 'payment_intent', + payment_intent: { status: 'requires_action' }, + }, + } as unknown as Stripe.InvoicePayment, + ], + has_more: false, + url: '/v1/invoice_payments', + }); + const { createOrganizationKiloPassCheckout } = await import('./stripe-adapter'); + + await expect( + createOrganizationKiloPassCheckout({ + organizationId: 'org_1', + actorUserId: 'user_1', + tier: 'tier_19', + allocations: [], + serviceFee: { + store: createMemoryAssessmentStore(), + }, + }) + ).resolves.toEqual({ kind: 'payment_action', clientSecret: 'pi_secret_1' }); + expect(update).toHaveBeenCalledWith( + 'sub_1', + expect.objectContaining({ + items: [{ price: 'price_pass', quantity: 9 }], + }) + ); + expect(bindProviderSeatAddOnItem).toHaveBeenCalledWith({ + agreementId: 'agreement_1', + providerSeatAddOnItemId: 'si_pass', + }); + expect(invoiceItemCreate).not.toHaveBeenCalled(); + }); + + test('initial add-on attaches one non-discountable fee on net Kilo Pass only', async () => { + retrieve.mockResolvedValue( + subscription({ + customer: 'cus_1', + items: { ...subscription().items, data: [subscription().items.data[0]!] }, + }) + ); + createPendingAgreement.mockResolvedValue({ agreementId: 'agreement_1', created: true }); + const draftInvoice = mixedDraftInvoice({ + id: 'in_add_on', + lines: [seatInvoiceLine(72_000), passInvoiceLine(4_900, { id: 'line_pass' })], + }); + createPreview.mockResolvedValue({ + id: 'in_preview_add_on', + currency: 'usd', + customer: 'cus_1', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + status: 'draft', + lines: draftInvoice.lines, + }); + update.mockResolvedValue({ + ...subscription({ customer: 'cus_1' }), + latest_invoice: { + ...draftInvoice, + confirmation_secret: { type: 'payment_intent', client_secret: 'pi_secret_1' }, + } as unknown as Stripe.Invoice, + }); + invoicePaymentsList.mockResolvedValue({ + object: 'list', + data: [ + { + status: 'open', + payment: { + type: 'payment_intent', + payment_intent: { status: 'requires_action' }, + }, + } as unknown as Stripe.InvoicePayment, + ], + has_more: false, + url: '/v1/invoice_payments', + }); + const { createOrganizationKiloPassCheckout } = await import('./stripe-adapter'); + + await expect( + createOrganizationKiloPassCheckout({ + organizationId: 'org_1', + actorUserId: 'user_1', + tier: 'tier_19', + allocations: [], + serviceFee: { + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems, createPreview }, + invoiceItems: { create: invoiceItemCreate }, + subscriptions: { retrieve }, + } as OrganizationKiloPassSeatCapacityStripe, + deps: { + now: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }, + }) + ).resolves.toEqual({ kind: 'payment_action', clientSecret: 'pi_secret_1' }); + expect(invoiceItemCreate).toHaveBeenCalled(); + expect(invoiceItemCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + amount: 245, + discountable: false, + description: SERVICE_FEE_DESCRIPTION, + tax_behavior: 'exclusive', + metadata: expect.objectContaining({ + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: expect.stringMatching(/^org-checkout:/), + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }), + }) + ); + expect(invoiceItemCreate.mock.calls[0]?.[0].invoice).toBeUndefined(); + expect(update).toHaveBeenCalledWith( + 'sub_1', + expect.objectContaining({ + metadata: expect.objectContaining({ + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + }), + }) + ); + expect(update.mock.calls[0]?.[1].metadata).not.toHaveProperty('serviceFeeAssessmentKey'); + expect(update.mock.calls[0]?.[1].metadata).not.toHaveProperty('serviceFeeFlow'); + }); + + test('stages the fee before update so a paid invoice can still charge', async () => { + retrieve.mockResolvedValue( + subscription({ + customer: 'cus_1', + items: { ...subscription().items, data: [subscription().items.data[0]!] }, + }) + ); + createPendingAgreement.mockResolvedValue({ agreementId: 'agreement_1', created: true }); + const previewLines = [seatInvoiceLine(72_000), passInvoiceLine(4_900, { id: 'line_pass' })]; + createPreview.mockResolvedValue({ + id: 'in_preview_paid', + currency: 'usd', + customer: 'cus_1', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + status: 'draft', + lines: { object: 'list', data: previewLines, has_more: false, url: '/v1/invoices' }, + }); + const store = createMemoryAssessmentStore(); + update.mockImplementation(async () => { + const assessmentKey = createdInvoiceItemAssessmentKey() ?? 'org-checkout:x'; + return { + ...subscription({ customer: 'cus_1' }), + latest_invoice: mixedDraftInvoice({ + id: 'in_paid_add_on', + status: 'paid', + amount_due: 0, + lines: [ + ...previewLines, + passInvoiceLine(245, { + id: 'il_fee', + description: SERVICE_FEE_DESCRIPTION, + discountable: false, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeAssessmentKey: assessmentKey, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + }), + ], + }), + }; + }); + const { createOrganizationKiloPassCheckout } = await import('./stripe-adapter'); + + await expect( + createOrganizationKiloPassCheckout({ + organizationId: 'org_1', + actorUserId: 'user_1', + tier: 'tier_19', + allocations: [], + serviceFee: { + store, + stripe: { + invoices: { listLineItems, createPreview }, + invoiceItems: { create: invoiceItemCreate, del: invoiceItemDel }, + subscriptions: { retrieve }, + } as OrganizationKiloPassSeatCapacityStripe, + deps: { + now: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }, + }) + ).resolves.toEqual({ kind: 'completed' }); + expect(invoiceItemCreate).toHaveBeenCalledTimes(1); + expect(invoiceItemCreate.mock.calls[0]?.[0].invoice).toBeUndefined(); + expect(invoiceItemDel).toHaveBeenCalledWith('ii_fee'); + const assessmentKey = createdInvoiceItemAssessmentKey(); + expect(assessmentKey).toEqual(expect.stringMatching(/^org-checkout:/)); + expect(await store.findByAssessmentKey(assessmentKey ?? '')).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 245, + }); + }); + + test('overlapping webhook does not create a second fee item when checkout already attached', async () => { + retrieve.mockResolvedValue( + subscription({ + customer: 'cus_1', + items: { ...subscription().items, data: [subscription().items.data[0]!] }, + }) + ); + createPendingAgreement.mockResolvedValue({ agreementId: 'agreement_1', created: true }); + const draftInvoice = mixedDraftInvoice({ + id: 'in_overlap', + lines: [seatInvoiceLine(72_000), passInvoiceLine(4_900, { id: 'line_pass_overlap' })], + }); + createPreview.mockResolvedValue({ + id: 'in_preview_overlap', + currency: 'usd', + customer: 'cus_1', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + status: 'draft', + lines: draftInvoice.lines, + }); + update.mockResolvedValue({ + ...subscription({ customer: 'cus_1' }), + latest_invoice: { + ...draftInvoice, + confirmation_secret: { type: 'payment_intent', client_secret: 'pi_secret_overlap' }, + } as unknown as Stripe.Invoice, + }); + invoicePaymentsList.mockResolvedValue({ + object: 'list', + data: [ + { + status: 'open', + payment: { + type: 'payment_intent', + payment_intent: { status: 'requires_action' }, + }, + } as unknown as Stripe.InvoicePayment, + ], + has_more: false, + url: '/v1/invoice_payments', + }); + const store = createMemoryAssessmentStore(); + const { createOrganizationKiloPassCheckout } = await import('./stripe-adapter'); + + await createOrganizationKiloPassCheckout({ + organizationId: 'org_1', + actorUserId: 'user_1', + tier: 'tier_19', + allocations: [], + serviceFee: { + store, + stripe: { + invoices: { listLineItems, createPreview }, + invoiceItems: { create: invoiceItemCreate }, + subscriptions: { retrieve }, + } as OrganizationKiloPassSeatCapacityStripe, + deps: { + now: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + getOrganizationPurchaseChannel: async () => 'self_serve', + }, + }, + }); + const createsAfterCheckout = invoiceItemCreate.mock.calls.length; + expect(createsAfterCheckout).toBeGreaterThanOrEqual(1); + const assessmentKey = createdInvoiceItemAssessmentKey(); + expect(assessmentKey).toEqual(expect.stringMatching(/^org-checkout:/)); + + const { handleKiloPassInvoiceCreated } = await import('@/lib/service-fees/invoice-created'); + await handleKiloPassInvoiceCreated({ + invoice: { + ...draftInvoice, + lines: { + ...draftInvoice.lines, + data: [ + ...draftInvoice.lines.data, + { + ...passInvoiceLine(245, { id: 'il_overlap_fee' }), + discountable: false, + metadata: buildServiceFeeLineMetadata(String(assessmentKey)), + } as Stripe.InvoiceLineItem, + ], + }, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + }, + subscription: 'sub_1', + }, + }, + } as Stripe.Invoice, + stripe: { + invoices: { listLineItems }, + invoiceItems: { create: invoiceItemCreate }, + subscriptions: { retrieve }, + }, + store, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }); + expect(invoiceItemCreate).toHaveBeenCalledTimes(createsAfterCheckout); + expect(await store.findByAssessmentKey(String(assessmentKey))).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 245, + }); + }); +}); + +const SEAT_PRODUCT_ID = [...SEAT_PRODUCT_IDS][0] ?? 'prod_seat'; +const SEAT_PRICE_ID = process.env.STRIPE_TEAMS_MONTHLY_PRICE_ID ?? 'price_seat'; + +function createMemoryAssessmentStore(): ServiceFeeAssessmentStore { + const rows = new Map(); + const store: ServiceFeeAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + async insert(record) { + if (rows.has(record.assessmentKey)) { + throw new Error(`duplicate assessment_key ${record.assessmentKey}`); + } + const copy = { ...record, metadata: { ...record.metadata } }; + rows.set(record.assessmentKey, copy); + return { ...copy }; + }, + async update(assessmentKey, patch) { + const existing = rows.get(assessmentKey); + if (!existing) throw new Error(`missing ${assessmentKey}`); + const next = { + ...existing, + ...patch, + metadata: { ...existing.metadata, ...(patch.metadata ?? {}) }, + }; + rows.set(assessmentKey, next); + return { ...next }; + }, + }; + return Object.assign(store, { + async findByStripeInvoiceId(stripeInvoiceId: string) { + const row = [...rows.values()].find( + candidate => candidate.stripeInvoiceId === stripeInvoiceId + ); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + }); +} + +function passInvoiceLine( + amount: number, + extra: Partial = {} +): Stripe.InvoiceLineItem { + return { + id: extra.id ?? 'il_pass', + object: 'line_item', + amount, + currency: 'usd', + description: 'Kilo Pass', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: 'in_test', + livemode: false, + metadata: extra.metadata ?? { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_19', + cadence: 'monthly', + }, + parent: extra.parent ?? { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: false, + proration_details: { credited_items: null }, + subscription: 'sub_1', + subscription_item: 'si_pass', + }, + }, + period: extra.period ?? { start: 1, end: 2 }, + pretax_credit_amounts: null, + pricing: { + type: 'price_details', + unit_amount_decimal: String(amount), + price_details: { price: 'price_pass', product: 'prod_pass' }, + }, + quantity: extra.quantity ?? 1, + subscription: 'sub_1', + taxes: null, + ...extra, + } as Stripe.InvoiceLineItem; +} + +function seatInvoiceLine(amount: number): Stripe.InvoiceLineItem { + return { + id: 'il_seat', + object: 'line_item', + amount, + currency: 'usd', + description: 'Seats', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: 'in_test', + livemode: false, + metadata: {}, + parent: { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: false, + proration_details: { credited_items: null }, + subscription: 'sub_1', + subscription_item: 'si_seat', + }, + }, + period: { start: 1, end: 2 }, + pretax_credit_amounts: null, + pricing: { + type: 'price_details', + unit_amount_decimal: String(amount), + price_details: { price: SEAT_PRICE_ID, product: SEAT_PRODUCT_ID }, + }, + quantity: 9, + subscription: 'sub_1', + taxes: null, + } as Stripe.InvoiceLineItem; +} + +function mixedDraftInvoice( + overrides: { + id?: string; + lines?: Stripe.InvoiceLineItem[]; + metadata?: Stripe.Metadata; + status?: Stripe.Invoice.Status; + amount_due?: number; + } = {} +): Stripe.Invoice { + const lines = overrides.lines ?? [seatInvoiceLine(72_000), passInvoiceLine(4_900)]; + const metadata = overrides.metadata ?? { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_19', + cadence: 'monthly', + }; + return { + id: overrides.id ?? 'in_test', + object: 'invoice', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + status: overrides.status ?? 'draft', + currency: 'usd', + customer: 'cus_1', + amount_due: overrides.amount_due ?? lines.reduce((sum, line) => sum + (line.amount ?? 0), 0), + amount_paid: 0, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata, + subscription: 'sub_1', + }, + }, + metadata, + lines: { + object: 'list', + data: lines, + has_more: false, + url: '/v1/invoices/in_test/lines', + }, + } as Stripe.Invoice; +} + +describe('organization Kilo Pass service-fee attachment', () => { + test('mixed seats and pass charge one fee on the net Kilo Pass product only', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params.amount).toBe(245); + expect(params.discountable).toBe(false); + return { id: 'ii_fee', amount: 245 }; + }); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice(), + subscription: subscription(), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }, + }); + expect(result.assessment).toMatchObject({ + flow: 'organization_kilo_pass', + eligibleSubtotalMinor: 4_900, + chargedFeeMinor: 245, + organizationId: 'org_1', + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('capacity-increase proration attaches one fee on the positive Kilo Pass net', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params.amount).toBe(150); + expect(params.discountable).toBe(false); + return { id: 'ii_fee', amount: 150 }; + }); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice({ + id: 'in_increase', + lines: [seatInvoiceLine(8_000), passInvoiceLine(3_000, { id: 'il_pass_proration' })], + }), + subscription: subscription(), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }); + expect(result.assessment).toMatchObject({ + eligibleSubtotalMinor: 3_000, + chargedFeeMinor: 150, + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('zero and negative net Kilo Pass omit the fee line', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 1 })); + const stripeClient = { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }; + const deps = { + getOrganizationPurchaseChannel: async () => 'self_serve' as const, + resolveTaxInput: async () => ({ + source: 'inline_inherit' as const, + }), + }; + + const zero = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice({ + id: 'in_zero', + lines: [seatInvoiceLine(72_000), passInvoiceLine(0, { id: 'il_zero' })], + }), + store: createMemoryAssessmentStore(), + stripe: stripeClient, + deps, + }); + const negative = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice({ + id: 'in_negative', + lines: [ + seatInvoiceLine(72_000), + passInvoiceLine(2_000, { id: 'il_pos' }), + passInvoiceLine(-5_000, { id: 'il_credit' }), + ], + }), + store: createMemoryAssessmentStore(), + stripe: stripeClient, + deps, + }); + + expect(zero.assessment).toMatchObject({ + outcome: 'zero_rounded', + expectedFeeMinor: 0, + chargedFeeMinor: 0, + }); + expect(negative.assessment).toMatchObject({ + outcome: 'zero_rounded', + eligibleSubtotalMinor: 0, + chargedFeeMinor: 0, + }); + expect(create).not.toHaveBeenCalled(); + }); + + test('exact organization exemption omits the fee and does not inherit', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const findEffectiveExemption: NonNullable< + KiloPassInvoiceCreatedDependencies['findEffectiveExemption'] + > = jest.fn(async organizationId => { + expect(organizationId).toBe('org_1'); + return { id: 'hist_exempt', isExempt: true }; + }); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice(), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + findEffectiveExemption, + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }, + }); + expect(findEffectiveExemption).toHaveBeenCalledWith( + 'org_1', + new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000) + ); + expect(result.assessment).toMatchObject({ + outcome: 'exempt', + exemptionId: 'hist_exempt', + chargedFeeMinor: 0, + expectedFeeMinor: 245, + }); + expect(create).not.toHaveBeenCalled(); + }); + + test('manual agreements stay fee-free', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice({ id: 'in_manual' }), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'manual', + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }, + }); + expect(result.assessment).toBeNull(); + expect(create).not.toHaveBeenCalled(); + }); + + test('tax resolution failure fails open as missed and does not attach a fee', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice({ id: 'in_tax' }), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + sendAlert, + }, + }); + expect(result.status).toBe('missed'); + expect(result.assessment).toMatchObject({ + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + chargedFeeMinor: 0, + expectedFeeMinor: 245, + }); + expect(create).not.toHaveBeenCalled(); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: SERVICE_FEE_FAILURE_APPLICATION }) + ); + }); + + test('injected tax creates one positive non-discountable fee line', async () => { + const { attachOrganizationKiloPassServiceFeeToDraftInvoice } = await import('./stripe-adapter'); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params).toMatchObject({ + customer: 'cus_1', + invoice: 'in_test', + amount: 245, + currency: 'usd', + description: SERVICE_FEE_DESCRIPTION, + discountable: false, + tax_behavior: 'exclusive', + }); + return { id: 'ii_fee', amount: 245 }; + }); + const result = await attachOrganizationKiloPassServiceFeeToDraftInvoice({ + invoice: mixedDraftInvoice(), + store: createMemoryAssessmentStore(), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + invoiceItems: { create }, + subscriptions: { retrieve }, + }, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }); + expect(result.status).toBe('charged'); + expect(result.assessment?.chargedFeeMinor).toBe(245); + expect(create).toHaveBeenCalledTimes(1); + }); +}); + +describe('organization Kilo Pass seat-capacity fee preparation', () => { + const prorationDate = SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + const now = new Date(prorationDate * 1000); + + beforeEach(() => { + createPreview.mockReset(); + invoiceItemCreate.mockReset(); + }); + + function previewInvoice(lines: Stripe.InvoiceLineItem[]): Stripe.Invoice { + return { + id: 'in_preview', + object: 'invoice', + created: prorationDate, + status: 'draft', + currency: 'usd', + customer: 'cus_1', + lines: { + object: 'list', + data: lines, + has_more: false, + url: '/v1/invoices/upcoming/lines', + }, + } as Stripe.Invoice; + } + + function seatCapacityStripe(options: { + previewLines: Stripe.InvoiceLineItem[]; + create?: typeof invoiceItemCreate; + }) { + createPreview.mockResolvedValue(previewInvoice(options.previewLines)); + return { + invoices: { + createPreview, + listLineItems: async () => ({ data: [], has_more: false as const }), + }, + invoiceItems: { create: options.create ?? invoiceItemCreate }, + }; + } + + test('previews seat+pass quantities and charges only the Kilo Pass net', async () => { + const { + prepareOrganizationKiloPassSeatCapacityFee, + createSeatCapacityServiceFeeAssessmentKey, + } = await import('./stripe-adapter'); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params.amount).toBe(150); + expect(params.invoice).toBeUndefined(); + return { id: 'ii_fee', amount: 150 }; + }); + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ customer: 'cus_1' }), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store: createMemoryAssessmentStore(), + stripe: seatCapacityStripe({ + previewLines: [seatInvoiceLine(8_000), passInvoiceLine(3_000, { id: 'il_pass_proration' })], + create, + }), + deps: { + now, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }); + + expect(createPreview).toHaveBeenCalledWith( + expect.objectContaining({ + subscription: 'sub_1', + subscription_details: expect.objectContaining({ + proration_date: prorationDate, + proration_behavior: 'always_invoice', + items: [ + { id: 'si_seat', quantity: 10 }, + { id: 'si_pass', quantity: 10 }, + ], + }), + }) + ); + expect(prepared.shouldAttach).toBe(true); + expect(prepared.assessment).toMatchObject({ + assessmentKey: createSeatCapacityServiceFeeAssessmentKey({ + subscriptionId: 'sub_1', + prorationDate, + paidSeatQuantity: 10, + }), + eligibleSubtotalMinor: 3_000, + expectedFeeMinor: 150, + outcome: 'pending', + }); + expect(prepared.feeInvoiceItem).toMatchObject({ + amount: 150, + discountable: false, + tax_behavior: 'exclusive', + }); + expect(prepared.feeInvoiceItem).not.toHaveProperty('invoice'); + expect(create).not.toHaveBeenCalled(); + }); + + test('reuses a pending staged item for the same assessment', async () => { + const { + prepareOrganizationKiloPassSeatCapacityFee, + stagePreparedOrganizationKiloPassServiceFeeItem, + } = await import('./stripe-adapter'); + const pendingItems: Stripe.InvoiceItem[] = []; + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + const item = { + id: 'ii_pending_fee', + amount: params.amount ?? 0, + metadata: params.metadata ?? {}, + } as Stripe.InvoiceItem; + pendingItems.push(item); + return item; + }); + const list = jest.fn(async () => ({ data: pendingItems, has_more: false })); + const stripeClient = { + ...seatCapacityStripe({ + previewLines: [seatInvoiceLine(8_000), passInvoiceLine(3_000)], + create, + }), + invoiceItems: { create, list }, + }; + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ customer: 'cus_1' }), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store: createMemoryAssessmentStore(), + stripe: stripeClient, + deps: { + now, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }, + }); + + await expect( + stagePreparedOrganizationKiloPassServiceFeeItem({ prepared, stripe: stripeClient }) + ).resolves.toBe('ii_pending_fee'); + await expect( + stagePreparedOrganizationKiloPassServiceFeeItem({ prepared, stripe: stripeClient }) + ).resolves.toBe('ii_pending_fee'); + expect(create).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledTimes(2); + }); + + test('seat-only subscriptions skip preview and assessment', async () => { + const { prepareOrganizationKiloPassSeatCapacityFee } = await import('./stripe-adapter'); + const store = createMemoryAssessmentStore(); + const insert = jest.spyOn(store, 'insert'); + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ + metadata: { type: 'seats', organizationId: 'org_1' }, + items: { + object: 'list', + data: [ + { + id: 'si_seat', + quantity: 5, + price: { id: 'price_seat', recurring: { interval: 'month' } }, + current_period_start: 1_767_225_600, + current_period_end: 1_769_904_000, + }, + ], + has_more: false, + url: '/v1/subscription_items', + }, + } as unknown as Partial), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store, + stripe: seatCapacityStripe({ previewLines: [seatInvoiceLine(8_000)] }), + deps: { + now, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }, + }); + expect(prepared.shouldAttach).toBe(false); + expect(prepared.assessment).toBeNull(); + expect(createPreview).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + + test('manual agreements skip preview and assessment', async () => { + const { prepareOrganizationKiloPassSeatCapacityFee } = await import('./stripe-adapter'); + const store = createMemoryAssessmentStore(); + const insert = jest.spyOn(store, 'insert'); + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ customer: 'cus_1' }), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store, + stripe: seatCapacityStripe({ + previewLines: [seatInvoiceLine(8_000), passInvoiceLine(3_000)], + }), + deps: { + now, + getOrganizationPurchaseChannel: async () => 'manual', + }, + }); + expect(prepared.shouldAttach).toBe(false); + expect(prepared.assessment).toBeNull(); + expect(createPreview).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + + test('tax resolution failure persists missed and does not prepare an item', async () => { + const { prepareOrganizationKiloPassSeatCapacityFee } = await import('./stripe-adapter'); + const sendAlert = jest.fn(async (_input: { failureCode: string }) => undefined); + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ customer: 'cus_1' }), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store: createMemoryAssessmentStore(), + stripe: seatCapacityStripe({ + previewLines: [seatInvoiceLine(8_000), passInvoiceLine(3_000)], + }), + deps: { + now, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + sendAlert, + }, + }); + expect(prepared.shouldAttach).toBe(false); + expect(prepared.feeInvoiceItem).toBeNull(); + expect(prepared.assessment).toMatchObject({ + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + eligibleSubtotalMinor: 3_000, + expectedFeeMinor: 150, + }); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: SERVICE_FEE_FAILURE_APPLICATION }) + ); + }); + + test('attach uses the prepared item on the draft invoice and marks charged', async () => { + const { + prepareOrganizationKiloPassSeatCapacityFee, + attachPreparedOrganizationKiloPassServiceFee, + } = await import('./stripe-adapter'); + const store = createMemoryAssessmentStore(); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params).toMatchObject({ + invoice: 'in_actual', + amount: 150, + discountable: false, + }); + return { id: 'ii_fee', amount: 150 }; + }); + const prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription: subscription({ customer: 'cus_1' }), + paidSeatItemId: 'si_seat', + paidSeatQuantity: 10, + isIncreasingSeats: true, + prorationDate, + store, + stripe: seatCapacityStripe({ + previewLines: [seatInvoiceLine(8_000), passInvoiceLine(3_000)], + create, + }), + deps: { + now, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }, + }); + const charged = await attachPreparedOrganizationKiloPassServiceFee({ + prepared, + invoice: mixedDraftInvoice({ + id: 'in_actual', + lines: [seatInvoiceLine(8_000), passInvoiceLine(3_000)], + }), + store, + stripe: { invoiceItems: { create } }, + deps: { now }, + }); + expect(create).toHaveBeenCalledTimes(1); + expect(charged).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 150, + stripeInvoiceId: 'in_actual', + stripeInvoiceFeeLineItemId: 'ii_fee', + }); + }); +}); + +describe('organization Kilo Pass invoice.paid settlement', () => { + const organizationId = '11111111-1111-4111-8111-111111111111'; + + beforeEach(() => { + jest.resetAllMocks(); + const rows = [dbAgreement({ parent_organization_id: organizationId })]; + select.mockReturnValue({ + from: () => ({ + where: () => ({ + limit: async () => rows, + orderBy: () => ({ limit: async () => rows }), + }), + }), + }); + listLineItems.mockResolvedValue({ data: [], has_more: false }); + }); + + async function persistOrgAssessment(input: { + store: ServiceFeeAssessmentStore; + invoiceId: string; + outcome: 'charged' | 'exempt' | 'missed'; + }) { + const assessmentKey = createInvoiceServiceFeeAssessmentKey(input.invoiceId); + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey, + flow: 'organization_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + eligibleSubtotalMinor: 4_900, + kiloUserId: 'user_1', + organizationId, + stripeCustomerId: 'cus_1', + }, + { + findEffectiveExemption: + input.outcome === 'exempt' + ? async () => ({ id: 'hist_exempt', isExempt: true }) + : async () => null, + } + ); + const record = await upsertServiceFeeAssessment({ + store: input.store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeInvoiceId: input.invoiceId, + stripeInvoiceFeeLineItemId: + input.outcome === 'charged' ? `il_fee_${input.invoiceId}` : null, + }, + }); + if (input.outcome === 'charged') { + return markServiceFeeAssessmentCharged({ + store: input.store, + assessmentKey: record.assessmentKey, + chargedFeeMinor: 245, + stripeIds: { stripeInvoiceFeeLineItemId: `il_fee_${input.invoiceId}` }, + }); + } + if (input.outcome === 'missed' && decision.outcome === 'pending') { + return markServiceFeeAssessmentMissed({ + store: input.store, + assessmentKey: record.assessmentKey, + failureCode: 'fee_application_failed', + }); + } + return record; + } + + function paidOrgInvoice(input: { + invoiceId: string; + assessmentKey: string; + includeFeeLine?: boolean; + }): Stripe.Invoice { + const lines = [ + passInvoiceLine(4_900, { + id: 'line_pass_settle', + period: { start: 1_767_528_000, end: 1_769_904_000 }, + quantity: 2, + }), + seatInvoiceLine(72_000), + ]; + if (input.includeFeeLine !== false) { + lines.unshift({ + ...passInvoiceLine(245, { id: `il_fee_${input.invoiceId}` }), + metadata: buildServiceFeeLineMetadata(input.assessmentKey), + pricing: null, + } as Stripe.InvoiceLineItem); + } + return { + ...mixedDraftInvoice({ id: input.invoiceId, lines }), + status: 'paid', + amount_paid: input.includeFeeLine === false ? 4_900 : 5_145, + status_transitions: { + finalized_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + marked_uncollectible_at: null, + paid_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 10, + voided_at: null, + }, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: { + type: 'kilo-pass-org', + organizationId, + kiloUserId: 'user_1', + serviceFeeAssessmentKey: input.assessmentKey, + }, + subscription: 'sub_1', + }, + }, + } as Stripe.Invoice; + } + + test.each(['charged', 'exempt', 'missed'] as const)( + 'settles a %s assessment before activating the agreement', + async outcome => { + retrieve.mockResolvedValue(subscription()); + const store = createMemoryAssessmentStore(); + const invoiceId = `in_org_${outcome}`; + const record = await persistOrgAssessment({ store, invoiceId, outcome }); + const invoice = paidOrgInvoice({ + invoiceId, + assessmentKey: record.assessmentKey, + includeFeeLine: outcome === 'charged', + }); + activatePaidAgreement.mockImplementation(async () => { + expect(await store.findByAssessmentKey(record.assessmentKey)).toMatchObject({ + settledAt: expect.any(String), + outcome, + settledProductMinor: 4_900, + }); + }); + const { handleOrganizationKiloPassInvoicePaid } = await import('./stripe-adapter'); + + await expect( + handleOrganizationKiloPassInvoicePaid({ + invoice, + serviceFee: { + store, + stripe: { invoices: { listLineItems } } as KiloPassServiceFeeSettlementStripe, + }, + }) + ).resolves.toBe(true); + expect(activatePaidAgreement).toHaveBeenCalled(); + expect(await store.findByAssessmentKey(record.assessmentKey)).toMatchObject({ + settledAt: expect.any(String), + outcome, + }); + } + ); + + test('settlement before activation is idempotent', async () => { + retrieve.mockResolvedValue(subscription()); + const store = createMemoryAssessmentStore(); + const invoiceId = 'in_org_idempotent'; + const record = await persistOrgAssessment({ + store, + invoiceId, + outcome: 'charged', + }); + const invoice = paidOrgInvoice({ + invoiceId, + assessmentKey: record.assessmentKey, + }); + const { handleOrganizationKiloPassInvoicePaid } = await import('./stripe-adapter'); + const params = { + invoice, + serviceFee: { + store, + stripe: { invoices: { listLineItems } } as KiloPassServiceFeeSettlementStripe, + }, + }; + + await handleOrganizationKiloPassInvoicePaid(params); + const first = await store.findByAssessmentKey(record.assessmentKey); + await handleOrganizationKiloPassInvoicePaid(params); + const second = await store.findByAssessmentKey(record.assessmentKey); + expect(second?.settledAt).toBe(first?.settledAt); + expect(second).toMatchObject({ + settledProductMinor: 4_900, + chargedFeeMinor: 245, + outcome: 'charged', + }); + expect(activatePaidAgreement).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/lib/kilo-pass-org/stripe-adapter.ts b/apps/web/src/lib/kilo-pass-org/stripe-adapter.ts index 17f2298013..75cd018e18 100644 --- a/apps/web/src/lib/kilo-pass-org/stripe-adapter.ts +++ b/apps/web/src/lib/kilo-pass-org/stripe-adapter.ts @@ -1,11 +1,13 @@ import 'server-only'; +import { randomUUID } from 'node:crypto'; import type Stripe from 'stripe'; import { and, desc, eq, ne } from 'drizzle-orm'; import { kilo_pass_org_agreements, organization_seats_purchases } from '@kilocode/db/schema'; import { KiloPassOrgAgreementState, KiloPassOrgProcessingCondition, + KiloPassOrgPurchaseChannel, } from '@kilocode/db/schema-types'; import type { KiloPassCadence, KiloPassTier } from '@/lib/kilo-pass/enums'; import { db } from '@/lib/drizzle'; @@ -15,6 +17,54 @@ import { getStripePriceIdForKiloPass, } from '@/lib/kilo-pass/stripe-price-ids.server'; import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; +import { + handleKiloPassInvoiceCreated, + SERVICE_FEE_FAILURE_APPLICATION, + type KiloPassInvoiceCreatedDependencies, + type KiloPassInvoiceCreatedResult, + type KiloPassInvoiceCreatedStripe, +} from '@/lib/service-fees/invoice-created'; +import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; +import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions'; +import { + buildServiceFeeCommercialMetadata, + isEligibleKiloPassInvoiceLine, + isServiceFeeInvoiceLine, + isServiceFeeMetadata, + listAllInvoiceLineItems, + sumEligibleKiloPassSubtotalMinor, + type InvoiceLineItemListClient, +} from '@/lib/service-fees/stripe-lines'; +import { + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + prepareServiceFeeAssessmentDecision, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, + type ServiceFeeStripeIds, +} from '@/lib/service-fees/assessments'; +import { buildAutoTopUpServiceFeeInvoiceItem } from '@/lib/service-fees/checkout'; +import { + resolveServiceFeeTaxInput, + type ServiceFeeTaxInput, + type ServiceFeeTaxPrincipal, + type StripePriceTaxReader, +} from '@/lib/service-fees/tax'; +import { + sendMissedServiceFeeAlert, + type MissedServiceFeeAlertInput, +} from '@/lib/service-fees/alerts'; +import { + settleKiloPassInvoiceServiceFee, + type KiloPassServiceFeeSettlementDependencies, + type KiloPassServiceFeeSettlementStripe, + type ServiceFeeSettlementStore, +} from '@/lib/service-fees/settlement'; +import { + SERVICE_FEE_SUPPORTED_CURRENCY, + type ServiceFeeCommercialMetadata, +} from '@/lib/service-fees/types'; import { activatePaidAgreement, bindProviderSeatAddOnItem, @@ -55,8 +105,71 @@ function periodForItem(item: Stripe.SubscriptionItem) { }; } -function organizationPassItem(subscription: Stripe.Subscription) { - const item = subscription.items.data.find(item => !isSeatLineItem(item)); +function isPendingProviderItemId(itemId: string | null | undefined): boolean { + return typeof itemId === 'string' && itemId.startsWith('pending:'); +} + +function isServiceFeeSubscriptionItem(item: Stripe.SubscriptionItem): boolean { + if (isServiceFeeMetadata(item.metadata) || isServiceFeeMetadata(item.price?.metadata)) { + return true; + } + const product = item.price?.product; + return ( + typeof product === 'object' && + product !== null && + !product.deleted && + isServiceFeeMetadata(product.metadata) + ); +} + +function isCandidateOrganizationPassItem(item: Stripe.SubscriptionItem): boolean { + return !isSeatLineItem(item) && !isServiceFeeSubscriptionItem(item); +} + +/** + * Locate the bound Kilo Pass organization subscription item. + * Order: persisted provider item id, known Kilo Pass Price id, then metadata. + * Seat and service-fee items are never candidates. The fee must not change + * which item, period, quantity, or capacity this returns. + */ +export function resolveOrganizationKiloPassSubscriptionItem(input: { + subscription: Stripe.Subscription; + boundProviderItemId?: string | null; +}): Stripe.SubscriptionItem | undefined { + const items = input.subscription.items.data; + const boundId = input.boundProviderItemId; + if (boundId && !isPendingProviderItemId(boundId)) { + const bound = items.find(item => item.id === boundId && isCandidateOrganizationPassItem(item)); + if (bound) return bound; + } + + const knownPriceIds = new Set(getKnownStripePriceIdsForKiloPass()); + const byKnownPrice = items.find( + item => isCandidateOrganizationPassItem(item) && knownPriceIds.has(item.price.id) + ); + if (byKnownPrice) return byKnownPrice; + + const byItemMetadata = items.find( + item => + isCandidateOrganizationPassItem(item) && + getOrganizationKiloPassMetadata(item.metadata) !== null + ); + if (byItemMetadata) return byItemMetadata; + + if (!getOrganizationKiloPassMetadata(input.subscription.metadata)) return undefined; + const metadataCandidates = items.filter(isCandidateOrganizationPassItem); + if (metadataCandidates.length === 1) return metadataCandidates[0]; + return undefined; +} + +function organizationPassItem( + subscription: Stripe.Subscription, + boundProviderItemId?: string | null +) { + const item = resolveOrganizationKiloPassSubscriptionItem({ + subscription, + boundProviderItemId, + }); if (!item) throw new Error(`Subscription ${subscription.id} has no Kilo Pass organization item`); return item; } @@ -81,11 +194,869 @@ function paidSeatItem(subscription: Stripe.Subscription) { return item; } +export type OrganizationKiloPassServiceFeeAttachment = { + invoice: Stripe.Invoice; + subscription?: Stripe.Subscription | null; + stripe?: KiloPassInvoiceCreatedStripe; + store?: ServiceFeeAssessmentStore; + deps?: KiloPassInvoiceCreatedDependencies; +}; + +async function lookupOrganizationKiloPassPurchaseChannel( + organizationId: string +): Promise<'self_serve' | 'manual' | null> { + const [agreement] = await db + .select({ purchaseChannel: kilo_pass_org_agreements.purchase_channel }) + .from(kilo_pass_org_agreements) + .where( + and( + eq(kilo_pass_org_agreements.parent_organization_id, organizationId), + ne(kilo_pass_org_agreements.state, KiloPassOrgAgreementState.Ended) + ) + ) + .orderBy(desc(kilo_pass_org_agreements.created_at)) + .limit(1); + if ( + agreement?.purchaseChannel === KiloPassOrgPurchaseChannel.SelfServe || + agreement?.purchaseChannel === KiloPassOrgPurchaseChannel.Manual + ) { + return agreement.purchaseChannel; + } + return null; +} + +/** + * Attach at most one non-discountable service fee to a self-service org Kilo + * Pass draft invoice. The fee is calculated from the aggregate net Kilo Pass + * product only; seats and existing fee lines are excluded. Exact-organization + * exemption is used with no hierarchy inheritance. Tax-unapproved and other + * fee-domain failures persist `missed` and return normally. + * + * Used by `createOrganizationKiloPassCheckout`, which already holds the draft + * invoice id. `handleUpdateSeatCount` must not call this: it previews, decides, + * and persists outside the seat advisory lock, then attaches only the prepared + * invoice item with `attachPreparedOrganizationKiloPassServiceFee`. + * Resolve the pass item with `resolveOrganizationKiloPassSubscriptionItem` + * instead of first-non-seat; this attach never changes the bound item, period, + * quantity, or capacity. + */ +export async function attachOrganizationKiloPassServiceFeeToDraftInvoice( + params: OrganizationKiloPassServiceFeeAttachment +): Promise { + const createdStores = params.store === undefined ? createServiceFeeStores() : undefined; + const store = params.store ?? createdStores?.assessments; + if (store === undefined) { + throw new Error('organization Kilo Pass service-fee store is unavailable'); + } + return handleKiloPassInvoiceCreated({ + invoice: params.invoice, + stripe: params.stripe ?? { + prices: stripe.prices, + invoices: stripe.invoices, + invoiceItems: stripe.invoiceItems, + subscriptions: stripe.subscriptions, + }, + store, + deps: { + findEffectiveExemption: createdStores + ? async (organizationId, at) => + getEffectiveOrganizationServiceFeeExemption({ + store: createdStores.exemptions, + organizationId, + at, + }) + : undefined, + getOrganizationPurchaseChannel: lookupOrganizationKiloPassPurchaseChannel, + ...params.deps, + ownsSynchronousAttachment: true, + }, + }); +} + +const SERVICE_FEE_FAILURE_INVOICE_NOT_DRAFT = 'invoice_not_draft' as const; + +export type OrganizationKiloPassSeatCapacityStripe = InvoiceLineItemListClient & { + invoices: InvoiceLineItemListClient['invoices'] & { + createPreview( + params: Stripe.InvoiceCreatePreviewParams + ): Promise< + Pick + >; + }; + invoiceItems?: { + create( + params: Stripe.InvoiceItemCreateParams + ): Promise>; + list?( + params: Stripe.InvoiceItemListParams + ): Promise, 'data' | 'has_more'>>; + del?(id: string): Promise; + }; + prices?: StripePriceTaxReader['prices']; +}; + +export type OrganizationKiloPassSeatCapacityFeeDependencies = KiloPassInvoiceCreatedDependencies & { + now?: Date; + resolveTaxInput?: (params: { + principal: ServiceFeeTaxPrincipal; + stripe?: StripePriceTaxReader; + }) => Promise; + sendAlert?: (input: MissedServiceFeeAlertInput) => Promise; +}; + +export type PreparedOrganizationKiloPassSeatCapacityFee = { + prorationDate: number; + organizationPassItemId: string | null; + shouldAttach: boolean; + assessmentKey: string | null; + assessment: ServiceFeeAssessmentRecord | null; + feeInvoiceItem: Omit | null; + expectedFeeMinor: number; + commercialMetadata: ServiceFeeCommercialMetadata | null; +}; + +export function createSeatCapacityServiceFeeAssessmentKey(input: { + subscriptionId: string; + prorationDate: number; + paidSeatQuantity: number; +}): string { + return `seat-capacity:${input.subscriptionId}:${input.prorationDate}:${input.paidSeatQuantity}`; +} + +export function createOrgCheckoutServiceFeeAssessmentKey(id: string = randomUUID()): string { + return `org-checkout:${id}`; +} + +function emptyPreparedSeatCapacityFee( + prorationDate: number, + organizationPassItemId: string | null = null +): PreparedOrganizationKiloPassSeatCapacityFee { + return { + prorationDate, + organizationPassItemId, + shouldAttach: false, + assessmentKey: null, + assessment: null, + feeInvoiceItem: null, + expectedFeeMinor: 0, + commercialMetadata: null, + }; +} + +/** + * Preview a seat+pass quantity increase and persist the fee decision before + * `handleUpdateSeatCount` takes the advisory lock. Seat-only, decreases, + * missing org identity, and manual agreements skip preview and assessment. + * Tax-unapproved and other fee-domain failures persist `missed` and return + * without an attachable item so the base update can continue. + */ +export async function prepareOrganizationKiloPassSeatCapacityFee(input: { + subscription: Stripe.Subscription; + paidSeatItemId: string; + paidSeatQuantity: number; + isIncreasingSeats: boolean; + prorationDate: number; + stripe?: OrganizationKiloPassSeatCapacityStripe; + store?: ServiceFeeAssessmentStore; + deps?: OrganizationKiloPassSeatCapacityFeeDependencies; +}): Promise { + const organizationPassItem = resolveOrganizationKiloPassSubscriptionItem({ + subscription: input.subscription, + }); + const empty = emptyPreparedSeatCapacityFee(input.prorationDate, organizationPassItem?.id ?? null); + if (!input.isIncreasingSeats || !organizationPassItem) { + return empty; + } + + const metadata = getOrganizationKiloPassMetadata(input.subscription.metadata); + if (!metadata) { + return empty; + } + + const deps = input.deps ?? {}; + const now = deps.now ?? new Date(input.prorationDate * 1000); + const getChannel = + deps.getOrganizationPurchaseChannel ?? lookupOrganizationKiloPassPurchaseChannel; + const channel = await getChannel(metadata.organizationId); + if (channel !== 'self_serve') { + return empty; + } + + const stripeClient = input.stripe ?? defaultSeatCapacityStripe(); + const createdStores = input.store === undefined ? createServiceFeeStores() : undefined; + const store = input.store ?? createdStores?.assessments; + if (store === undefined) { + throw new Error('organization Kilo Pass seat-capacity service-fee store is unavailable'); + } + + const assessmentKey = createSeatCapacityServiceFeeAssessmentKey({ + subscriptionId: input.subscription.id, + prorationDate: input.prorationDate, + paidSeatQuantity: input.paidSeatQuantity, + }); + const customer = customerIdFromReference(input.subscription.customer); + const stripeIds: ServiceFeeStripeIds = { stripeCustomerId: customer }; + + try { + const existing = await store.findByAssessmentKey(assessmentKey); + if (existing && (existing.outcome === 'charged' || existing.outcome === 'missed')) { + return { + ...empty, + assessmentKey, + assessment: existing, + expectedFeeMinor: existing.expectedFeeMinor, + }; + } + + const preview = await stripeClient.invoices.createPreview({ + customer: customer ?? undefined, + subscription: input.subscription.id, + subscription_details: { + items: [ + { id: input.paidSeatItemId, quantity: input.paidSeatQuantity }, + { id: organizationPassItem.id, quantity: input.paidSeatQuantity }, + ], + proration_behavior: 'always_invoice', + proration_date: input.prorationDate, + }, + expand: ['lines.data'], + }); + const lines = + preview.lines?.has_more && preview.id + ? await listAllInvoiceLineItems({ + invoice: { id: preview.id, lines: preview.lines }, + stripe: stripeClient, + }) + : (preview.lines?.data ?? []); + const eligibleSubtotalMinor = sumEligibleKiloPassSubtotalMinor({ + lines, + currency: preview.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + subscription: input.subscription, + }); + const findEffectiveExemption = + deps.findEffectiveExemption ?? + (createdStores + ? async (organizationId, at) => + getEffectiveOrganizationServiceFeeExemption({ + store: createdStores.exemptions, + organizationId, + at, + }) + : undefined); + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey, + flow: 'organization_kilo_pass', + currency: preview.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt: now, + eligibleSubtotalMinor, + kiloUserId: metadata.kiloUserId, + organizationId: metadata.organizationId, + stripeCustomerId: customer ?? undefined, + }, + { findEffectiveExemption } + ); + const commercialMetadata = buildServiceFeeCommercialMetadata({ + assessmentKey, + flow: decision.flow, + organizationId: metadata.organizationId, + }); + + const record = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds, + now, + }); + if (record.outcome !== 'pending') { + return { + prorationDate: input.prorationDate, + organizationPassItemId: organizationPassItem.id, + shouldAttach: false, + assessmentKey, + assessment: record, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + + const resolveTax = deps.resolveTaxInput ?? resolveServiceFeeTaxInput; + let taxInput: ServiceFeeTaxInput; + try { + taxInput = await resolveTax({ + principal: taxPrincipalFromLines(lines, input.subscription), + stripe: stripeClient.prices ? { prices: stripeClient.prices } : undefined, + }); + } catch { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + return { + prorationDate: input.prorationDate, + organizationPassItemId: organizationPassItem.id, + shouldAttach: false, + assessmentKey, + assessment: missed.assessment, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + if (!customer) { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + return { + prorationDate: input.prorationDate, + organizationPassItemId: organizationPassItem.id, + shouldAttach: false, + assessmentKey, + assessment: missed.assessment, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + + const feeInvoiceItem = buildAutoTopUpServiceFeeInvoiceItem({ + assessmentKey, + invoiceId: 'pending', + customerId: customer, + feeMinor: decision.expectedFeeMinor, + taxInput, + }); + const { invoice: _pendingInvoice, ...feeInvoiceItemWithoutInvoice } = feeInvoiceItem; + return { + prorationDate: input.prorationDate, + organizationPassItemId: organizationPassItem.id, + shouldAttach: true, + assessmentKey, + assessment: record, + feeInvoiceItem: feeInvoiceItemWithoutInvoice, + expectedFeeMinor: decision.expectedFeeMinor, + commercialMetadata, + }; + } catch (error) { + await alertSeatCapacitySafely({ + assessmentKey, + flow: 'organization_kilo_pass', + kiloUserId: metadata.kiloUserId, + organizationId: metadata.organizationId, + eligibleSubtotalMinor: 0, + expectedFeeMinor: 0, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + deps, + now, + }); + return empty; + } +} + +/** + * Attach one already-prepared non-discountable fee item to the draft invoice + * returned by `subscriptions.update`. No preview, exemption, tax, or initial + * assessment write happens here. Attachment failure is fail-open. + */ +export async function attachPreparedOrganizationKiloPassServiceFee(input: { + prepared: PreparedOrganizationKiloPassSeatCapacityFee; + invoice: Stripe.Invoice; + stripe?: Pick; + store?: ServiceFeeAssessmentStore; + deps?: OrganizationKiloPassSeatCapacityFeeDependencies; + pendingInvoiceItemId?: string | null; +}): Promise { + const prepared = input.prepared; + if (!prepared.shouldAttach || !prepared.assessmentKey || !prepared.feeInvoiceItem) { + return prepared.assessment; + } + + const deps = input.deps ?? {}; + const now = deps.now ?? new Date(); + const createdStores = input.store === undefined ? createServiceFeeStores() : undefined; + const store = input.store ?? createdStores?.assessments; + if (store === undefined) { + return prepared.assessment; + } + + const stripeIds: ServiceFeeStripeIds = { + stripeCustomerId: prepared.assessment?.stripeCustomerId ?? null, + stripeInvoiceId: input.invoice.id, + }; + + try { + const existingFeeLine = (input.invoice.lines?.data ?? []).find(line => { + if (!isServiceFeeInvoiceLine(line)) return false; + return line.metadata?.serviceFeeAssessmentKey === prepared.assessmentKey; + }); + if (existingFeeLine) { + if (input.pendingInvoiceItemId) { + await discardStagedOrganizationKiloPassServiceFeeItem({ + invoiceItemId: input.pendingInvoiceItemId, + stripe: input.stripe, + }); + } + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: prepared.assessmentKey, + chargedFeeMinor: Math.max(0, existingFeeLine.amount), + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: existingFeeLine.id, + }, + now, + }); + } + + if (input.pendingInvoiceItemId) { + await discardStagedOrganizationKiloPassServiceFeeItem({ + invoiceItemId: input.pendingInvoiceItemId, + stripe: input.stripe, + }); + } + + if (input.invoice.status !== 'draft' || !input.invoice.id) { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey: prepared.assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_INVOICE_NOT_DRAFT, + record: prepared.assessment, + deps, + now, + }); + return missed.assessment; + } + + const create = + input.stripe?.invoiceItems?.create ?? stripe.invoiceItems.create.bind(stripe.invoiceItems); + const item = await create({ + ...prepared.feeInvoiceItem, + invoice: input.invoice.id, + }); + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: prepared.assessmentKey, + chargedFeeMinor: prepared.expectedFeeMinor, + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: item.id, + }, + now, + }); + } catch (error) { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey: prepared.assessmentKey, + stripeIds, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + record: prepared.assessment, + deps, + now, + }); + return missed.assessment; + } +} + +function defaultSeatCapacityStripe(): OrganizationKiloPassSeatCapacityStripe { + return { + invoices: stripe.invoices, + invoiceItems: stripe.invoiceItems, + prices: stripe.prices, + }; +} + +/** + * Create the non-discountable fee as a pending customer invoice item so + * `subscriptions.update` can pull it onto the invoice it immediately + * finalizes. Saved cards often leave no draft window for post-update attach. + */ +export async function stagePreparedOrganizationKiloPassServiceFeeItem(input: { + prepared: PreparedOrganizationKiloPassSeatCapacityFee; + stripe?: Pick; +}): Promise { + const prepared = input.prepared; + if (!prepared.shouldAttach || !prepared.feeInvoiceItem) return null; + const invoiceItems = input.stripe?.invoiceItems ?? stripe.invoiceItems; + const list = invoiceItems.list?.bind(invoiceItems); + const customer = prepared.feeInvoiceItem.customer; + if (list && typeof customer === 'string') { + try { + let startingAfter: string | undefined; + for (;;) { + const page = await list({ + customer, + pending: true, + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + const existing = page.data.find( + item => item.metadata?.serviceFeeAssessmentKey === prepared.assessmentKey + ); + if (existing) return existing.id; + if (!page.has_more) break; + const cursor = page.data.at(-1)?.id; + if (!cursor) return null; + startingAfter = cursor; + } + } catch { + return null; + } + } + const create = invoiceItems.create.bind(invoiceItems); + try { + const item = await create(prepared.feeInvoiceItem); + return item.id; + } catch { + return null; + } +} + +export async function discardStagedOrganizationKiloPassServiceFeeItem(input: { + invoiceItemId: string; + stripe?: Pick; +}): Promise { + const del = input.stripe?.invoiceItems?.del ?? stripe.invoiceItems.del?.bind(stripe.invoiceItems); + if (!del) return; + try { + await del(input.invoiceItemId); + } catch { + // Orphan cleanup is best-effort. Settlement still records missed. + } +} + +function taxPrincipalFromLines( + lines: readonly Stripe.InvoiceLineItem[], + subscription: Stripe.Subscription | null +): ServiceFeeTaxPrincipal { + const eligible = lines.find(line => isEligibleKiloPassInvoiceLine(line, subscription)); + const priceId = eligible?.pricing?.price_details?.price; + return priceId ? { kind: 'price', priceId } : { kind: 'inline' }; +} + +function customerIdFromReference( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined +): string | null { + if (typeof customer === 'string' && customer.trim()) return customer; + if (customer && typeof customer === 'object' && 'id' in customer && customer.id) { + return customer.id; + } + return null; +} + +async function persistSeatCapacityMissed(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + stripeIds: ServiceFeeStripeIds; + failureCode: string; + record: ServiceFeeAssessmentRecord | null; + deps: OrganizationKiloPassSeatCapacityFeeDependencies; + now: Date; +}): Promise<{ assessment: ServiceFeeAssessmentRecord | null }> { + try { + const missed = await markServiceFeeAssessmentMissed({ + store: params.store, + assessmentKey: params.assessmentKey, + failureCode: params.failureCode, + stripeIds: params.stripeIds, + now: params.now, + }); + await alertSeatCapacitySafely({ + assessmentKey: missed.assessmentKey, + flow: missed.flow, + kiloUserId: missed.kiloUserId, + organizationId: missed.organizationId, + stripeInvoiceId: missed.stripeInvoiceId, + eligibleSubtotalMinor: missed.eligibleSubtotalMinor, + expectedFeeMinor: missed.expectedFeeMinor, + failureCode: missed.failureCode ?? params.failureCode, + deps: params.deps, + now: params.now, + }); + return { assessment: missed }; + } catch (error) { + await alertSeatCapacitySafely({ + assessmentKey: params.assessmentKey, + flow: params.record?.flow ?? 'organization_kilo_pass', + kiloUserId: params.record?.kiloUserId, + organizationId: params.record?.organizationId, + stripeInvoiceId: params.stripeIds.stripeInvoiceId, + eligibleSubtotalMinor: params.record?.eligibleSubtotalMinor ?? 0, + expectedFeeMinor: params.record?.expectedFeeMinor ?? 0, + failureCode: failureCodeFromUnknown(error, params.failureCode), + deps: params.deps, + now: params.now, + }); + return { assessment: params.record }; + } +} + +async function alertSeatCapacitySafely(params: { + assessmentKey: string; + flow: MissedServiceFeeAlertInput['flow']; + kiloUserId?: string | null; + organizationId?: string | null; + stripeInvoiceId?: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + failureCode: string; + deps: OrganizationKiloPassSeatCapacityFeeDependencies; + now: Date; +}): Promise { + const sendAlert = params.deps.sendAlert ?? sendMissedServiceFeeAlert; + try { + await sendAlert({ + assessmentKey: params.assessmentKey, + flow: params.flow, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeInvoiceId: params.stripeInvoiceId, + eligibleSubtotalMinor: params.eligibleSubtotalMinor, + expectedFeeMinor: params.expectedFeeMinor, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + failureCode: params.failureCode, + attemptedAt: params.now, + }); + } catch { + // Slack/Sentry failure must not change the seat-capacity fee outcome. + } +} + +function failureCodeFromUnknown(error: unknown, fallback: string): string { + if (error instanceof Error && /^[a-z][a-z0-9_]{0,99}$/.test(error.message)) { + return error.message; + } + return fallback; +} + +function hasCreatePreview( + stripeClient: KiloPassInvoiceCreatedStripe | undefined +): stripeClient is OrganizationKiloPassSeatCapacityStripe { + const invoices = stripeClient?.invoices as { createPreview?: unknown } | undefined; + return typeof invoices?.createPreview === 'function'; +} + +/** + * Persist the org-checkout assessment before `subscriptions.update` so + * `invoice.created` can bind to `org-checkout:` and skip attachment. + * The checkout caller is the synchronous attachment owner. + */ +export async function prepareOrganizationKiloPassCheckoutFee(input: { + subscription: Stripe.Subscription; + priceId: string; + quantity: number; + organizationId: string; + kiloUserId: string; + assessmentKey: string; + stripe?: OrganizationKiloPassSeatCapacityStripe; + store?: ServiceFeeAssessmentStore; + deps?: OrganizationKiloPassSeatCapacityFeeDependencies; +}): Promise { + const empty = emptyPreparedSeatCapacityFee(Math.floor(Date.now() / 1000)); + const deps = input.deps ?? {}; + const now = deps.now ?? new Date(); + const stripeClient = input.stripe ?? defaultSeatCapacityStripe(); + const createdStores = input.store === undefined ? createServiceFeeStores() : undefined; + const store = input.store ?? createdStores?.assessments; + if (store === undefined) { + throw new Error('organization Kilo Pass checkout service-fee store is unavailable'); + } + + const customer = customerIdFromReference(input.subscription.customer); + const stripeIds: ServiceFeeStripeIds = { stripeCustomerId: customer }; + const commercialMetadata = buildServiceFeeCommercialMetadata({ + assessmentKey: input.assessmentKey, + flow: 'organization_kilo_pass', + organizationId: input.organizationId, + }); + + try { + const existing = await store.findByAssessmentKey(input.assessmentKey); + if (existing && (existing.outcome === 'charged' || existing.outcome === 'missed')) { + return { + ...empty, + assessmentKey: input.assessmentKey, + assessment: existing, + expectedFeeMinor: existing.expectedFeeMinor, + commercialMetadata, + }; + } + + const preview = await stripeClient.invoices.createPreview({ + customer: customer ?? undefined, + subscription: input.subscription.id, + subscription_details: { + items: [ + ...input.subscription.items.data.map(item => ({ + id: item.id, + quantity: item.quantity ?? 1, + })), + { price: input.priceId, quantity: input.quantity }, + ], + proration_behavior: 'always_invoice', + }, + expand: ['lines.data'], + }); + const lines = + preview.lines?.has_more && preview.id + ? await listAllInvoiceLineItems({ + invoice: { id: preview.id, lines: preview.lines }, + stripe: stripeClient, + }) + : (preview.lines?.data ?? []); + const eligibleSubtotalMinor = sumEligibleKiloPassSubtotalMinor({ + lines, + currency: preview.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + subscription: input.subscription, + }); + const findEffectiveExemption = + deps.findEffectiveExemption ?? + (createdStores + ? async (organizationId, at) => + getEffectiveOrganizationServiceFeeExemption({ + store: createdStores.exemptions, + organizationId, + at, + }) + : undefined); + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey: input.assessmentKey, + flow: 'organization_kilo_pass', + currency: preview.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt: now, + eligibleSubtotalMinor, + kiloUserId: input.kiloUserId, + organizationId: input.organizationId, + stripeCustomerId: customer ?? undefined, + }, + { findEffectiveExemption } + ); + const record = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds, + now, + }); + if (record.outcome !== 'pending') { + return { + prorationDate: empty.prorationDate, + organizationPassItemId: null, + shouldAttach: false, + assessmentKey: input.assessmentKey, + assessment: record, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + + const resolveTax = deps.resolveTaxInput ?? resolveServiceFeeTaxInput; + let taxInput: ServiceFeeTaxInput; + try { + taxInput = await resolveTax({ + principal: taxPrincipalFromLines(lines, input.subscription), + stripe: stripeClient.prices ? { prices: stripeClient.prices } : undefined, + }); + } catch { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey: input.assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + return { + prorationDate: empty.prorationDate, + organizationPassItemId: null, + shouldAttach: false, + assessmentKey: input.assessmentKey, + assessment: missed.assessment, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + if (!customer) { + const missed = await persistSeatCapacityMissed({ + store, + assessmentKey: input.assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + return { + prorationDate: empty.prorationDate, + organizationPassItemId: null, + shouldAttach: false, + assessmentKey: input.assessmentKey, + assessment: missed.assessment, + feeInvoiceItem: null, + expectedFeeMinor: record.expectedFeeMinor, + commercialMetadata, + }; + } + + const feeInvoiceItem = buildAutoTopUpServiceFeeInvoiceItem({ + assessmentKey: input.assessmentKey, + invoiceId: 'pending', + customerId: customer, + feeMinor: decision.expectedFeeMinor, + taxInput, + }); + const { invoice: _pendingInvoice, ...feeInvoiceItemWithoutInvoice } = feeInvoiceItem; + return { + prorationDate: empty.prorationDate, + organizationPassItemId: null, + shouldAttach: true, + assessmentKey: input.assessmentKey, + assessment: record, + feeInvoiceItem: feeInvoiceItemWithoutInvoice, + expectedFeeMinor: decision.expectedFeeMinor, + commercialMetadata, + }; + } catch (error) { + await alertSeatCapacitySafely({ + assessmentKey: input.assessmentKey, + flow: 'organization_kilo_pass', + kiloUserId: input.kiloUserId, + organizationId: input.organizationId, + eligibleSubtotalMinor: 0, + expectedFeeMinor: 0, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + deps, + now, + }); + return { + ...empty, + assessmentKey: input.assessmentKey, + commercialMetadata, + }; + } +} + export async function createOrganizationKiloPassCheckout(input: { organizationId: string; actorUserId: string; tier: 'tier_19' | 'tier_49' | 'tier_199'; allocations: { childOrganizationId: string; passCount: number }[]; + serviceFee?: Omit; }): Promise< { kind: 'payment_action'; clientSecret: string } | { kind: 'completed' } | { kind: 'pending' } > { @@ -105,7 +1076,7 @@ export async function createOrganizationKiloPassCheckout(input: { if (subscription.status !== 'active' || subscription.ended_at) { throw new Error('An active organization seat subscription is required'); } - const existingPassItem = subscription.items.data.find(item => !isSeatLineItem(item)); + const existingPassItem = resolveOrganizationKiloPassSubscriptionItem({ subscription }); if (existingPassItem) throw new Error('KILO_PASS_ORG_ALREADY_EXISTS'); const seatItem = paidSeatItem(subscription); const cadence = intervalToCadence(seatItem.price.recurring?.interval); @@ -132,28 +1103,93 @@ export async function createOrganizationKiloPassCheckout(input: { tier: input.tier as KiloPassTier, cadence: cadence as KiloPassCadence, }); - const updated = await stripe.subscriptions.update(subscription.id, { - payment_behavior: 'allow_incomplete', - proration_behavior: 'always_invoice', - items: [{ price, quantity: paidSeats }], - metadata: { - ...subscription.metadata, - type: ORGANIZATION_KILO_PASS_METADATA_TYPE, + const assessmentKey = createOrgCheckoutServiceFeeAssessmentKey(); + let prepared: PreparedOrganizationKiloPassSeatCapacityFee = { + ...emptyPreparedSeatCapacityFee(Math.floor(Date.now() / 1000)), + assessmentKey, + commercialMetadata: buildServiceFeeCommercialMetadata({ + assessmentKey, + flow: 'organization_kilo_pass', + organizationId: input.organizationId, + }), + }; + try { + prepared = await prepareOrganizationKiloPassCheckoutFee({ + subscription, + priceId: price, + quantity: paidSeats, organizationId: input.organizationId, kiloUserId: input.actorUserId, - tier: input.tier, - cadence, - }, - expand: ['latest_invoice.confirmation_secret'], + assessmentKey, + stripe: hasCreatePreview(input.serviceFee?.stripe) ? input.serviceFee.stripe : undefined, + store: input.serviceFee?.store, + deps: { + getOrganizationPurchaseChannel: async () => 'self_serve', + ...input.serviceFee?.deps, + }, + }); + } catch { + // Persist-before-update is best-effort. The base add-on still proceeds. + } + const pendingFeeItemId = await stagePreparedOrganizationKiloPassServiceFeeItem({ + prepared, + stripe: input.serviceFee?.stripe, }); + let updated: Stripe.Subscription; + try { + updated = await stripe.subscriptions.update(subscription.id, { + payment_behavior: 'allow_incomplete', + proration_behavior: 'always_invoice', + items: [{ price, quantity: paidSeats }], + metadata: { + ...subscription.metadata, + type: ORGANIZATION_KILO_PASS_METADATA_TYPE, + organizationId: input.organizationId, + kiloUserId: input.actorUserId, + tier: input.tier, + cadence, + }, + expand: ['latest_invoice.confirmation_secret', 'latest_invoice.lines'], + }); + } catch (error) { + if (pendingFeeItemId) { + await discardStagedOrganizationKiloPassServiceFeeItem({ + invoiceItemId: pendingFeeItemId, + stripe: input.serviceFee?.stripe, + }); + } + throw error; + } const passItem = organizationPassItem(updated); await bindProviderSeatAddOnItem({ agreementId: pending.agreementId, providerSeatAddOnItemId: passItem.id, }); const invoice = typeof updated.latest_invoice === 'object' ? updated.latest_invoice : null; - if (invoice?.status === 'paid' || invoice?.amount_due === 0) { - await handleOrganizationKiloPassInvoicePaid({ invoice }); + let feeResult: Awaited> | null = + null; + if (invoice && prepared.shouldAttach) { + feeResult = await attachPreparedOrganizationKiloPassServiceFee({ + prepared, + invoice, + stripe: input.serviceFee?.stripe, + store: input.serviceFee?.store, + deps: input.serviceFee?.deps, + pendingInvoiceItemId: pendingFeeItemId, + }); + } + if ( + invoice?.status === 'paid' || + (invoice?.amount_due === 0 && feeResult?.outcome !== 'charged') + ) { + await handleOrganizationKiloPassInvoicePaid({ + invoice, + serviceFee: { + store: input.serviceFee?.store, + stripe: input.serviceFee?.stripe, + deps: input.serviceFee?.deps, + }, + }); return { kind: 'completed' }; } let openPayments: Stripe.ApiList | null = null; @@ -181,18 +1217,39 @@ export async function createOrganizationKiloPassCheckout(input: { return { kind: 'pending' }; } -function invoiceLineForSubscriptionItem(invoice: Stripe.Invoice, itemId: string) { - return (invoice.lines?.data ?? []).find( - line => line.parent?.subscription_item_details?.subscription_item === itemId +function invoiceLinesForSubscriptionItem( + invoice: Stripe.Invoice, + itemId: string +): Stripe.InvoiceLineItem[] { + return (invoice.lines?.data ?? []).filter( + line => + !isServiceFeeInvoiceLine(line) && + line.parent?.subscription_item_details?.subscription_item === itemId ); } +function preferredInvoiceLine( + lines: readonly Stripe.InvoiceLineItem[] +): Stripe.InvoiceLineItem | undefined { + if (lines.length === 0) return undefined; + const positive = lines.filter(line => line.amount > 0); + const pool = positive.length > 0 ? positive : lines; + return [...pool].sort((left, right) => (right.quantity ?? 0) - (left.quantity ?? 0))[0]; +} + +function invoiceLineForSubscriptionItem(invoice: Stripe.Invoice, itemId: string) { + return preferredInvoiceLine(invoiceLinesForSubscriptionItem(invoice, itemId)); +} + function invoiceLineForKnownKiloPassPrice(invoice: Stripe.Invoice) { const knownPriceIds = new Set(getKnownStripePriceIdsForKiloPass()); - return (invoice.lines?.data ?? []).find(line => { - const priceId = line.pricing?.price_details?.price; - return priceId !== undefined && knownPriceIds.has(priceId); - }); + return preferredInvoiceLine( + (invoice.lines?.data ?? []).filter(line => { + if (isServiceFeeInvoiceLine(line)) return false; + const priceId = line.pricing?.price_details?.price; + return priceId !== undefined && knownPriceIds.has(priceId); + }) + ); } function linePeriod(line: Stripe.InvoiceLineItem) { @@ -216,9 +1273,49 @@ function isBridgeWindow(window: IssuanceWindow, paid: IssuanceWindow) { return paid.start > window.start || paid.end < window.end; } +async function settleOrganizationKiloPassInvoiceServiceFeeBeforeActivation(params: { + invoice: Stripe.Invoice; + subscription: Stripe.Subscription; + serviceFee?: + | { + store?: ServiceFeeAssessmentStore; + stripe?: KiloPassServiceFeeSettlementStripe; + deps?: KiloPassServiceFeeSettlementDependencies & { now?: Date }; + } + | undefined; +}): Promise { + const createdStores = + params.serviceFee?.store === undefined ? createServiceFeeStores() : undefined; + const store = (params.serviceFee?.store ?? createdStores?.assessments) as + | ServiceFeeSettlementStore + | undefined; + if (store === undefined) return; + try { + await settleKiloPassInvoiceServiceFee({ + invoice: params.invoice, + stripe: params.serviceFee?.stripe ?? { + invoices: stripe.invoices, + subscriptions: stripe.subscriptions, + }, + store, + subscription: params.subscription, + deps: params.serviceFee?.deps, + }); + } catch { + // Fee settlement must not block agreement activation after a successful payment. + } +} + export async function handleOrganizationKiloPassInvoicePaid(params: { invoice: Stripe.Invoice; paidSeatCount?: number; + serviceFee?: { + store?: ServiceFeeAssessmentStore; + stripe?: KiloPassServiceFeeSettlementStripe; + deps?: KiloPassServiceFeeSettlementDependencies & { + now?: Date; + }; + }; }) { const reference = params.invoice.parent?.subscription_details?.subscription; const subscriptionId = typeof reference === 'string' ? reference : reference?.id; @@ -238,18 +1335,20 @@ export async function handleOrganizationKiloPassInvoicePaid(params: { .orderBy(desc(kilo_pass_org_agreements.created_at)) .limit(1); if (!agreement) return false; - const boundItem = subscription.items.data.find( - item => item.id === agreement.provider_seat_add_on_item_id - ); - const item = boundItem ?? subscription.items.data.find(item => !isSeatLineItem(item)); + const item = resolveOrganizationKiloPassSubscriptionItem({ + subscription, + boundProviderItemId: agreement.provider_seat_add_on_item_id, + }); if (!item) return false; - if (!boundItem) { + if (agreement.provider_seat_add_on_item_id !== item.id) { await bindProviderSeatAddOnItem({ agreementId: agreement.id, providerSeatAddOnItemId: item.id, }); } - const line = invoiceLineForSubscriptionItem(params.invoice, item.id); + const line = + invoiceLineForSubscriptionItem(params.invoice, item.id) ?? + invoiceLineForKnownKiloPassPrice(params.invoice); if (!line) return false; const paidPeriod = linePeriod(line); const seatItem = subscription.items.data.find(isSeatLineItem); @@ -275,6 +1374,11 @@ export async function handleOrganizationKiloPassInvoicePaid(params: { const paidIncreaseSupersedesPendingCapacity = agreement.next_purchased_pass_capacity !== null && seats > agreement.next_purchased_pass_capacity; + await settleOrganizationKiloPassInvoiceServiceFeeBeforeActivation({ + invoice: params.invoice, + subscription, + serviceFee: params.serviceFee, + }); await activatePaidAgreement({ agreementId: agreement.id, recipientUserId: metadata.kiloUserId, @@ -482,7 +1586,10 @@ export async function handleOrganizationKiloPassSubscriptionEvent( .orderBy(desc(kilo_pass_org_agreements.created_at)) .limit(1); if (!agreement) return false; - const item = subscription.items.data.find(item => !isSeatLineItem(item)); + const item = resolveOrganizationKiloPassSubscriptionItem({ + subscription, + boundProviderItemId: agreement.provider_seat_add_on_item_id, + }); if (!item) { await db .update(kilo_pass_org_agreements) @@ -553,7 +1660,7 @@ export async function endPendingOrganizationKiloPassForTerminalInvoice(invoice: .orderBy(desc(kilo_pass_org_agreements.created_at)) .limit(1); if (!agreement) return false; - const isUnbound = agreement.provider_seat_add_on_item_id?.startsWith('pending:') === true; + const isUnbound = isPendingProviderItemId(agreement.provider_seat_add_on_item_id); const unboundPassLine = isUnbound ? invoiceLineForKnownKiloPassPrice(invoice) : undefined; if ( isUnbound @@ -564,10 +1671,10 @@ export async function endPendingOrganizationKiloPassForTerminalInvoice(invoice: } const subscription = await stripe.subscriptions.retrieve(subscriptionId); - const unboundPassPriceId = unboundPassLine?.pricing?.price_details?.price; - const passItem = isUnbound - ? subscription.items.data.find(item => item.price.id === unboundPassPriceId) - : subscription.items.data.find(item => item.id === agreement.provider_seat_add_on_item_id); + const passItem = resolveOrganizationKiloPassSubscriptionItem({ + subscription, + boundProviderItemId: isUnbound ? undefined : agreement.provider_seat_add_on_item_id, + }); if (passItem) { await stripe.subscriptions.update(subscriptionId, { proration_behavior: 'none', diff --git a/apps/web/src/lib/stripe-3ds.test.ts b/apps/web/src/lib/stripe-3ds.test.ts index aaa418eeb6..2eff8a8882 100644 --- a/apps/web/src/lib/stripe-3ds.test.ts +++ b/apps/web/src/lib/stripe-3ds.test.ts @@ -19,6 +19,8 @@ jest.mock('@/lib/kilo-pass-org/stripe-adapter', () => ({ handleOrganizationKiloPassInvoicePaid: jest.fn().mockResolvedValue(true), handleOrganizationKiloPassPaymentAdverseForInvoice: jest.fn(), handleOrganizationKiloPassSubscriptionEvent: jest.fn(), + stagePreparedOrganizationKiloPassServiceFeeItem: jest.fn().mockResolvedValue(null), + discardStagedOrganizationKiloPassServiceFeeItem: jest.fn().mockResolvedValue(undefined), })); // Mock organization-seats to avoid DB calls @@ -246,10 +248,13 @@ describe('handleUpdateSeatCount with 3DS', () => { expect.objectContaining({ success: true }) ); - expect(mockHandleOrganizationKiloPassInvoicePaid).toHaveBeenCalledWith({ - invoice: paidInvoice, - paidSeatCount: 10, - }); + expect(mockHandleOrganizationKiloPassInvoicePaid).toHaveBeenCalledWith( + expect.objectContaining({ + invoice: paidInvoice, + paidSeatCount: 10, + serviceFee: expect.any(Object), + }) + ); }); it('does not eagerly reconcile an organization Kilo Pass seat decrease', async () => { diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index 7cf4f22e0f..c99ec08349 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -61,8 +61,17 @@ import { ensurePaymentMethodStored, processStripePaymentEventHook, handleSuccessfulChargeWithPayment, + handleUpdateSeatCount, isCardFingerprintEligibleForFreeCredits, + KNOWN_SEAT_PRICE_IDS, } from '@/lib/stripe'; +import { client } from '@/lib/stripe-client'; +import * as kiloPassOrgStripe from '@/lib/kilo-pass-org/stripe-adapter'; +import { + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { SEAT_PRODUCT_IDS } from '@/lib/organizations/stripe-seat-line-items'; import { type User, payment_methods, @@ -82,6 +91,7 @@ import { impact_referral_conversions, impact_referral_reward_decisions, impact_referral_rewards, + stripe_service_fee_assessments, } from '@kilocode/db/schema'; import { db, auto_deleted_at } from '@/lib/drizzle'; import { insertTestUser } from '@/tests/helpers/user.helper'; @@ -3752,6 +3762,262 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } }); + test('invoice.created skips kilo-owned auto-top-up invoices and leaves seats/KiloClaw fee-free', async () => { + const { client } = await import('@/lib/stripe-client'); + const createInvoiceItem = jest.spyOn(client.invoiceItems, 'create'); + + try { + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { + object: { + id: 'in_auto_skip', + object: 'invoice', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { type: 'auto-topup', kiloUserId: 'user_skip' }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { + object: { + id: 'in_org_auto_skip', + object: 'invoice', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { type: 'org-auto-topup', organizationId: 'org_skip' }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { + object: { + id: 'in_seat_created', + object: 'invoice', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { type: 'seats' }, + lines: { + data: [ + { + pricing: { price_details: { price: process.env.STRIPE_TEAMS_MONTHLY_PRICE_ID } }, + }, + ], + }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { + object: { + id: 'in_kiloclaw_created', + object: 'invoice', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + lines: { + data: [ + { + pricing: { + price_details: { + price: process.env.STRIPE_KILOCLAW_2026_05_10_STANDARD_PRICE_ID, + }, + }, + }, + ], + }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + + expect(createInvoiceItem).not.toHaveBeenCalled(); + } finally { + createInvoiceItem.mockRestore(); + } + }); + + test('invoice.created assesses an eligible personal Kilo Pass draft without attaching a second auto-top-up fee', async () => { + await cleanupDbForTest(); + const user = await insertTestUser(); + const { client } = await import('@/lib/stripe-client'); + const createInvoiceItem = jest.spyOn(client.invoiceItems, 'create').mockResolvedValue({ + id: 'ii_kilo_pass_fee', + amount: 245, + } as unknown as Stripe.Response); + const retrievePrice = jest.spyOn(client.prices, 'retrieve').mockResolvedValue({ + id: CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID, + tax_behavior: 'exclusive', + } as unknown as Stripe.Response); + const invoiceId = `in_kilo_pass_created_${Math.random().toString(36).slice(2)}`; + const metadata = { + type: 'kilo-pass', + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }; + + try { + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { + object: { + id: invoiceId, + object: 'invoice', + status: 'draft', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + currency: 'usd', + customer: user.stripe_customer_id, + metadata, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata, + subscription: { + id: 'sub_kilo_pass_created', + object: 'subscription', + metadata, + items: { + object: 'list', + data: [], + has_more: false, + url: '/v1/subscription_items', + }, + }, + }, + }, + lines: { + object: 'list', + has_more: false, + url: `/v1/invoices/${invoiceId}/lines`, + data: [ + { + id: 'il_kilo_pass_created', + object: 'line_item', + amount: 4_900, + currency: 'usd', + description: 'Kilo Pass', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: invoiceId, + livemode: false, + metadata, + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: null, + pricing: { + type: 'price_details', + unit_amount_decimal: '4900', + price_details: { + price: CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID, + product: 'prod_kilo_pass', + }, + }, + quantity: 1, + subscription: null, + taxes: null, + }, + ], + }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + + const [assessment] = await db + .select() + .from(stripe_service_fee_assessments) + .where(eq(stripe_service_fee_assessments.stripe_invoice_id, invoiceId)) + .limit(1); + expect(assessment).toMatchObject({ + flow: 'personal_kilo_pass', + kilo_user_id: user.id, + eligible_subtotal_minor: 4_900, + expected_fee_minor: 245, + }); + expect(assessment?.outcome === 'charged' || assessment?.outcome === 'missed').toBe(true); + if (assessment?.outcome === 'missed') { + expect(createInvoiceItem).not.toHaveBeenCalled(); + } else { + expect(createInvoiceItem).toHaveBeenCalledTimes(1); + } + } finally { + retrievePrice.mockRestore(); + createInvoiceItem.mockRestore(); + } + }); + + test('invoice.created fee attachment failure is acknowledged', async () => { + const handleKiloPassInvoiceCreated = jest.fn(async (_params: { invoice: Stripe.Invoice }) => { + throw new Error('fee attachment failed'); + }); + const invoice = { + id: 'in_kilo_pass_attach_fail', + object: 'invoice', + status: 'draft', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + currency: 'usd', + customer: 'cus_attach_fail', + metadata: { + type: 'kilo-pass', + kiloUserId: 'user_kilo_pass_attach_fail', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }, + lines: { + object: 'list', + has_more: false, + data: [ + { + pricing: { + price_details: { price: CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID }, + }, + amount: 4_900, + }, + ], + }, + } as unknown as Stripe.Invoice; + + try { + jest.resetModules(); + jest.doMock('@/lib/service-fees/invoice-created', () => { + const actual = jest.requireActual('@/lib/service-fees/invoice-created'); + return { + __esModule: true, + ...actual, + handleKiloPassInvoiceCreated, + }; + }); + + await jest.isolateModulesAsync(async () => { + const { processStripePaymentEventHook: dispatch } = await import('@/lib/stripe'); + await expect( + dispatch({ + ...baseStripeEvent(), + type: 'invoice.created', + data: { object: invoice, previous_attributes: {} }, + } as Stripe.Event) + ).resolves.toBeUndefined(); + }); + + expect(handleKiloPassInvoiceCreated).toHaveBeenCalledWith( + expect.objectContaining({ invoice }) + ); + } finally { + jest.dontMock('@/lib/service-fees/invoice-created'); + jest.resetModules(); + } + }); + test('invoice.paid dispatches zero-dollar KiloClaw invoices to settlement', async () => { const handleKiloClawInvoicePaid = jest.fn< Promise, @@ -4111,3 +4377,525 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } ); }); + +function createMemoryAssessmentStore(): ServiceFeeAssessmentStore { + const rows = new Map(); + const store: ServiceFeeAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + async insert(record) { + if (rows.has(record.assessmentKey)) { + throw new Error(`duplicate assessment_key ${record.assessmentKey}`); + } + const copy = { ...record, metadata: { ...record.metadata } }; + rows.set(record.assessmentKey, copy); + return { ...copy }; + }, + async update(assessmentKey, patch) { + const existing = rows.get(assessmentKey); + if (!existing) throw new Error(`missing ${assessmentKey}`); + const next = { + ...existing, + ...patch, + metadata: { ...existing.metadata, ...(patch.metadata ?? {}) }, + }; + rows.set(assessmentKey, next); + return { ...next }; + }, + }; + return Object.assign(store, { + async findByStripeInvoiceId(stripeInvoiceId: string) { + const row = [...rows.values()].find( + candidate => candidate.stripeInvoiceId === stripeInvoiceId + ); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + }); +} + +describe('handleUpdateSeatCount organization Kilo Pass service fee', () => { + const subscriptionId = 'sub_seat_capacity_fee'; + const seatItemId = 'si_seat_paid'; + const passItemId = 'si_pass'; + const seatPriceId = 'price_test_seat_capacity'; + const prorationDate = SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + const now = new Date(prorationDate * 1000); + const seatProductId = [...SEAT_PRODUCT_IDS][0] ?? 'prod_seat'; + let actor: User; + let organizationId: string; + + function memoryStore() { + return createMemoryAssessmentStore(); + } + + function invoiceLine(params: { + id: string; + amount: number; + priceId: string; + productId: string; + subscriptionItem: string; + }): Stripe.InvoiceLineItem { + return { + id: params.id, + object: 'line_item', + amount: params.amount, + currency: 'usd', + description: params.subscriptionItem, + discountable: true, + discount_amounts: null, + discounts: [], + invoice: 'in_preview', + livemode: false, + metadata: {}, + parent: { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: true, + proration_details: { credited_items: null }, + subscription: subscriptionId, + subscription_item: params.subscriptionItem, + }, + }, + period: { start: prorationDate, end: prorationDate + 86_400 }, + pretax_credit_amounts: null, + pricing: { + type: 'price_details', + unit_amount_decimal: String(params.amount), + price_details: { price: params.priceId, product: params.productId }, + }, + quantity: 10, + subscription: subscriptionId, + taxes: null, + } as Stripe.InvoiceLineItem; + } + + function mixedLines() { + return [ + invoiceLine({ + id: 'il_seat', + amount: 8_000, + priceId: seatPriceId, + productId: seatProductId, + subscriptionItem: seatItemId, + }), + invoiceLine({ + id: 'il_pass', + amount: 3_000, + priceId: CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID, + productId: 'prod_kilo_pass', + subscriptionItem: passItemId, + }), + ]; + } + + function orgSubscription(overrides: Partial = {}): Stripe.Subscription { + return { + id: subscriptionId, + object: 'subscription', + status: 'active', + customer: 'cus_seat_capacity', + metadata: { + type: 'kilo-pass-org', + organizationId, + kiloUserId: actor.id, + tier: 'tier_19', + cadence: 'monthly', + seats: '10', + }, + items: { + object: 'list', + data: [ + { + id: seatItemId, + quantity: 5, + current_period_start: prorationDate, + current_period_end: prorationDate + 2_592_000, + price: { + id: seatPriceId, + product: seatProductId, + unit_amount: 2_900, + recurring: { interval: 'month' }, + }, + }, + { + id: passItemId, + quantity: 5, + current_period_start: prorationDate, + current_period_end: prorationDate + 2_592_000, + price: { id: CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID, product: 'prod_kilo_pass' }, + }, + ], + has_more: false, + url: '/v1/subscription_items', + }, + ...overrides, + } as Stripe.Subscription; + } + + function previewInvoice(lines = mixedLines()): Stripe.Invoice { + return { + id: 'in_preview', + object: 'invoice', + created: prorationDate, + status: 'draft', + currency: 'usd', + customer: 'cus_seat_capacity', + lines: { + object: 'list', + data: lines, + has_more: false, + url: '/v1/invoices/upcoming/lines', + }, + } as Stripe.Invoice; + } + + function draftInvoice(lines = mixedLines()): Stripe.Invoice { + return { + ...previewInvoice(lines), + id: 'in_actual', + status: 'draft', + } as Stripe.Invoice; + } + + beforeEach(async () => { + KNOWN_SEAT_PRICE_IDS.add(seatPriceId); + jest.spyOn(client.invoiceItems, 'list').mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/invoiceitems', + } as never); + actor = await insertTestUser(); + const organization = await createOrganization(`Seat capacity fee ${Date.now()}`, actor.id); + organizationId = organization.id; + }); + + afterEach(() => { + KNOWN_SEAT_PRICE_IDS.delete(seatPriceId); + jest.restoreAllMocks(); + }); + + test('preview and update share proration_date and exclude seat amount', async () => { + const store = memoryStore(); + const retrieve = jest + .spyOn(client.subscriptions, 'retrieve') + .mockResolvedValue(orgSubscription() as never); + const createPreview = jest + .spyOn(client.invoices, 'createPreview') + .mockResolvedValue(previewInvoice() as never); + const update = jest.spyOn(client.subscriptions, 'update').mockResolvedValue({ + ...orgSubscription(), + latest_invoice: draftInvoice(), + } as never); + const createItem = jest.spyOn(client.invoiceItems, 'create').mockResolvedValue({ + id: 'ii_fee', + amount: 150, + } as never); + const finalize = jest.spyOn(client.invoices, 'finalizeInvoice').mockResolvedValue({ + ...draftInvoice(), + status: 'open', + } as never); + jest.spyOn(client.invoices, 'pay').mockResolvedValue({ + ...draftInvoice(), + status: 'paid', + } as never); + + await handleUpdateSeatCount(subscriptionId, 10, 5, { + now, + store, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + sendAlert: async () => undefined, + }); + + expect(createPreview).toHaveBeenCalledWith( + expect.objectContaining({ + subscription: subscriptionId, + subscription_details: expect.objectContaining({ + proration_date: prorationDate, + items: [ + { id: seatItemId, quantity: 10 }, + { id: passItemId, quantity: 10 }, + ], + }), + }) + ); + expect(update).toHaveBeenCalledWith( + subscriptionId, + expect.objectContaining({ + proration_date: prorationDate, + proration_behavior: 'always_invoice', + expand: ['latest_invoice.lines'], + }), + expect.any(Object) + ); + expect(update.mock.calls[0]?.[1]).not.toHaveProperty('metadata'); + expect(createItem).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 150, + discountable: false, + }) + ); + expect(createItem.mock.calls[0]?.[0]).not.toEqual( + expect.objectContaining({ invoice: 'in_actual' }) + ); + expect( + await store.findByAssessmentKey( + kiloPassOrgStripe.createSeatCapacityServiceFeeAssessmentKey({ + subscriptionId, + prorationDate, + paidSeatQuantity: 10, + }) + ) + ).toMatchObject({ + eligibleSubtotalMinor: 3_000, + expectedFeeMinor: 150, + chargedFeeMinor: 150, + outcome: 'charged', + }); + expect(retrieve).toHaveBeenCalled(); + expect(finalize).toHaveBeenCalled(); + }); + + test('seat-only updates do not preview or persist an assessment', async () => { + const store = memoryStore(); + const insert = jest.spyOn(store, 'insert'); + jest.spyOn(client.subscriptions, 'retrieve').mockResolvedValue({ + id: subscriptionId, + object: 'subscription', + status: 'active', + metadata: { + type: 'seats', + kiloUserId: actor.id, + organizationId, + seats: '10', + }, + items: { + data: [ + { + id: seatItemId, + quantity: 5, + current_period_start: prorationDate, + current_period_end: prorationDate + 2_592_000, + price: { + id: seatPriceId, + product: seatProductId, + unit_amount: 2_900, + recurring: { interval: 'month' }, + }, + }, + ], + }, + } as never); + const createPreview = jest.spyOn(client.invoices, 'createPreview'); + jest.spyOn(client.subscriptions, 'update').mockResolvedValue({ + id: subscriptionId, + object: 'subscription', + status: 'active', + metadata: { + type: 'seats', + kiloUserId: actor.id, + organizationId, + seats: '10', + }, + items: { + data: [ + { + id: seatItemId, + quantity: 10, + current_period_start: prorationDate, + current_period_end: prorationDate + 2_592_000, + price: { + id: seatPriceId, + product: seatProductId, + unit_amount: 2_900, + recurring: { interval: 'month' }, + }, + }, + ], + }, + } as never); + + await expect(handleUpdateSeatCount(subscriptionId, 10, 5, { now, store })).resolves.toEqual( + expect.objectContaining({ success: true }) + ); + expect(createPreview).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + + test('tax resolution failure fails open and still updates seats', async () => { + const store = memoryStore(); + const sendAlert = jest.fn(async () => undefined); + jest.spyOn(client.subscriptions, 'retrieve').mockResolvedValue(orgSubscription() as never); + jest.spyOn(client.invoices, 'createPreview').mockResolvedValue(previewInvoice() as never); + const update = jest.spyOn(client.subscriptions, 'update').mockResolvedValue({ + ...orgSubscription(), + latest_invoice: { ...draftInvoice(), status: 'open' }, + } as never); + const createItem = jest.spyOn(client.invoiceItems, 'create'); + jest.spyOn(client.invoices, 'pay').mockResolvedValue({ + ...draftInvoice(), + status: 'paid', + } as never); + + await expect( + handleUpdateSeatCount(subscriptionId, 10, 5, { + now, + store, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => { + throw new Error('fee_application_failed'); + }, + sendAlert, + }) + ).resolves.toEqual(expect.objectContaining({ success: true })); + + expect(update).toHaveBeenCalled(); + expect(createItem).not.toHaveBeenCalled(); + expect( + await store.findByAssessmentKey( + kiloPassOrgStripe.createSeatCapacityServiceFeeAssessmentKey({ + subscriptionId, + prorationDate, + paidSeatQuantity: 10, + }) + ) + ).toMatchObject({ + outcome: 'missed', + failureCode: 'fee_application_failed', + expectedFeeMinor: 150, + }); + expect(sendAlert).toHaveBeenCalled(); + }); + + test('injected tax attaches the prepared fee before finalize', async () => { + const store = memoryStore(); + const calls: string[] = []; + jest.spyOn(client.subscriptions, 'retrieve').mockResolvedValue(orgSubscription() as never); + jest.spyOn(client.invoices, 'createPreview').mockImplementation(async () => { + calls.push('preview'); + return previewInvoice() as never; + }); + jest.spyOn(client.subscriptions, 'update').mockImplementation(async () => { + calls.push('update'); + return { + ...orgSubscription(), + latest_invoice: draftInvoice(), + } as never; + }); + jest.spyOn(client.invoiceItems, 'create').mockImplementation(async () => { + calls.push('attach'); + return { id: 'ii_fee', amount: 150 } as never; + }); + jest.spyOn(client.invoices, 'finalizeInvoice').mockImplementation(async () => { + calls.push('finalize'); + return { ...draftInvoice(), status: 'open' } as never; + }); + jest.spyOn(client.invoices, 'pay').mockImplementation(async () => { + calls.push('pay'); + return { ...draftInvoice(), status: 'paid' } as never; + }); + + await handleUpdateSeatCount(subscriptionId, 10, 5, { + now, + store, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + sendAlert: async () => undefined, + }); + + expect(calls).toEqual(['preview', 'attach', 'update', 'attach', 'finalize', 'pay']); + }); + + test('does not preview, look up tax/exemption, or write assessments after the advisory lock', async () => { + const store = memoryStore(); + const calls: string[] = []; + const originalTransaction = db.transaction.bind(db); + jest.spyOn(db, 'transaction').mockImplementation(((...args: unknown[]) => { + calls.push('transaction-start'); + const result = originalTransaction(...(args as Parameters)); + return Promise.resolve(result).then(value => { + calls.push('transaction-end'); + return value; + }); + }) as typeof db.transaction); + const originalInsert = store.insert.bind(store); + jest.spyOn(store, 'insert').mockImplementation(async record => { + calls.push('assessment-insert'); + return originalInsert(record); + }); + jest.spyOn(client.subscriptions, 'retrieve').mockResolvedValue(orgSubscription() as never); + jest.spyOn(client.invoices, 'createPreview').mockImplementation(async () => { + calls.push('preview'); + return previewInvoice() as never; + }); + const resolveTaxInput = jest.fn(async () => { + calls.push('tax'); + return { + source: 'price' as const, + taxBehavior: 'exclusive' as const, + }; + }); + const findEffectiveExemption = jest.fn(async () => { + calls.push('exemption'); + return null; + }); + jest.spyOn(client.subscriptions, 'update').mockImplementation(async () => { + calls.push('update'); + return { + ...orgSubscription(), + latest_invoice: draftInvoice(), + } as never; + }); + jest.spyOn(client.invoiceItems, 'create').mockImplementation(async () => { + calls.push('attach'); + return { id: 'ii_fee', amount: 150 } as never; + }); + jest.spyOn(client.invoices, 'finalizeInvoice').mockResolvedValue({ + ...draftInvoice(), + status: 'open', + } as never); + jest.spyOn(client.invoices, 'pay').mockResolvedValue({ + ...draftInvoice(), + status: 'paid', + } as never); + + await handleUpdateSeatCount(subscriptionId, 10, 5, { + now, + store, + getOrganizationPurchaseChannel: async () => 'self_serve', + resolveTaxInput, + findEffectiveExemption, + sendAlert: async () => undefined, + }); + + const transactionStart = calls.indexOf('transaction-start'); + const transactionEnd = calls.indexOf('transaction-end'); + expect(transactionStart).toBeGreaterThan(-1); + expect(transactionEnd).toBeGreaterThan(transactionStart); + expect(calls.indexOf('preview')).toBeLessThan(transactionStart); + expect(calls.indexOf('tax')).toBeLessThan(transactionStart); + expect(calls.indexOf('exemption')).toBeLessThan(transactionStart); + expect(calls.indexOf('assessment-insert')).toBeLessThan(transactionStart); + expect(calls.indexOf('attach')).toBeLessThan(transactionStart); + expect(calls.lastIndexOf('attach')).toBeGreaterThan(transactionEnd); + expect(calls.slice(transactionStart, transactionEnd + 1)).not.toContain('preview'); + expect(calls.slice(transactionStart, transactionEnd + 1)).not.toContain('tax'); + expect(calls.slice(transactionStart, transactionEnd + 1)).not.toContain('exemption'); + expect(calls.slice(transactionStart, transactionEnd + 1)).not.toContain('assessment-insert'); + expect(calls.slice(transactionStart, transactionEnd + 1)).not.toContain('attach'); + }); +}); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 01a01cd10f..44e485bc28 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -6,7 +6,9 @@ import { captureException } from '@sentry/nextjs'; import { db, auto_deleted_at } from '@/lib/drizzle'; import type { User, PaymentMethod, Organization } from '@kilocode/db/schema'; import { + kilo_pass_org_agreements, kilo_pass_scheduled_changes, + kilo_pass_subscriptions, payment_methods, kilocode_users, auto_top_up_configs, @@ -52,10 +54,18 @@ import { import { invoiceLooksLikeKiloPassByPriceId } from '@/lib/kilo-pass/stripe-invoice-classifier.server'; import { invoiceLooksLikeOrganizationKiloPass } from '@/lib/kilo-pass/stripe-invoice-classifier.server'; import { + attachPreparedOrganizationKiloPassServiceFee, + discardStagedOrganizationKiloPassServiceFeeItem, endPendingOrganizationKiloPassForTerminalInvoice, handleOrganizationKiloPassPaymentAdverseForInvoice, handleOrganizationKiloPassInvoicePaid, handleOrganizationKiloPassSubscriptionEvent, + prepareOrganizationKiloPassSeatCapacityFee, + resolveOrganizationKiloPassSubscriptionItem, + stagePreparedOrganizationKiloPassServiceFeeItem, + type OrganizationKiloPassSeatCapacityFeeDependencies, + type OrganizationKiloPassSeatCapacityStripe, + type PreparedOrganizationKiloPassSeatCapacityFee, } from '@/lib/kilo-pass-org/stripe-adapter'; import { getKiloPassMetadataFromStripeMetadata } from '@/lib/kilo-pass/stripe-handlers-metadata'; import { @@ -68,7 +78,12 @@ import { import { enqueueImpactSaleReversalForCharge } from '@/lib/impact/affiliate-events'; import { markPersonalKiloClawReferralPaymentAdverse } from '@/lib/impact/kiloclaw-referrals'; import { markPersonalKiloPassReferralPaymentAdverse } from '@/lib/impact/kilo-pass-referrals'; -import { ImpactReferralPaymentProvider } from '@kilocode/db/schema-types'; +import { + ImpactReferralPaymentProvider, + KiloPassOrgAgreementState, + KiloPassOrgPurchaseChannel, + KiloPassPaymentProvider, +} from '@kilocode/db/schema-types'; import { invoiceLooksLikeKiloClawByPriceId } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; import { reportEvents } from '@/lib/ai-gateway/abuse-service'; import { @@ -84,6 +99,7 @@ import { observeStripeEarlyFraudWarningCreated } from '@/lib/stripe/early-fraud- import { observeStripeDisputeCreated } from '@/lib/stripe/disputes'; import { createTopUpCheckoutSession, + isKiloOwnedAutoTopUpInvoice, mergeServiceFeeCommercialMetadata, prepareTopUpCheckoutFee, resolveFixedUsdPriceUnitAmount, @@ -91,7 +107,9 @@ import { settleTrustedTopUpCharge, type ServiceFeeCheckoutDependencies, } from '@/lib/service-fees/checkout'; +import type { ServiceFeeAssessmentStore } from '@/lib/service-fees/assessments'; import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; +import { handleKiloPassInvoiceCreated } from '@/lib/service-fees/invoice-created'; import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions'; import { observeServiceFeeChargeRefunded, @@ -293,6 +311,61 @@ function createStripeTopUpFeeDeps(): ServiceFeeCheckoutDependencies { }; } +async function lookupOrganizationKiloPassPurchaseChannel( + organizationId: string +): Promise<'self_serve' | 'manual' | null> { + const agreement = await db.query.kilo_pass_org_agreements.findFirst({ + columns: { purchase_channel: true }, + where: and( + eq(kilo_pass_org_agreements.parent_organization_id, organizationId), + ne(kilo_pass_org_agreements.state, KiloPassOrgAgreementState.Ended) + ), + }); + if ( + agreement?.purchase_channel === KiloPassOrgPurchaseChannel.SelfServe || + agreement?.purchase_channel === KiloPassOrgPurchaseChannel.Manual + ) { + return agreement.purchase_channel; + } + return null; +} + +function metadataLooksStoreManaged(metadata: Stripe.Metadata | null | undefined): boolean { + const provider = metadata?.paymentProvider ?? metadata?.payment_provider; + return ( + provider === KiloPassPaymentProvider.AppStore || provider === KiloPassPaymentProvider.GooglePlay + ); +} + +async function isKiloPassInvoiceStoreManaged(input: { + invoice: Stripe.Invoice; + subscription: Stripe.Subscription | null; +}): Promise { + if ( + metadataLooksStoreManaged(input.invoice.metadata) || + metadataLooksStoreManaged(input.invoice.parent?.subscription_details?.metadata) || + metadataLooksStoreManaged(input.subscription?.metadata) + ) { + return true; + } + + const subscriptionId = + input.subscription?.id ?? + stripeReferenceId(input.invoice.parent?.subscription_details?.subscription); + if (!subscriptionId) { + return false; + } + + const row = await db.query.kilo_pass_subscriptions.findFirst({ + columns: { payment_provider: true }, + where: or( + eq(kilo_pass_subscriptions.stripe_subscription_id, subscriptionId), + eq(kilo_pass_subscriptions.provider_subscription_id, subscriptionId) + ), + }); + return Boolean(row && row.payment_provider !== KiloPassPaymentProvider.Stripe); +} + export async function detachAllPaymentMethods(user: User) { const paymentMethods = await client.paymentMethods.list({ customer: user.stripe_customer_id, @@ -938,6 +1011,42 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { await handleSuccessfulCharge(event); break; + case 'invoice.created': { + const invoice = event.data.object; + // Kilo-owned auto-top-up invoices attach their own fee before pay. + // Skip without creating an assessment or attaching a second fee. + if (isKiloOwnedAutoTopUpInvoice(invoice)) { + break; + } + try { + const stores = createServiceFeeStores(); + await handleKiloPassInvoiceCreated({ + invoice, + stripe: client, + store: stores.assessments, + deps: { + findEffectiveExemption: async (organizationId, at) => + getEffectiveOrganizationServiceFeeExemption({ + store: stores.exemptions, + organizationId, + at, + }), + getOrganizationPurchaseChannel: lookupOrganizationKiloPassPurchaseChannel, + isStoreManaged: isKiloPassInvoiceStoreManaged, + }, + }); + } catch (error) { + captureException(error, { + tags: { source: 'kilo_pass_invoice_created_service_fee' }, + extra: { + stripe_event_id: event.id, + stripe_invoice_id: invoice.id, + }, + }); + } + break; + } + // Handle auto-topups via invoice.paid - this has direct access to invoice metadata. // invoice.paid is a superset of invoice.payment_succeeded per Stripe docs. case 'invoice.voided': @@ -1073,7 +1182,6 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { where: eq(auto_top_up_configs.owned_by_organization_id, organizationId), columns: { created_by_user_id: true }, }); - const initiatingUserId = autoTopUpConfig?.created_by_user_id ?? SYSTEM_AUTO_TOP_UP_USER_ID; const settlement = await settleTrustedAutoTopUpInvoice({ @@ -1685,6 +1793,7 @@ export async function getStripeTopUpCheckoutUrl( /** Optional internal path to redirect to when the user cancels checkout. */ cancelPath?: string | null ): Promise { + const feeDeps = createStripeTopUpFeeDeps(); const defaultPriceId = amount ? null : getEnvVariable('STRIPE_TOP_UP_PRICE_ID'); const principalMinor = amount ? Math.round(amount * 100) @@ -1692,25 +1801,21 @@ export async function getStripeTopUpCheckoutUrl( stripe: client, priceId: defaultPriceId as string, }); - const line_items = amount - ? [ - { - price_data: { - currency: 'usd', - product_data: { - name: 'Kilo Balance Top Up', - }, - unit_amount: principalMinor, + const principalLine = amount + ? { + price_data: { + currency: 'usd', + product_data: { + name: 'Kilo Balance Top Up', }, - quantity: 1, - }, - ] - : [ - { - price: defaultPriceId as string, - quantity: 1, + unit_amount: principalMinor, }, - ]; + quantity: 1, + } + : { + price: defaultPriceId as string, + quantity: 1, + }; const isOrganizationTopUp = Boolean(organizationId); let cancelUrl: string; @@ -1722,7 +1827,6 @@ export async function getStripeTopUpCheckoutUrl( cancelUrl = `${APP_URL}/profile?payment_status=topup_cancelled&origin=${origin}`; } - const feeDeps = createStripeTopUpFeeDeps(); const prepared = await prepareTopUpCheckoutFee({ flow: isOrganizationTopUp ? 'organization_top_up' : 'personal_top_up', principalMinor, @@ -1732,7 +1836,6 @@ export async function getStripeTopUpCheckoutUrl( taxPrincipal: defaultPriceId ? { kind: 'price', priceId: defaultPriceId } : { kind: 'inline' }, deps: feeDeps, }); - const principalLine = line_items[0]; const checkoutSession = await createTopUpCheckoutSession({ prepared, @@ -2009,57 +2112,127 @@ export type UpdateSeatCountResult = { paymentIntentClientSecret?: string; }; +export type UpdateSeatCountServiceFeeDependencies = + OrganizationKiloPassSeatCapacityFeeDependencies & { + store?: ServiceFeeAssessmentStore; + stripe?: OrganizationKiloPassSeatCapacityStripe; + }; + +function resolveSeatUpdateOrganizationPassItem( + subscription: Stripe.Subscription +): Stripe.SubscriptionItem | undefined { + if (subscription.metadata?.type !== 'kilo-pass-org') { + return undefined; + } + if (typeof resolveOrganizationKiloPassSubscriptionItem === 'function') { + return resolveOrganizationKiloPassSubscriptionItem({ subscription }); + } + // Adapter test doubles omit the resolver. Keep the bound non-seat add-on so + // the base capacity update still runs. + return subscription.items.data.find( + item => !isSeatLineItem(item) && !KNOWN_SEAT_PRICE_IDS.has(item.price.id) + ); +} + export async function handleUpdateSeatCount( subscriptionStripeId: string, newSeatCount: number, - currentSeatCount: number + currentSeatCount: number, + feeDeps: UpdateSeatCountServiceFeeDependencies = {} ): Promise { - // Serialize concurrent seat modifications for the same subscription (Seat Count Modification 8). - // Uses pg_advisory_xact_lock inside a transaction to guarantee the lock and all operations - // share the same pooled connection. The lock auto-releases on commit/rollback. - return await db.transaction(async tx => { - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${subscriptionStripeId}))`); - - const isIncreasingSeats = currentSeatCount < newSeatCount; - const idempotencyKey = `sub-update-${randomUUID()}`; - const subscription = await client.subscriptions.retrieve(subscriptionStripeId); - - // Find the paid seat item by known price ID, not blindly using items[0] - // which could be a free-seat line item (Finding #5). - const paidSeatItem = subscription.items.data.find(item => - KNOWN_SEAT_PRICE_IDS.has(item.price.id) - ); - if (!paidSeatItem) { - throw new Error(`No recognized paid seat item found in subscription ${subscriptionStripeId}`); - } + const isIncreasingSeats = currentSeatCount < newSeatCount; + const idempotencyKey = `sub-update-${randomUUID()}`; + const now = feeDeps.now ?? new Date(); + const prorationDate = Math.floor(now.getTime() / 1000); + const subscription = await client.subscriptions.retrieve(subscriptionStripeId); + + // Find the paid seat item by known price ID, not blindly using items[0] + // which could be a free-seat line item (Finding #5). + const paidSeatItem = subscription.items.data.find(item => + KNOWN_SEAT_PRICE_IDS.has(item.price.id) + ); + if (!paidSeatItem) { + throw new Error(`No recognized paid seat item found in subscription ${subscriptionStripeId}`); + } - // Calculate the free seat count from non-paid seat-product items to preserve them. - // Non-seat add-ons can share the subscription but must not reduce the paid seat quantity. - const freeSeatCount = subscription.items.data - .filter(item => isSeatLineItem(item) && !KNOWN_SEAT_PRICE_IDS.has(item.price.id)) - .reduce((total, item) => total + (item.quantity ?? 0), 0); - - // The requested newSeatCount is the desired total. Deduct free seats to get paid quantity. - // The Stripe subscription must retain at least 1 paid seat (Stripe does not allow 0-quantity - // line items). Reducing to 0 paid seats requires cancelling the subscription instead. - const rawPaidQuantity = newSeatCount - freeSeatCount; - if (rawPaidQuantity < 1) { - throw new Error( - `Cannot reduce paid seats below 1 (requested total: ${newSeatCount}, free seats: ${freeSeatCount}). Cancel the subscription to remove all paid seats.` - ); + // Calculate the free seat count from non-paid seat-product items to preserve them. + // Non-seat add-ons can share the subscription but must not reduce the paid seat quantity. + const freeSeatCount = subscription.items.data + .filter(item => isSeatLineItem(item) && !KNOWN_SEAT_PRICE_IDS.has(item.price.id)) + .reduce((total, item) => total + (item.quantity ?? 0), 0); + + // The requested newSeatCount is the desired total. Deduct free seats to get paid quantity. + // The Stripe subscription must retain at least 1 paid seat (Stripe does not allow 0-quantity + // line items). Reducing to 0 paid seats requires cancelling the subscription instead. + const rawPaidQuantity = newSeatCount - freeSeatCount; + if (rawPaidQuantity < 1) { + throw new Error( + `Cannot reduce paid seats below 1 (requested total: ${newSeatCount}, free seats: ${freeSeatCount}). Cancel the subscription to remove all paid seats.` + ); + } + const paidSeatQuantity = rawPaidQuantity; + const organizationPassItem = resolveSeatUpdateOrganizationPassItem(subscription); + + let prepared: PreparedOrganizationKiloPassSeatCapacityFee = { + prorationDate, + organizationPassItemId: organizationPassItem?.id ?? null, + shouldAttach: false, + assessmentKey: null, + assessment: null, + feeInvoiceItem: null, + expectedFeeMinor: 0, + commercialMetadata: null, + }; + if ( + organizationPassItem && + isIncreasingSeats && + typeof prepareOrganizationKiloPassSeatCapacityFee === 'function' + ) { + try { + prepared = await prepareOrganizationKiloPassSeatCapacityFee({ + subscription, + paidSeatItemId: paidSeatItem.id, + paidSeatQuantity, + isIncreasingSeats, + prorationDate, + stripe: feeDeps.stripe, + store: feeDeps.store, + deps: { + now, + findEffectiveExemption: feeDeps.findEffectiveExemption, + resolveTaxInput: feeDeps.resolveTaxInput, + getOrganizationPurchaseChannel: + feeDeps.getOrganizationPurchaseChannel ?? lookupOrganizationKiloPassPurchaseChannel, + sendAlert: feeDeps.sendAlert, + }, + }); + } catch (error) { + captureException(error, { + tags: { source: 'seat_capacity_service_fee_prepare' }, + extra: { subscriptionStripeId }, + }); } - const paidSeatQuantity = rawPaidQuantity; - const organizationPassItem = - subscription.metadata?.type === 'kilo-pass-org' - ? subscription.items.data.find(item => !isSeatLineItem(item)) - : undefined; + } - try { - const updatedSubscription = await client.subscriptions.update( + // Serialize concurrent seat quantity updates for the same subscription. + // The advisory lock covers only the Stripe subscription update. Fee preview, + // assessment writes, invoiceItems.create, finalize, and pay stay outside it + // so the lock does not hold a pooled connection across extra Stripe IO. + const pendingFeeItemId = await stagePreparedOrganizationKiloPassServiceFeeItem({ + prepared, + stripe: feeDeps.stripe, + }); + let updatedSubscription: Stripe.Subscription; + let invoiceObj: Stripe.Invoice | null; + try { + const locked = await db.transaction(async tx => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${subscriptionStripeId}))`); + const updated = await client.subscriptions.update( subscriptionStripeId, { proration_behavior: isIncreasingSeats ? 'always_invoice' : 'none', payment_behavior: isIncreasingSeats ? 'allow_incomplete' : undefined, + ...(isIncreasingSeats ? { proration_date: prepared.prorationDate } : {}), // Only modify the paid seat item; free-seat items remain untouched. items: [ { @@ -2070,248 +2243,267 @@ export async function handleUpdateSeatCount( ? [{ id: organizationPassItem.id, quantity: paidSeatQuantity }] : []), ], - expand: ['latest_invoice'], + expand: ['latest_invoice.lines'], }, { idempotencyKey, } ); + const latestInvoice = updated.latest_invoice; + return { + updatedSubscription: updated, + invoiceObj: typeof latestInvoice === 'object' ? latestInvoice : null, + }; + }); + updatedSubscription = locked.updatedSubscription; + invoiceObj = locked.invoiceObj; + } catch (error) { + if (pendingFeeItemId) { + await discardStagedOrganizationKiloPassServiceFeeItem({ + invoiceItemId: pendingFeeItemId, + stripe: feeDeps.stripe, + }); + } + return handleUpdateSeatCountRequiresActionError(error, subscriptionStripeId); + } - // Get the latest invoice - const latestInvoice = updatedSubscription.latest_invoice; - let invoiceObj: Stripe.Invoice | null = - typeof latestInvoice === 'object' ? latestInvoice : null; + let paymentIntent: Stripe.PaymentIntent | null = null; - let paymentIntent: Stripe.PaymentIntent | null = null; + if ( + invoiceObj && + prepared.shouldAttach && + typeof attachPreparedOrganizationKiloPassServiceFee === 'function' + ) { + try { + const charged = await attachPreparedOrganizationKiloPassServiceFee({ + prepared, + invoice: invoiceObj, + stripe: feeDeps.stripe, + store: feeDeps.store, + deps: { + now, + sendAlert: feeDeps.sendAlert, + }, + pendingInvoiceItemId: pendingFeeItemId, + }); + if (charged) { + prepared = { ...prepared, assessment: charged, shouldAttach: false }; + } + } catch (error) { + captureException(error, { + tags: { source: 'seat_capacity_service_fee_attach' }, + extra: { subscriptionStripeId, invoiceId: invoiceObj.id }, + }); + } + } - // If the invoice is open or draft, attempt to pay it to trigger PaymentIntent creation - if (invoiceObj && (invoiceObj.status === 'open' || invoiceObj.status === 'draft')) { - try { - // Finalize if draft - if (invoiceObj.status === 'draft') { - invoiceObj = await client.invoices.finalizeInvoice(invoiceObj.id); - } + // If the invoice is open or draft, attempt to pay it to trigger PaymentIntent creation + if (invoiceObj && (invoiceObj.status === 'open' || invoiceObj.status === 'draft')) { + try { + // Finalize if draft + if (invoiceObj.status === 'draft') { + invoiceObj = await client.invoices.finalizeInvoice(invoiceObj.id); + } - // Attempt to pay the invoice - this will create a PaymentIntent and attempt charge - const paidInvoice = (await client.invoices.pay(invoiceObj.id, { - expand: ['payment_intent'], - })) as Stripe.Invoice & { payment_intent?: Stripe.PaymentIntent | string | null }; - invoiceObj = paidInvoice; + // Attempt to pay the invoice - this will create a PaymentIntent and attempt charge + const paidInvoice = (await client.invoices.pay(invoiceObj.id, { + expand: ['payment_intent'], + })) as Stripe.Invoice & { payment_intent?: Stripe.PaymentIntent | string | null }; + invoiceObj = paidInvoice; - if (typeof paidInvoice.payment_intent === 'object' && paidInvoice.payment_intent) { - paymentIntent = paidInvoice.payment_intent; - } else if (typeof paidInvoice.payment_intent === 'string') { - paymentIntent = await client.paymentIntents.retrieve(paidInvoice.payment_intent); - } - } catch (payError) { - // Invoice.pay() throws when payment fails (e.g., needs 3DS/SCA) - // When this happens with subscriptions, Stripe may void the original invoice - // and create a NEW invoice with the PaymentIntent that requires action. - // We need to list recent invoices for the subscription and find the one - // with a payment_intent in requires_action status. - - // List recent invoices for this subscription to find one with requires_action - const recentInvoices = await client.invoices.list({ - subscription: subscriptionStripeId, + if (typeof paidInvoice.payment_intent === 'object' && paidInvoice.payment_intent) { + paymentIntent = paidInvoice.payment_intent; + } else if (typeof paidInvoice.payment_intent === 'string') { + paymentIntent = await client.paymentIntents.retrieve(paidInvoice.payment_intent); + } + } catch (payError) { + // Invoice.pay() throws when payment fails (e.g., needs 3DS/SCA) + // When this happens with subscriptions, Stripe may void the original invoice + // and create a NEW invoice with the PaymentIntent that requires action. + // We need to list recent invoices for the subscription and find the one + // with a payment_intent in requires_action status. + + // List recent invoices for this subscription to find one with requires_action + const recentInvoices = await client.invoices.list({ + subscription: subscriptionStripeId, + limit: 5, + expand: ['data.payment_intent'], + }); + + type InvoiceWithPaymentIntent = Stripe.Invoice & { + payment_intent?: Stripe.PaymentIntent | string | null; + }; + + // Find an invoice with a payment_intent that requires action + for (const inv of recentInvoices.data) { + const invWithPi = inv as InvoiceWithPaymentIntent; + const pi = invWithPi.payment_intent; + if (typeof pi === 'object' && pi && pi.status === 'requires_action') { + paymentIntent = pi; + break; + } + } + + // If still not found, try listing PaymentIntents directly for the customer + if (!paymentIntent) { + // subscription.customer can be a string ID, expanded Customer object, or DeletedCustomer. + // Extract the customer ID string regardless of the shape. + const customerId = + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer?.id; + + if (customerId) { + const paymentIntents = await client.paymentIntents.list({ + customer: customerId, limit: 5, - expand: ['data.payment_intent'], }); - type InvoiceWithPaymentIntent = Stripe.Invoice & { - payment_intent?: Stripe.PaymentIntent | string | null; - }; - - // Find an invoice with a payment_intent that requires action - for (const inv of recentInvoices.data) { - const invWithPi = inv as InvoiceWithPaymentIntent; - const pi = invWithPi.payment_intent; - if (typeof pi === 'object' && pi && pi.status === 'requires_action') { + for (const pi of paymentIntents.data) { + if (pi.status === 'requires_action') { paymentIntent = pi; break; } } - - // If still not found, try listing PaymentIntents directly for the customer - if (!paymentIntent) { - // subscription.customer can be a string ID, expanded Customer object, or DeletedCustomer. - // Extract the customer ID string regardless of the shape. - const customerId = - typeof subscription.customer === 'string' - ? subscription.customer - : subscription.customer?.id; - - if (customerId) { - const paymentIntents = await client.paymentIntents.list({ - customer: customerId, - limit: 5, - }); - - for (const pi of paymentIntents.data) { - if (pi.status === 'requires_action') { - paymentIntent = pi; - break; - } - } - } - } - - // If we couldn't identify a requires_action payment intent, the invoice payment - // failed for another reason (e.g., card declined, insufficient funds). - // Re-throw to avoid falsely treating the seat update as successful. - if (!paymentIntent || paymentIntent.status !== 'requires_action') { - throw payError; - } } } - if (paymentIntent && paymentIntent.status === 'requires_action') { - // 3DS authentication is required - return the client secret for frontend handling - return { - success: false, - message: - 'Payment requires additional authentication. Please complete the verification process.', - requiresAction: true, - paymentIntentClientSecret: paymentIntent.client_secret ?? undefined, - }; - } - - if ( - paymentIntent && - (paymentIntent.status === 'requires_payment_method' || paymentIntent.status === 'canceled') - ) { - // Payment failed for another reason - throw new Error('Payment failed. Please update your payment method and try again.'); - } - - if (isIncreasingSeats && organizationPassItem && invoiceObj?.status === 'paid') { - const reconciled = await handleOrganizationKiloPassInvoicePaid({ - invoice: invoiceObj, - paidSeatCount: paidSeatQuantity, - }); - if (!reconciled) { - warnExceptInTest('Could not eagerly reconcile organization Kilo Pass seat increase', { - subscriptionStripeId, - invoiceId: invoiceObj.id, - }); - } + // If we couldn't identify a requires_action payment intent, the invoice payment + // failed for another reason (e.g., card declined, insufficient funds). + // Re-throw to avoid falsely treating the seat update as successful. + if (!paymentIntent || paymentIntent.status !== 'requires_action') { + throw payError; } + } + } - // immediately update our seats purchases - it will usually be updated again by the webhook - // but this allows us to be immediately consistent - await handleSubscriptionEvent(updatedSubscription, idempotencyKey); - - return { - success: true, - message: `Subscription updated to ${newSeatCount} seats successfully.`, - }; - } catch (error) { - // Handle 3DS authentication required errors - // When a payment requires 3DS, Stripe throws an error with code - // 'subscription_payment_intent_requires_action' or 'invoice_payment_intent_requires_action' - - // Check if this is a Stripe error that requires payment action - const isStripeError = error instanceof Stripe.errors.StripeError; - const stripeError = error as Stripe.errors.StripeError & { - payment_intent?: Stripe.PaymentIntent; - raw?: { - payment_intent?: Stripe.PaymentIntent; - }; - }; - const errorCode = isStripeError ? stripeError.code : undefined; - - // Check for either subscription or invoice requires_action errors - const requires3DS = - errorCode === 'subscription_payment_intent_requires_action' || - errorCode === 'invoice_payment_intent_requires_action'; - - if (isStripeError && requires3DS) { - // First, check if the error itself contains the PaymentIntent - // Stripe may attach it directly to the error object - if (stripeError.payment_intent && stripeError.payment_intent.status === 'requires_action') { - return { - success: false, - message: - 'Payment requires additional authentication. Please complete the verification process.', - requiresAction: true, - paymentIntentClientSecret: stripeError.payment_intent.client_secret ?? undefined, - }; - } + if (paymentIntent && paymentIntent.status === 'requires_action') { + // 3DS authentication is required - return the client secret for frontend handling + return { + success: false, + message: + 'Payment requires additional authentication. Please complete the verification process.', + requiresAction: true, + paymentIntentClientSecret: paymentIntent.client_secret ?? undefined, + }; + } - // When the subscription update fails due to 3DS, Stripe creates a new invoice - // but then rolls back the subscription. We need to find the pending/draft invoice - // or the most recent invoice with a payment_intent that requires_action. + if ( + paymentIntent && + (paymentIntent.status === 'requires_payment_method' || paymentIntent.status === 'canceled') + ) { + // Payment failed for another reason + throw new Error('Payment failed. Please update your payment method and try again.'); + } - // Re-retrieve the subscription to get the latest invoice info - const updatedSubscription = await client.subscriptions.retrieve(subscriptionStripeId, { - expand: ['latest_invoice.payment_intent', 'pending_setup_intent'], - }); + if (isIncreasingSeats && organizationPassItem && invoiceObj?.status === 'paid') { + const reconciled = await handleOrganizationKiloPassInvoicePaid({ + invoice: invoiceObj, + paidSeatCount: paidSeatQuantity, + serviceFee: { + store: feeDeps.store, + stripe: feeDeps.stripe, + deps: { + now, + sendAlert: feeDeps.sendAlert, + }, + }, + }); + if (!reconciled) { + warnExceptInTest('Could not eagerly reconcile organization Kilo Pass seat increase', { + subscriptionStripeId, + invoiceId: invoiceObj.id, + }); + } + } - // The latest_invoice is expanded to include payment_intent - // Use type assertion for the expanded invoice structure - const latestInvoice = updatedSubscription.latest_invoice as - | (Stripe.Invoice & { payment_intent?: Stripe.PaymentIntent | string | null }) - | null; - - let paymentIntent: Stripe.PaymentIntent | null = null; - - // First, try to get payment_intent from the expanded latest_invoice - if ( - latestInvoice && - typeof latestInvoice.payment_intent === 'object' && - latestInvoice.payment_intent !== null - ) { - paymentIntent = latestInvoice.payment_intent; - } + // immediately update our seats purchases - it will usually be updated again by the webhook + // but this allows us to be immediately consistent + await handleSubscriptionEvent(updatedSubscription, idempotencyKey); - // If not expanded or not found, check if the invoice has a payment_intent ID - // and retrieve it directly. When subscription update fails, the invoice may be - // in 'open' status with a payment_intent that requires action. - if (!paymentIntent && latestInvoice) { - const paymentIntentId = - typeof latestInvoice.payment_intent === 'string' - ? latestInvoice.payment_intent - : undefined; - - if (paymentIntentId) { - paymentIntent = await client.paymentIntents.retrieve(paymentIntentId); - } - } + return { + success: true, + message: `Subscription updated to ${newSeatCount} seats successfully.`, + }; +} - // If still no payment intent, list recent invoices for this subscription - // and find one with an open payment intent requiring action - if (!paymentIntent) { - const recentInvoices = await client.invoices.list({ - subscription: subscriptionStripeId, - limit: 10, - expand: ['data.payment_intent'], - }); +async function handleUpdateSeatCountRequiresActionError( + error: unknown, + subscriptionStripeId: string +): Promise { + const isStripeError = error instanceof Stripe.errors.StripeError; + const stripeError = error as Stripe.errors.StripeError & { + payment_intent?: Stripe.PaymentIntent; + raw?: { + payment_intent?: Stripe.PaymentIntent; + }; + }; + const errorCode = isStripeError ? stripeError.code : undefined; + const requires3DS = + errorCode === 'subscription_payment_intent_requires_action' || + errorCode === 'invoice_payment_intent_requires_action'; - for (const inv of recentInvoices.data) { - // Cast to include the expanded payment_intent field - const invoiceWithPi = inv as Stripe.Invoice & { - payment_intent?: Stripe.PaymentIntent | null; - }; - const pi = invoiceWithPi.payment_intent; - if (pi && pi.status === 'requires_action') { - paymentIntent = pi; - break; - } - } - } + if (isStripeError && requires3DS) { + if (stripeError.payment_intent && stripeError.payment_intent.status === 'requires_action') { + return { + success: false, + message: + 'Payment requires additional authentication. Please complete the verification process.', + requiresAction: true, + paymentIntentClientSecret: stripeError.payment_intent.client_secret ?? undefined, + }; + } - if (paymentIntent && paymentIntent.status === 'requires_action') { - return { - success: false, - message: - 'Payment requires additional authentication. Please complete the verification process.', - requiresAction: true, - paymentIntentClientSecret: paymentIntent.client_secret ?? undefined, - }; + const updatedSubscription = await client.subscriptions.retrieve(subscriptionStripeId, { + expand: ['latest_invoice.payment_intent', 'pending_setup_intent'], + }); + const latestInvoice = updatedSubscription.latest_invoice as + | (Stripe.Invoice & { payment_intent?: Stripe.PaymentIntent | string | null }) + | null; + + let paymentIntent: Stripe.PaymentIntent | null = null; + if ( + latestInvoice && + typeof latestInvoice.payment_intent === 'object' && + latestInvoice.payment_intent !== null + ) { + paymentIntent = latestInvoice.payment_intent; + } + if (!paymentIntent && latestInvoice) { + const paymentIntentId = + typeof latestInvoice.payment_intent === 'string' ? latestInvoice.payment_intent : undefined; + if (paymentIntentId) { + paymentIntent = await client.paymentIntents.retrieve(paymentIntentId); + } + } + if (!paymentIntent) { + const recentInvoices = await client.invoices.list({ + subscription: subscriptionStripeId, + limit: 10, + expand: ['data.payment_intent'], + }); + for (const inv of recentInvoices.data) { + const invoiceWithPi = inv as Stripe.Invoice & { + payment_intent?: Stripe.PaymentIntent | null; + }; + const pi = invoiceWithPi.payment_intent; + if (pi && pi.status === 'requires_action') { + paymentIntent = pi; + break; } } + } - // Re-throw other errors - throw error; + if (paymentIntent && paymentIntent.status === 'requires_action') { + return { + success: false, + message: + 'Payment requires additional authentication. Please complete the verification process.', + requiresAction: true, + paymentIntentClientSecret: paymentIntent.client_secret ?? undefined, + }; } - }); + } + + throw error; } diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 2b4cb90f6d..e8366fdd9a 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -60,6 +60,24 @@ import type Stripe from 'stripe'; import type dayjsType from 'dayjs'; import type utcType from 'dayjs/plugin/utc'; import type * as Sentry from '@sentry/nextjs'; +import type { CreatePersonalKiloPassCheckoutSession } from '@/routers/kilo-pass-router'; +import type { + ServiceFeeAssessmentRecord, + ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_DESCRIPTION, + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + SERVICE_FEE_FAILURE_APPLICATION, + type CheckoutSessionLike, + type ServiceFeeCheckoutDependencies, +} from '@/lib/service-fees/checkout'; +import { buildInheritedInlineServiceFeeTaxInput } from '@/lib/service-fees/tax'; const PROMO_OFFER_ACTIVE_TEST_TIME = '2026-05-06T12:00:00.000Z'; const PROMO_OFFER_EXPIRED_TEST_TIME = '2026-05-07T00:00:00.000Z'; @@ -81,6 +99,8 @@ type StripeMock = { sessions: { create: ReturnType; retrieve: ReturnType; + expire: ReturnType; + listLineItems: ReturnType; }; }; billingPortal: { @@ -91,6 +111,9 @@ type StripeMock = { invoices: { list: ReturnType; }; + prices: { + retrieve: ReturnType; + }; }; type AppStoreVerifierMock = { @@ -324,11 +347,71 @@ type KiloPassCaller = { type Caller = { kiloPass: KiloPassCaller }; let createCallerForUser: (userId: string) => Promise; +let createPersonalKiloPassCheckoutSession: CreatePersonalKiloPassCheckoutSession; function freezeKiloPassClock(nowIso: string): void { mockKiloPassNowIso = nowIso; } +function createMemoryAssessmentStore(): ServiceFeeAssessmentStore { + const rows = new Map(); + const store: ServiceFeeAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + async insert(record) { + if (rows.has(record.assessmentKey)) { + throw new Error(`duplicate assessment_key ${record.assessmentKey}`); + } + const copy = { ...record, metadata: { ...record.metadata } }; + rows.set(record.assessmentKey, copy); + return { ...copy }; + }, + async update(assessmentKey, patch) { + const existing = rows.get(assessmentKey); + if (!existing) throw new Error(`missing ${assessmentKey}`); + const next = { + ...existing, + ...patch, + metadata: { ...existing.metadata, ...(patch.metadata ?? {}) }, + }; + rows.set(assessmentKey, next); + return { ...next }; + }, + }; + return store; +} + +type PersonalKiloPassCheckoutTestDeps = ServiceFeeCheckoutDependencies & { + createSession?: (params: Stripe.Checkout.SessionCreateParams) => Promise; +}; + +function kiloPassCheckoutDeps( + store: ServiceFeeAssessmentStore, + overrides: Partial = {} +): PersonalKiloPassCheckoutTestDeps { + return { + store, + now: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + sendAlert: jest.fn(async () => undefined), + stripe: { + prices: { + retrieve: async () => ({ + id: 'price_test_kilo_pass', + currency: 'usd', + unit_amount: 4900, + tax_behavior: 'unspecified', + }), + }, + }, + ...overrides, + }; +} + jest.mock('@/lib/kilo-pass/dayjs', () => { const realDayjs = jest.requireActual('dayjs'); const utc = jest.requireActual('dayjs/plugin/utc'); @@ -363,6 +446,8 @@ jest.mock('@/lib/stripe-client', () => { sessions: { create: jest.fn(), retrieve: jest.fn(), + expire: jest.fn(), + listLineItems: jest.fn(), }, }, billingPortal: { @@ -373,6 +458,9 @@ jest.mock('@/lib/stripe-client', () => { invoices: { list: jest.fn(), }, + prices: { + retrieve: jest.fn(), + }, }; return { @@ -668,6 +756,7 @@ describe('kiloPassRouter', () => { // Delay importing the tRPC caller factory until after mocks are registered, // otherwise router imports will capture the real Stripe client. ({ createCallerForUser } = await import('@/routers/test-utils')); + ({ createPersonalKiloPassCheckoutSession } = await import('@/routers/kilo-pass-router')); }); beforeEach(() => { @@ -680,8 +769,21 @@ describe('kiloPassRouter', () => { stripeMock.subscriptionSchedules.retrieve.mockReset(); stripeMock.checkout.sessions.create.mockReset(); stripeMock.checkout.sessions.retrieve.mockReset(); + stripeMock.checkout.sessions.expire.mockReset(); + stripeMock.checkout.sessions.listLineItems.mockReset(); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.invoices.list.mockReset(); + stripeMock.prices.retrieve.mockReset(); + stripeMock.prices.retrieve.mockResolvedValue({ + id: 'price_test_kilo_pass', + currency: 'usd', + unit_amount: 4900, + tax_behavior: 'unspecified', + }); + stripeMock.checkout.sessions.listLineItems.mockResolvedValue({ + data: [], + has_more: false, + }); getAppStoreVerifierMock().verifyAppleKiloPassTransactionJws.mockReset(); getStoreCompletionMock().completeStoreKiloPassPurchase.mockReset(); getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockReset(); @@ -5112,6 +5214,8 @@ describe('kiloPassRouter', () => { it('creates a checkout session with empty affiliate metadata when attribution is absent', async () => { const stripeMock = getStripeMock(); stripeMock.checkout.sessions.create.mockResolvedValue({ + id: 'cs_test_ok', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, url: 'https://stripe.example.test/checkout', }); @@ -5133,10 +5237,12 @@ describe('kiloPassRouter', () => { }); expect(result).toEqual({ url: 'https://stripe.example.test/checkout' }); + expect(stripeMock.prices.retrieve).toHaveBeenCalledWith('price_test_kilo_pass'); expect(stripeMock.checkout.sessions.create).toHaveBeenCalledWith( expect.objectContaining({ mode: 'subscription', customer: user.stripe_customer_id, + allow_promotion_codes: true, line_items: [{ price: 'price_test_kilo_pass', quantity: 1 }], success_url: expect.stringContaining('/payments/kilo-pass/awarding'), subscription_data: { @@ -5148,13 +5254,16 @@ describe('kiloPassRouter', () => { affiliateTrackingId: '', }, }, - metadata: { + metadata: expect.objectContaining({ type: 'kilo-pass', kiloUserId: user.id, tier: 'tier_49', cadence: 'yearly', affiliateTrackingId: '', - }, + serviceFeeFlow: 'personal_kilo_pass', + serviceFeePrincipalMinor: '4900', + serviceFeeVersion: SERVICE_FEE_VERSION, + }), }) ); }); @@ -5162,6 +5271,8 @@ describe('kiloPassRouter', () => { it('includes affiliateTrackingId in checkout metadata when attribution exists', async () => { const stripeMock = getStripeMock(); stripeMock.checkout.sessions.create.mockResolvedValue({ + id: 'cs_test_attributed', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, url: 'https://stripe.example.test/checkout', }); @@ -5182,6 +5293,7 @@ describe('kiloPassRouter', () => { expect(stripeMock.checkout.sessions.create).toHaveBeenCalledWith( expect.objectContaining({ + allow_promotion_codes: true, subscription_data: { metadata: { type: 'kilo-pass', @@ -5191,13 +5303,14 @@ describe('kiloPassRouter', () => { affiliateTrackingId: 'impact-click-123', }, }, - metadata: { + metadata: expect.objectContaining({ type: 'kilo-pass', kiloUserId: user.id, tier: 'tier_49', cadence: 'yearly', affiliateTrackingId: 'impact-click-123', - }, + serviceFeeFlow: 'personal_kilo_pass', + }), }) ); }); @@ -5217,5 +5330,245 @@ describe('kiloPassRouter', () => { }) ).rejects.toThrow('commerce_not_available'); }); + + it('fails open to a product-only session when fee tax resolution fails', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert = jest.fn(async () => undefined); + const createSession = jest.fn( + async (_params: Stripe.Checkout.SessionCreateParams): Promise => ({ + id: 'cs_missed_tax', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://stripe.example.test/missed', + line_items: { data: [], has_more: false }, + }) + ); + + const session = await createPersonalKiloPassCheckoutSession({ + kiloUserId: 'user_kilo_pass', + stripeCustomerId: 'cus_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: '', + priceId: 'price_test_kilo_pass', + deps: kiloPassCheckoutDeps(store, { + sendAlert, + createSession, + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + }), + }); + + expect(session.id).toBe('cs_missed_tax'); + expect(createSession).toHaveBeenCalledTimes(1); + const createParams = createSession.mock.calls[0]?.[0]; + expect(createParams?.allow_promotion_codes).toBe(true); + expect(createParams?.line_items).toEqual([{ price: 'price_test_kilo_pass', quantity: 1 }]); + expect(createParams?.metadata).toEqual( + expect.objectContaining({ + type: 'kilo-pass', + kiloUserId: 'user_kilo_pass', + serviceFeeFlow: 'personal_kilo_pass', + serviceFeePrincipalMinor: '4900', + }) + ); + const record = await store.findByAssessmentKey( + String(createParams?.metadata?.serviceFeeAssessmentKey) + ); + expect(record).toMatchObject({ + flow: 'personal_kilo_pass', + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + stripeCheckoutSessionId: 'cs_missed_tax', + eligibleSubtotalMinor: 4900, + expectedFeeMinor: 245, + chargedFeeMinor: 0, + kiloUserId: 'user_kilo_pass', + stripeCustomerId: 'cus_kilo_pass', + }); + expect(sendAlert).toHaveBeenCalled(); + }); + + it('adds a separate discountable one-time 5% fee line when tax input is injected', async () => { + const store = createMemoryAssessmentStore(); + const assessmentKey = 'checkout:11111111-1111-4111-8111-111111111111'; + const feeLine = { + id: 'li_kilo_pass_fee', + price: { + id: 'price_kilo_pass_fee', + product: { + id: 'prod_kilo_pass_fee', + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: assessmentKey, + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + }, + }, + } as unknown as Stripe.LineItem; + const createSession = jest.fn( + async (_params: Stripe.Checkout.SessionCreateParams): Promise => ({ + id: 'cs_with_fee', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://stripe.example.test/fee', + line_items: { data: [feeLine], has_more: false }, + }) + ); + + const session = await createPersonalKiloPassCheckoutSession({ + kiloUserId: 'user_kilo_pass', + stripeCustomerId: 'cus_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: 'impact-click-123', + priceId: 'price_test_kilo_pass', + deps: kiloPassCheckoutDeps(store, { + createAssessmentKey: () => assessmentKey, + resolveTaxInput: async () => buildInheritedInlineServiceFeeTaxInput(), + createSession, + }), + }); + + expect(session.id).toBe('cs_with_fee'); + expect(createSession).toHaveBeenCalledTimes(1); + const createParams = createSession.mock.calls[0]?.[0]; + expect(createParams?.allow_promotion_codes).toBe(true); + expect(createParams?.line_items).toHaveLength(2); + expect(createParams?.line_items?.[0]).toEqual({ + price: 'price_test_kilo_pass', + quantity: 1, + }); + expect(createParams?.line_items?.[1]).toEqual({ + quantity: 1, + price_data: { + currency: 'usd', + unit_amount: 245, + product_data: { + name: SERVICE_FEE_DESCRIPTION, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: assessmentKey, + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + }, + }, + }); + expect(createParams?.line_items?.[1]?.price_data?.recurring).toBeUndefined(); + expect(createParams?.metadata).toEqual( + expect.objectContaining({ + type: 'kilo-pass', + kiloUserId: 'user_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: 'impact-click-123', + serviceFeeAssessmentKey: assessmentKey, + serviceFeeFlow: 'personal_kilo_pass', + serviceFeePrincipalMinor: '4900', + }) + ); + expect(createParams?.subscription_data?.metadata).toEqual({ + type: 'kilo-pass', + kiloUserId: 'user_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: 'impact-click-123', + }); + + const record = await store.findByAssessmentKey(assessmentKey); + expect(record).toMatchObject({ + flow: 'personal_kilo_pass', + stripeCheckoutSessionId: 'cs_with_fee', + stripeCheckoutFeeLineItemId: 'li_kilo_pass_fee', + stripeFeePriceId: 'price_kilo_pass_fee', + eligibleSubtotalMinor: 4900, + expectedFeeMinor: 245, + }); + }); + + it('expires and replaces once near the activation boundary and does not replace outside it', async () => { + const nearStore = createMemoryAssessmentStore(); + const expire = jest.fn(async (_sessionId: string) => undefined); + const nearCreate = jest + .fn<(params: Stripe.Checkout.SessionCreateParams) => Promise>() + .mockResolvedValueOnce({ + id: 'cs_early', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://stripe.example.test/early', + }) + .mockResolvedValueOnce({ + id: 'cs_replaced', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://stripe.example.test/replaced', + line_items: { + data: [ + { + id: 'li_replaced_fee', + price: { + id: 'price_replaced_fee', + product: { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem, + ], + has_more: false, + }, + }); + + const replaced = await createPersonalKiloPassCheckoutSession({ + kiloUserId: 'user_kilo_pass', + stripeCustomerId: 'cus_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: '', + priceId: 'price_test_kilo_pass', + deps: kiloPassCheckoutDeps(nearStore, { + now: new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 30) * 1000), + resolveTaxInput: async () => buildInheritedInlineServiceFeeTaxInput(), + expireCheckoutSession: expire, + createSession: nearCreate, + }), + }); + + expect(expire).toHaveBeenCalledWith('cs_early'); + expect(nearCreate).toHaveBeenCalledTimes(2); + expect(replaced.id).toBe('cs_replaced'); + expect(nearCreate.mock.calls[0]?.[0]?.line_items).toHaveLength(1); + expect(nearCreate.mock.calls[1]?.[0]?.line_items).toHaveLength(2); + expect(nearCreate.mock.calls[1]?.[0]?.allow_promotion_codes).toBe(true); + + const farStore = createMemoryAssessmentStore(); + const farExpire = jest.fn(async () => undefined); + const farCreate = jest.fn(async () => ({ + id: 'cs_far', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 90, + url: 'https://stripe.example.test/far', + line_items: { data: [], has_more: false }, + })); + + const farSession = await createPersonalKiloPassCheckoutSession({ + kiloUserId: 'user_kilo_pass', + stripeCustomerId: 'cus_kilo_pass', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + affiliateTrackingId: '', + priceId: 'price_test_kilo_pass', + deps: kiloPassCheckoutDeps(farStore, { + now: new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 120) * 1000), + resolveTaxInput: async () => buildInheritedInlineServiceFeeTaxInput(), + expireCheckoutSession: farExpire, + createSession: farCreate, + }), + }); + + expect(farSession.id).toBe('cs_far'); + expect(farCreate).toHaveBeenCalledTimes(1); + expect(farExpire).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 7380be1055..0d5f375308 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -16,6 +16,17 @@ import { getKiloPassStateForUser, type KiloPassSubscriptionState } from '@/lib/k import { client as stripe } from '@/lib/stripe-client'; import { getStripePriceIdForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; +import { + createTopUpCheckoutSession, + mergeServiceFeeCommercialMetadata, + prepareTopUpCheckoutFee, + resolveFixedUsdPriceUnitAmount, + type CheckoutSessionCreateFn, + type CheckoutSessionLike, + type ServiceFeeCheckoutDependencies, + type TopUpPriceReader, +} from '@/lib/service-fees/checkout'; +import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; import { APP_URL } from '@/lib/constants'; import { KILO_PASS_REFERRER_REWARD_CAP } from '@/lib/impact/kilo-pass-referrals'; import { TRPCError } from '@trpc/server'; @@ -1194,6 +1205,109 @@ async function getSettledKiloPassCheckoutSubscription(params: { }; } +const PERSONAL_KILO_PASS_CHECKOUT_FLOW = 'personal_kilo_pass' as const; + +export type PersonalKiloPassCheckoutDependencies = ServiceFeeCheckoutDependencies & { + createSession?: CheckoutSessionCreateFn; + retrievePriceUnitAmount?: typeof resolveFixedUsdPriceUnitAmount; +}; + +function createDefaultPersonalKiloPassCheckoutDependencies(): PersonalKiloPassCheckoutDependencies { + const stores = createServiceFeeStores(); + return { + store: stores.assessments, + stripe: stripe, + listCheckoutLineItems: (sessionId, listParams) => + stripe.checkout.sessions.listLineItems(sessionId, listParams), + expireCheckoutSession: sessionId => stripe.checkout.sessions.expire(sessionId), + createSession: sessionParams => stripe.checkout.sessions.create(sessionParams), + }; +} + +function requireTopUpPriceReader( + stripeReader: PersonalKiloPassCheckoutDependencies['stripe'] +): TopUpPriceReader { + if (!stripeReader || !('prices' in stripeReader) || !stripeReader.prices) { + throw new Error('Stripe price reader is required to create a Kilo Pass checkout session'); + } + return stripeReader as TopUpPriceReader; +} + +export async function createPersonalKiloPassCheckoutSession(params: { + kiloUserId: string; + stripeCustomerId: string; + tier: KiloPassTier; + cadence: KiloPassCadence; + affiliateTrackingId: string; + priceId: string; + deps?: PersonalKiloPassCheckoutDependencies; +}): Promise { + const deps = { + ...createDefaultPersonalKiloPassCheckoutDependencies(), + ...params.deps, + }; + const retrievePrice = deps.retrievePriceUnitAmount ?? resolveFixedUsdPriceUnitAmount; + const principalMinor = await retrievePrice({ + stripe: requireTopUpPriceReader(deps.stripe), + priceId: params.priceId, + }); + + const prepared = await prepareTopUpCheckoutFee({ + flow: PERSONAL_KILO_PASS_CHECKOUT_FLOW, + principalMinor, + kiloUserId: params.kiloUserId, + stripeCustomerId: params.stripeCustomerId, + taxPrincipal: { kind: 'price', priceId: params.priceId }, + deps, + }); + + const productMetadata = { + type: 'kilo-pass', + kiloUserId: params.kiloUserId, + tier: params.tier, + cadence: params.cadence, + affiliateTrackingId: params.affiliateTrackingId, + }; + const sessionMetadata = mergeServiceFeeCommercialMetadata( + productMetadata, + prepared.commercialMetadata + ); + const createSession = + deps.createSession ?? (sessionParams => stripe.checkout.sessions.create(sessionParams)); + + return createTopUpCheckoutSession({ + prepared, + buildSessionParams: feeLine => ({ + mode: 'subscription', + customer: params.stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: feeLine + ? [{ price: params.priceId, quantity: 1 }, feeLine] + : [{ price: params.priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, + subscription_data: { + metadata: productMetadata, + }, + metadata: sessionMetadata, + expand: ['line_items.data.price.product'], + }), + createSession, + deps, + }); +} + +export type CreatePersonalKiloPassCheckoutSession = typeof createPersonalKiloPassCheckoutSession; + export const kiloPassRouter = createTRPCRouter({ getMobileStoreProducts: baseProcedure.query(({ ctx }) => ({ appAccountToken: ctx.user.app_store_account_token, @@ -2630,34 +2744,13 @@ export const kiloPassRouter = createTRPCRouter({ const priceId = getStripePriceIdForKiloPass({ tier, cadence }); const attribution = await getAffiliateAttribution(ctx.user.id, 'impact'); - const sessionMetadata = { - type: 'kilo-pass', + const session = await createPersonalKiloPassCheckoutSession({ kiloUserId: ctx.user.id, + stripeCustomerId, tier, cadence, affiliateTrackingId: attribution?.tracking_id ?? '', - }; - - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - customer: stripeCustomerId, - allow_promotion_codes: true, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, - subscription_data: { - metadata: sessionMetadata, - }, - metadata: sessionMetadata, + priceId, }); return { url: typeof session.url === 'string' ? session.url : null };