diff --git a/apps/web/src/lib/service-fees/alerts.test.ts b/apps/web/src/lib/service-fees/alerts.test.ts new file mode 100644 index 0000000000..64d4ed0c85 --- /dev/null +++ b/apps/web/src/lib/service-fees/alerts.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; + +import type { captureException as captureSentryException } from '@sentry/nextjs'; + +import type { sendAdminSlackNotification } from '@/lib/slack/admin-notifications'; + +class AdminSlackNotificationError extends Error { + constructor( + readonly kind: 'network' | 'upstream', + readonly status?: number + ) { + super('Admin Slack notification request failed'); + this.name = 'AdminSlackNotificationError'; + } +} + +async function loadAlerts() { + jest.resetModules(); + jest.doMock('@sentry/nextjs', () => ({ + captureException: jest.fn(), + })); + jest.doMock('@/lib/slack/admin-notifications', () => ({ + AdminSlackNotificationError, + sendAdminSlackNotification: jest.fn(async () => undefined), + })); + return import('./alerts'); +} + +afterEach(() => { + jest.resetModules(); + jest.dontMock('@/lib/slack/admin-notifications'); + jest.dontMock('@sentry/nextjs'); +}); + +const ALERT_INPUT = { + assessmentKey: 'checkout:11111111-1111-4111-8111-111111111111', + flow: 'organization_top_up' as const, + organizationId: 'org_123', + kiloUserId: 'user_456', + stripeCheckoutSessionId: 'cs_test_1', + stripeInvoiceId: 'in_test_1', + stripePaymentIntentId: 'pi_test_1', + stripeChargeId: 'ch_test_1', + eligibleSubtotalMinor: 10_000, + expectedFeeMinor: 500, + currency: 'usd', + failureCode: 'fee_application_failed', + attemptedAt: new Date('2026-09-01T00:00:00.000Z'), +}; + +describe('sendMissedServiceFeeAlert', () => { + it('sends only non-sensitive identifiers, amounts, and the failure code', async () => { + const { buildMissedServiceFeeAlertText, sendMissedServiceFeeAlert } = await loadAlerts(); + const sendNotification = jest.fn(async () => undefined); + const capture = jest.fn(() => 'event-id'); + + await sendMissedServiceFeeAlert(ALERT_INPUT, { + sendNotification, + captureException: capture, + }); + + expect(sendNotification).toHaveBeenCalledTimes(1); + const notification = sendNotification.mock.calls[0]?.[0]; + expect(notification?.unfurl_links).toBe(false); + expect(notification?.unfurl_media).toBe(false); + expect(notification?.text).toBe(buildMissedServiceFeeAlertText(ALERT_INPUT)); + expect(notification?.text).toContain( + 'assessment_key=checkout:11111111-1111-4111-8111-111111111111' + ); + expect(notification?.text).toContain('flow=organization_top_up'); + expect(notification?.text).toContain('owner_id=org_123'); + expect(notification?.text).toContain('eligible_subtotal_minor=10000'); + expect(notification?.text).toContain('expected_fee_minor=500'); + expect(notification?.text).toContain('currency=usd'); + expect(notification?.text).toContain('failure_code=fee_application_failed'); + expect(notification?.text).toContain('attempted_at=2026-09-01T00:00:00.000Z'); + expect(notification?.text).not.toMatch(/email|payload|webhook|card|secret|metadata/i); + expect(capture).not.toHaveBeenCalled(); + }); + + it('captures only Slack kind/status and identifiers when Slack fails', async () => { + const { sendMissedServiceFeeAlert, SERVICE_FEE_MISSED_SENTRY_TAG } = await loadAlerts(); + const slackError = new AdminSlackNotificationError('upstream', 500); + const sendNotification = jest.fn(async () => { + throw slackError; + }); + const capture = jest.fn(() => 'event-id'); + + await expect( + sendMissedServiceFeeAlert(ALERT_INPUT, { + sendNotification, + captureException: capture, + }) + ).resolves.toBeUndefined(); + + expect(capture).toHaveBeenCalledTimes(1); + expect(capture).toHaveBeenCalledWith(slackError, { + tags: { source: SERVICE_FEE_MISSED_SENTRY_TAG }, + extra: { + kind: 'upstream', + status: 500, + assessmentKey: ALERT_INPUT.assessmentKey, + flow: ALERT_INPUT.flow, + ownerId: 'org_123', + failureCode: 'fee_application_failed', + }, + }); + const captureContext = capture.mock.calls[0]?.[1]; + expect(JSON.stringify(captureContext)).not.toMatch(/secret|payload|webhook|email/i); + }); + + it('captures and swallows unexpected notifier failures without retaining the raw error', async () => { + const { sendMissedServiceFeeAlert, SERVICE_FEE_MISSED_SENTRY_TAG } = await loadAlerts(); + const sendNotification = jest.fn(async () => { + throw new Error('potentially sensitive notifier error'); + }); + const capture = jest.fn(() => 'event-id'); + + await expect( + sendMissedServiceFeeAlert(ALERT_INPUT, { + sendNotification, + captureException: capture, + }) + ).resolves.toBeUndefined(); + + expect(capture).toHaveBeenCalledWith(expect.any(Error), { + tags: { source: SERVICE_FEE_MISSED_SENTRY_TAG }, + extra: { + kind: 'unexpected', + status: null, + assessmentKey: ALERT_INPUT.assessmentKey, + flow: ALERT_INPUT.flow, + ownerId: 'org_123', + failureCode: 'fee_application_failed', + }, + }); + expect(capture.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ message: 'Admin Slack notification failed' }) + ); + }); +}); diff --git a/apps/web/src/lib/service-fees/alerts.ts b/apps/web/src/lib/service-fees/alerts.ts new file mode 100644 index 0000000000..9034a644f7 --- /dev/null +++ b/apps/web/src/lib/service-fees/alerts.ts @@ -0,0 +1,102 @@ +import 'server-only'; + +import { captureException } from '@sentry/nextjs'; + +import { + AdminSlackNotificationError, + sendAdminSlackNotification, +} from '@/lib/slack/admin-notifications'; +import type { ServiceFeeFlow } from '@/lib/service-fees/types'; + +export const SERVICE_FEE_MISSED_SENTRY_TAG = 'service_fee_missed'; + +export type MissedServiceFeeAlertInput = { + assessmentKey: string; + flow: ServiceFeeFlow; + kiloUserId?: string | null; + organizationId?: string | null; + stripeCheckoutSessionId?: string | null; + stripeInvoiceId?: string | null; + stripePaymentIntentId?: string | null; + stripeChargeId?: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + currency: string; + failureCode: string; + attemptedAt: Date | string; +}; + +export type MissedServiceFeeAlertDependencies = { + sendNotification?: typeof sendAdminSlackNotification; + captureException?: typeof captureException; +}; + +function nonEmpty(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function ownerId(input: MissedServiceFeeAlertInput): string { + return nonEmpty(input.organizationId) ?? nonEmpty(input.kiloUserId) ?? 'unknown'; +} + +function attemptedAtIso(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return 'invalid_timestamp'; + } + return date.toISOString(); +} + +export function buildMissedServiceFeeAlertText(input: MissedServiceFeeAlertInput): string { + const lines = [ + 'Missed service fee', + `assessment_key=${input.assessmentKey}`, + `flow=${input.flow}`, + `owner_id=${ownerId(input)}`, + `stripe_checkout_session_id=${nonEmpty(input.stripeCheckoutSessionId) ?? 'none'}`, + `stripe_invoice_id=${nonEmpty(input.stripeInvoiceId) ?? 'none'}`, + `stripe_payment_intent_id=${nonEmpty(input.stripePaymentIntentId) ?? 'none'}`, + `stripe_charge_id=${nonEmpty(input.stripeChargeId) ?? 'none'}`, + `eligible_subtotal_minor=${input.eligibleSubtotalMinor}`, + `expected_fee_minor=${input.expectedFeeMinor}`, + `currency=${input.currency}`, + `failure_code=${input.failureCode}`, + `attempted_at=${attemptedAtIso(input.attemptedAt)}`, + ]; + return lines.join('\n'); +} + +/** + * Best-effort Admin Slack alert for a fail-open missed fee. Retries are not + * deduplicated. Slack or Sentry failure must not change assessment outcome. + */ +export async function sendMissedServiceFeeAlert( + input: MissedServiceFeeAlertInput, + deps: MissedServiceFeeAlertDependencies = {} +): Promise { + const sendNotification = deps.sendNotification ?? sendAdminSlackNotification; + const capture = deps.captureException ?? captureException; + + try { + await sendNotification({ + text: buildMissedServiceFeeAlertText(input), + unfurl_links: false, + unfurl_media: false, + }); + } catch (error) { + const isSlackError = error instanceof AdminSlackNotificationError; + capture(isSlackError ? error : new Error('Admin Slack notification failed'), { + tags: { source: SERVICE_FEE_MISSED_SENTRY_TAG }, + extra: { + kind: isSlackError ? error.kind : 'unexpected', + status: isSlackError ? (error.status ?? null) : null, + assessmentKey: input.assessmentKey, + flow: input.flow, + ownerId: ownerId(input), + failureCode: input.failureCode, + }, + }); + } +} diff --git a/apps/web/src/lib/service-fees/assessments.test.ts b/apps/web/src/lib/service-fees/assessments.test.ts new file mode 100644 index 0000000000..aa6142bce0 --- /dev/null +++ b/apps/web/src/lib/service-fees/assessments.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, test } from '@jest/globals'; + +import { + ServiceFeeAssessmentConflictError, + canTransitionServiceFeeOutcome, + linkServiceFeeAssessmentStripeIds, + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + observeServiceFeeAssessmentDispute, + observeServiceFeeAssessmentRefunds, + prepareServiceFeeAssessmentDecision, + sanitizeServiceFeeAssessmentMetadata, + settleServiceFeeAssessment, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; +import type { PrepareAssessmentInput } from '@/lib/service-fees/types'; + +function createMemoryAssessmentStore(): ServiceFeeAssessmentStore { + const rows = new Map(); + + const store: ServiceFeeAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + return rows.get(assessmentKey) ?? 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: + patch.metadata !== undefined + ? sanitizeServiceFeeAssessmentMetadata(patch.metadata) + : { ...existing.metadata }, + }; + rows.set(assessmentKey, next); + return { ...next }; + }, + }; + + return store; +} + +const ACTIVATION = new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000); +const BEFORE_ACTIVATION = new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1) * 1000); + +function personalInput(overrides: Partial = {}): PrepareAssessmentInput { + return { + assessmentKey: 'checkout:11111111-1111-4111-8111-111111111111', + flow: 'personal_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + kiloUserId: 'user_1', + ...overrides, + }; +} + +function organizationInput( + overrides: Partial = {} +): PrepareAssessmentInput { + return { + assessmentKey: 'invoice:in_test_1', + flow: 'organization_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + organizationId: 'org_1', + kiloUserId: 'user_1', + ...overrides, + }; +} + +async function persistPending( + store: ServiceFeeAssessmentStore, + input: PrepareAssessmentInput = personalInput() +) { + const decision = await prepareServiceFeeAssessmentDecision(input); + return upsertServiceFeeAssessment({ store, decision }); +} + +describe('prepareServiceFeeAssessmentDecision', () => { + test('validates owner and subtotal, then applies cutoff, exemption, and fee outcomes', async () => { + await expect( + prepareServiceFeeAssessmentDecision(personalInput({ kiloUserId: undefined })) + ).rejects.toThrow(/requires kiloUserId/); + await expect( + prepareServiceFeeAssessmentDecision(personalInput({ eligibleSubtotalMinor: 1.5 })) + ).rejects.toThrow(/safe integer/); + + const unsupported = await prepareServiceFeeAssessmentDecision( + personalInput({ currency: 'EUR', eligibleSubtotalMinor: 10_000 }) + ); + expect(unsupported).toMatchObject({ + outcome: 'unsupported_currency', + eligibleSubtotalMinor: 0, + expectedFeeMinor: 0, + chargedFeeMinor: 0, + }); + + const preActivation = await prepareServiceFeeAssessmentDecision( + personalInput({ eligibilityCreatedAt: BEFORE_ACTIVATION }) + ); + expect(preActivation).toMatchObject({ + outcome: 'pre_activation', + expectedFeeMinor: 500, + }); + + const exempt = await prepareServiceFeeAssessmentDecision(organizationInput(), { + findEffectiveExemption: async () => ({ id: 'hist_1', isExempt: true }), + }); + expect(exempt).toMatchObject({ + outcome: 'exempt', + expectedFeeMinor: 500, + exemptionId: 'hist_1', + }); + + const revoked = await prepareServiceFeeAssessmentDecision(organizationInput(), { + findEffectiveExemption: async () => ({ id: 'hist_2', isExempt: false }), + }); + expect(revoked).toMatchObject({ outcome: 'pending', exemptionId: null }); + + const zeroRounded = await prepareServiceFeeAssessmentDecision( + personalInput({ eligibleSubtotalMinor: 1 }) + ); + expect(zeroRounded).toMatchObject({ + outcome: 'zero_rounded', + expectedFeeMinor: 0, + }); + + const pending = await prepareServiceFeeAssessmentDecision(personalInput()); + expect(pending).toMatchObject({ + outcome: 'pending', + expectedFeeMinor: 500, + chargedFeeMinor: 0, + }); + }); +}); + +describe('upsertServiceFeeAssessment', () => { + test('is idempotent on assessment_key and enriches absent Stripe IDs', async () => { + const store = createMemoryAssessmentStore(); + const decision = await prepareServiceFeeAssessmentDecision(personalInput()); + const first = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { stripeCustomerId: 'cus_1' }, + }); + const second = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeCheckoutSessionId: 'cs_1', + stripePaymentIntentId: 'pi_1', + }, + }); + + expect(second.assessmentKey).toBe(first.assessmentKey); + expect(second.stripeCustomerId).toBe('cus_1'); + expect(second.stripeCheckoutSessionId).toBe('cs_1'); + expect(second.stripePaymentIntentId).toBe('pi_1'); + expect(second.outcome).toBe('pending'); + }); + + test('rejects conflicting owner, flow, currency, subtotal, expected fee, and Stripe IDs', async () => { + const store = createMemoryAssessmentStore(); + const decision = await prepareServiceFeeAssessmentDecision(personalInput()); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { stripeInvoiceId: 'in_1' }, + }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision: await prepareServiceFeeAssessmentDecision( + personalInput({ kiloUserId: 'user_other' }) + ), + }) + ).rejects.toMatchObject({ reason: 'owner', field: 'kiloUserId' }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision: { + ...decision, + flow: 'personal_kilo_pass', + }, + }) + ).rejects.toMatchObject({ reason: 'flow' }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision: { ...decision, currency: 'eur' }, + }) + ).rejects.toMatchObject({ reason: 'currency' }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision: { ...decision, eligibleSubtotalMinor: 20_000, expectedFeeMinor: 1_000 }, + }) + ).rejects.toMatchObject({ reason: 'eligible_subtotal' }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision: { ...decision, expectedFeeMinor: 499 }, + }) + ).rejects.toMatchObject({ reason: 'expected_fee' }); + + await expect( + upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { stripeInvoiceId: 'in_other' }, + }) + ).rejects.toMatchObject({ reason: 'stripe_id', field: 'stripeInvoiceId' }); + }); + + test('drops raw provider payload and PII from metadata', () => { + expect( + sanitizeServiceFeeAssessmentMetadata({ + service_fee_rate_deviation: true, + email: 'person@example.com', + payload: { raw: true }, + refund_allocation_unresolved: true, + }) + ).toEqual({ + service_fee_rate_deviation: true, + refund_allocation_unresolved: true, + }); + }); +}); + +describe('service fee assessment transitions', () => { + test('pending may become charged, missed, or a terminal omitted outcome, and those never recollect', async () => { + expect(canTransitionServiceFeeOutcome('pending', 'charged')).toBe(true); + expect(canTransitionServiceFeeOutcome('pending', 'missed')).toBe(true); + expect(canTransitionServiceFeeOutcome('pending', 'exempt')).toBe(true); + expect(canTransitionServiceFeeOutcome('missed', 'charged')).toBe(false); + expect(canTransitionServiceFeeOutcome('exempt', 'charged')).toBe(false); + expect(canTransitionServiceFeeOutcome('pre_activation', 'charged')).toBe(false); + + const store = createMemoryAssessmentStore(); + const pending = await persistPending(store); + const charged = await markServiceFeeAssessmentCharged({ + store, + assessmentKey: pending.assessmentKey, + chargedFeeMinor: 0, + stripeIds: { stripeCheckoutSessionId: 'cs_1' }, + }); + expect(charged.outcome).toBe('charged'); + expect(charged.chargedFeeMinor).toBe(0); + + await expect( + markServiceFeeAssessmentMissed({ + store, + assessmentKey: pending.assessmentKey, + failureCode: 'fee_application_failed', + }) + ).rejects.toBeInstanceOf(ServiceFeeAssessmentConflictError); + + const missedStore = createMemoryAssessmentStore(); + const missedPending = await persistPending( + missedStore, + personalInput({ assessmentKey: 'checkout:missed' }) + ); + const missed = await markServiceFeeAssessmentMissed({ + store: missedStore, + assessmentKey: missedPending.assessmentKey, + failureCode: 'fee_application_failed', + }); + expect(missed).toMatchObject({ + outcome: 'missed', + chargedFeeMinor: 0, + failureCode: 'fee_application_failed', + }); + await expect( + markServiceFeeAssessmentCharged({ + store: missedStore, + assessmentKey: missed.assessmentKey, + chargedFeeMinor: 500, + }) + ).rejects.toMatchObject({ reason: 'illegal_transition' }); + + await expect( + markServiceFeeAssessmentMissed({ + store: missedStore, + assessmentKey: missed.assessmentKey, + failureCode: ' ', + }) + ).rejects.toMatchObject({ reason: 'invalid_failure_code' }); + }); + + test('links Stripe IDs only when absent or identical', async () => { + const store = createMemoryAssessmentStore(); + const pending = await persistPending(store); + const linked = await linkServiceFeeAssessmentStripeIds({ + store, + assessmentKey: pending.assessmentKey, + stripeIds: { stripeChargeId: 'ch_1', stripePaymentIntentId: 'pi_1' }, + }); + const again = await linkServiceFeeAssessmentStripeIds({ + store, + assessmentKey: pending.assessmentKey, + stripeIds: { stripeChargeId: 'ch_1', stripeInvoiceId: 'in_1' }, + }); + + expect(again.stripeChargeId).toBe('ch_1'); + expect(again.stripePaymentIntentId).toBe('pi_1'); + expect(again.stripeInvoiceId).toBe('in_1'); + expect(linked.assessmentKey).toBe(again.assessmentKey); + + await expect( + linkServiceFeeAssessmentStripeIds({ + store, + assessmentKey: pending.assessmentKey, + stripeIds: { stripeChargeId: 'ch_other' }, + }) + ).rejects.toMatchObject({ reason: 'stripe_id' }); + }); +}); + +describe('settleServiceFeeAssessment', () => { + test('rejects pending and records observed product, fee, and gross idempotently', async () => { + const store = createMemoryAssessmentStore(); + const pending = await persistPending(store); + + await expect( + settleServiceFeeAssessment({ + store, + assessmentKey: pending.assessmentKey, + settledAt: '2026-09-01T01:00:00.000Z', + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + }) + ).rejects.toMatchObject({ reason: 'pending_settlement' }); + + await markServiceFeeAssessmentCharged({ + store, + assessmentKey: pending.assessmentKey, + chargedFeeMinor: 0, + }); + + const settled = await settleServiceFeeAssessment({ + store, + assessmentKey: pending.assessmentKey, + settledAt: '2026-09-01T01:00:00.000Z', + settledProductMinor: 8_000, + grossPaidMinor: 8_400, + chargedFeeMinor: 400, + stripeIds: { stripeChargeId: 'ch_1' }, + }); + expect(settled).toMatchObject({ + outcome: 'charged', + settledProductMinor: 8_000, + chargedFeeMinor: 400, + grossPaidMinor: 8_400, + stripeChargeId: 'ch_1', + settledAt: '2026-09-01T01:00:00.000Z', + }); + + const replay = await settleServiceFeeAssessment({ + store, + assessmentKey: pending.assessmentKey, + settledAt: '2026-09-01T02:00:00.000Z', + settledProductMinor: 8_000, + grossPaidMinor: 8_400, + chargedFeeMinor: 400, + stripeIds: { stripeChargeId: 'ch_1', stripeInvoiceId: 'in_1' }, + }); + expect(replay.settledAt).toBe('2026-09-01T01:00:00.000Z'); + expect(replay.stripeInvoiceId).toBe('in_1'); + + await expect( + settleServiceFeeAssessment({ + store, + assessmentKey: pending.assessmentKey, + settledAt: '2026-09-01T01:00:00.000Z', + settledProductMinor: 9_000, + grossPaidMinor: 8_400, + chargedFeeMinor: 400, + }) + ).rejects.toMatchObject({ field: 'settledProductMinor' }); + }); + + test('caps settled product at the eligible subtotal and can settle exempt or missed rows', async () => { + const store = createMemoryAssessmentStore(); + const decision = await prepareServiceFeeAssessmentDecision(organizationInput(), { + findEffectiveExemption: async () => ({ id: 'hist_1', isExempt: true }), + }); + const exempt = await upsertServiceFeeAssessment({ store, decision }); + const settled = await settleServiceFeeAssessment({ + store, + assessmentKey: exempt.assessmentKey, + settledAt: ACTIVATION, + settledProductMinor: 99_999, + grossPaidMinor: 10_000, + }); + expect(settled.settledProductMinor).toBe(10_000); + expect(settled.chargedFeeMinor).toBe(0); + expect(settled.outcome).toBe('exempt'); + }); +}); + +describe('refunds and disputes', () => { + test('refunds are monotonic and disputes are mutable without changing outcome', async () => { + const store = createMemoryAssessmentStore(); + const pending = await persistPending(store); + await markServiceFeeAssessmentCharged({ + store, + assessmentKey: pending.assessmentKey, + chargedFeeMinor: 500, + }); + await settleServiceFeeAssessment({ + store, + assessmentKey: pending.assessmentKey, + settledAt: ACTIVATION, + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + }); + + const firstRefund = await observeServiceFeeAssessmentRefunds({ + store, + assessmentKey: pending.assessmentKey, + refundedProductMinor: 4_000, + refundedFeeMinor: 200, + refundedGrossMinor: 4_200, + unresolved: true, + }); + expect(firstRefund).toMatchObject({ + refundedProductMinor: 4_000, + refundedFeeMinor: 200, + refundedGrossMinor: 4_200, + outcome: 'charged', + metadata: { refund_allocation_unresolved: true }, + }); + + const resolved = await observeServiceFeeAssessmentRefunds({ + store, + assessmentKey: pending.assessmentKey, + refundedProductMinor: 4_000, + refundedFeeMinor: 200, + refundedGrossMinor: 4_200, + unresolved: false, + }); + expect(resolved.metadata.refund_allocation_unresolved).toBeUndefined(); + + await expect( + observeServiceFeeAssessmentRefunds({ + store, + assessmentKey: pending.assessmentKey, + refundedProductMinor: 3_000, + refundedFeeMinor: 200, + }) + ).rejects.toMatchObject({ reason: 'non_monotonic_refund' }); + + await expect( + observeServiceFeeAssessmentRefunds({ + store, + assessmentKey: pending.assessmentKey, + refundedProductMinor: 10_000, + refundedFeeMinor: 501, + }) + ).rejects.toMatchObject({ reason: 'refund_exceeds_settled' }); + + const withdrawn = await observeServiceFeeAssessmentDispute({ + store, + assessmentKey: pending.assessmentKey, + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + }); + expect(withdrawn).toMatchObject({ + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + outcome: 'charged', + refundedFeeMinor: 200, + }); + + const won = await observeServiceFeeAssessmentDispute({ + store, + assessmentKey: pending.assessmentKey, + disputedProductMinor: 0, + disputedFeeMinor: 0, + }); + expect(won).toMatchObject({ + disputedProductMinor: 0, + disputedFeeMinor: 0, + outcome: 'charged', + refundedProductMinor: 4_000, + }); + }); +}); diff --git a/apps/web/src/lib/service-fees/assessments.ts b/apps/web/src/lib/service-fees/assessments.ts new file mode 100644 index 0000000000..7e0145e70b --- /dev/null +++ b/apps/web/src/lib/service-fees/assessments.ts @@ -0,0 +1,951 @@ +import 'server-only'; + +import { calculateServiceFeeMinor } from '@/lib/service-fees/calculation'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import type { OrganizationServiceFeeExemptionRecord } from '@/lib/service-fees/organization-exemptions'; +import { + getServiceFeeOwner, + isSupportedServiceFeeCurrency, + type PrepareAssessmentInput, + type ServiceFeeFlow, + type ServiceFeeOutcome, +} from '@/lib/service-fees/types'; + +export const SERVICE_FEE_TERMINAL_OMITTED_OUTCOMES = [ + 'exempt', + 'pre_activation', + 'zero_rounded', + 'unsupported_currency', +] as const satisfies readonly ServiceFeeOutcome[]; + +export type ServiceFeeTerminalOmittedOutcome = + (typeof SERVICE_FEE_TERMINAL_OMITTED_OUTCOMES)[number]; + +export type ServiceFeeAssessmentConflictReason = + | 'owner' + | 'flow' + | 'currency' + | 'eligible_subtotal' + | 'expected_fee' + | 'stripe_id' + | 'illegal_transition' + | 'pending_settlement' + | 'non_monotonic_refund' + | 'refund_exceeds_settled' + | 'dispute_exceeds_settled' + | 'invalid_failure_code' + | 'invalid_amount'; + +export class ServiceFeeAssessmentConflictError extends Error { + readonly name = 'ServiceFeeAssessmentConflictError'; + + constructor( + readonly assessmentKey: string, + readonly reason: ServiceFeeAssessmentConflictReason, + readonly field: string, + readonly existing: unknown, + readonly incoming: unknown, + message?: string + ) { + super(message ?? `service fee assessment ${assessmentKey} conflict on ${field} (${reason})`); + } +} + +export type ServiceFeeAssessmentMetadata = { + service_fee_rate_deviation?: true; + refund_allocation_unresolved?: true; +}; + +const ALLOWED_METADATA_KEYS = new Set([ + 'service_fee_rate_deviation', + 'refund_allocation_unresolved', +]); + +export type ServiceFeeStripeIds = { + stripeCustomerId?: string | null; + stripeCheckoutSessionId?: string | null; + stripeInvoiceId?: string | null; + stripePaymentIntentId?: string | null; + stripeChargeId?: string | null; + stripeFeePriceId?: string | null; + stripeCheckoutFeeLineItemId?: string | null; + stripeInvoiceFeeLineItemId?: string | null; +}; + +const STRIPE_ID_FIELDS = [ + 'stripeCustomerId', + 'stripeCheckoutSessionId', + 'stripeInvoiceId', + 'stripePaymentIntentId', + 'stripeChargeId', + 'stripeFeePriceId', + 'stripeCheckoutFeeLineItemId', + 'stripeInvoiceFeeLineItemId', +] as const satisfies readonly (keyof ServiceFeeStripeIds)[]; + +export type ServiceFeeAssessmentRecord = { + assessmentKey: string; + version: string; + flow: ServiceFeeFlow; + outcome: ServiceFeeOutcome; + currency: string; + kiloUserId: string | null; + organizationId: string | null; + stripeCustomerId: string | null; + stripeCheckoutSessionId: string | null; + stripeInvoiceId: string | null; + stripePaymentIntentId: string | null; + stripeChargeId: string | null; + stripeFeePriceId: string | null; + stripeCheckoutFeeLineItemId: string | null; + stripeInvoiceFeeLineItemId: string | null; + eligibilityCreatedAt: string; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + chargedFeeMinor: number; + grossPaidMinor: number; + settledProductMinor: number; + settledAt: string | null; + refundedProductMinor: number; + refundedFeeMinor: number; + refundedGrossMinor: number; + disputedProductMinor: number; + disputedFeeMinor: number; + exemptionId: string | null; + failureCode: string | null; + metadata: ServiceFeeAssessmentMetadata; + createdAt: string; + updatedAt: string; +}; + +/** + * Drizzle-compatible transaction wrapper. `db.transaction` and an already-open + * `tx` (whose `transaction` simply invokes the callback) both satisfy this. + */ +export type ServiceFeeAssessmentExecutor = { + transaction: (fn: (tx: ServiceFeeAssessmentExecutor) => Promise) => Promise; +}; + +export type ServiceFeeAssessmentStore = { + transact(fn: (store: ServiceFeeAssessmentStore) => Promise): Promise; + findByAssessmentKey(assessmentKey: string): Promise; + insert(record: ServiceFeeAssessmentRecord): Promise; + update( + assessmentKey: string, + patch: Partial + ): Promise; +}; + +export type EffectiveExemptionLookup = ( + organizationId: string, + at: Date +) => Promise | null>; + +export type PreparedServiceFeeDecision = { + assessmentKey: string; + version: typeof SERVICE_FEE_VERSION; + flow: ServiceFeeFlow; + outcome: Exclude; + currency: string; + kiloUserId: string | null; + organizationId: string | null; + stripeCustomerId: string | null; + eligibilityCreatedAt: string; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + chargedFeeMinor: 0; + exemptionId: string | null; + failureCode: null; + metadata: ServiceFeeAssessmentMetadata; +}; + +const FAILURE_CODE_PATTERN = /^[a-z][a-z0-9_]{0,99}$/; +const ISO_CURRENCY_PATTERN = /^[a-z]{3}$/; + +function assertNonNegativeSafeInteger(value: number, label: string): void { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } +} + +function assertNonEmptyAssessmentKey(assessmentKey: string): void { + if (typeof assessmentKey !== 'string' || assessmentKey.trim().length === 0) { + throw new Error('assessmentKey must be a nonempty string'); + } +} + +export function toServiceFeeTimestamp(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error('service fee timestamp is invalid'); + } + return date.toISOString(); +} + +export function sanitizeServiceFeeAssessmentMetadata( + metadata: Record | ServiceFeeAssessmentMetadata | null | undefined +): ServiceFeeAssessmentMetadata { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return {}; + } + + const sanitized: ServiceFeeAssessmentMetadata = {}; + for (const key of ALLOWED_METADATA_KEYS) { + if (metadata[key] === true) { + sanitized[key] = true; + } + } + return sanitized; +} + +export function assertServiceFeeFailureCode(failureCode: string, assessmentKey = ''): string { + const trimmed = failureCode.trim(); + if (!FAILURE_CODE_PATTERN.test(trimmed)) { + throw new ServiceFeeAssessmentConflictError( + assessmentKey, + 'invalid_failure_code', + 'failureCode', + null, + failureCode, + 'failure_code must be a stable nonempty internal code' + ); + } + return trimmed; +} + +export function isServiceFeeTerminalOmittedOutcome( + outcome: ServiceFeeOutcome +): outcome is ServiceFeeTerminalOmittedOutcome { + return (SERVICE_FEE_TERMINAL_OMITTED_OUTCOMES as readonly string[]).includes(outcome); +} + +export function canTransitionServiceFeeOutcome( + from: ServiceFeeOutcome, + to: ServiceFeeOutcome +): boolean { + if (from === to) return true; + if (from === 'pending') { + return to === 'charged' || to === 'missed' || isServiceFeeTerminalOmittedOutcome(to); + } + return false; +} + +function assertLegalOutcomeTransition( + assessmentKey: string, + from: ServiceFeeOutcome, + to: ServiceFeeOutcome +): void { + if (canTransitionServiceFeeOutcome(from, to)) return; + throw new ServiceFeeAssessmentConflictError( + assessmentKey, + 'illegal_transition', + 'outcome', + from, + to, + `service fee assessment ${assessmentKey} cannot transition from ${from} to ${to}` + ); +} + +function conflict( + assessmentKey: string, + reason: ServiceFeeAssessmentConflictReason, + field: string, + existing: unknown, + incoming: unknown +): never { + throw new ServiceFeeAssessmentConflictError(assessmentKey, reason, field, existing, incoming); +} + +function normalizeCurrency(currency: string): string { + return currency.trim().toLowerCase(); +} + +function enrichNullableString( + assessmentKey: string, + field: string, + existing: string | null, + incoming: string | null | undefined +): string | null { + if (incoming === undefined || incoming === null || incoming === '') { + return existing; + } + if (existing === null || existing === '') { + return incoming; + } + if (existing === incoming) { + return existing; + } + conflict( + assessmentKey, + field === 'kiloUserId' ? 'owner' : 'stripe_id', + field, + existing, + incoming + ); +} + +function mergeStripeIds( + assessmentKey: string, + existing: ServiceFeeAssessmentRecord, + incoming: ServiceFeeStripeIds | undefined +): Pick { + const merged = { + stripeCustomerId: existing.stripeCustomerId, + stripeCheckoutSessionId: existing.stripeCheckoutSessionId, + stripeInvoiceId: existing.stripeInvoiceId, + stripePaymentIntentId: existing.stripePaymentIntentId, + stripeChargeId: existing.stripeChargeId, + stripeFeePriceId: existing.stripeFeePriceId, + stripeCheckoutFeeLineItemId: existing.stripeCheckoutFeeLineItemId, + stripeInvoiceFeeLineItemId: existing.stripeInvoiceFeeLineItemId, + }; + + if (!incoming) return merged; + + for (const field of STRIPE_ID_FIELDS) { + merged[field] = enrichNullableString(assessmentKey, field, existing[field], incoming[field]); + } + return merged; +} + +function requireAssessment( + assessmentKey: string, + record: ServiceFeeAssessmentRecord | null +): ServiceFeeAssessmentRecord { + if (!record) { + throw new Error(`service fee assessment ${assessmentKey} was not found`); + } + return record; +} + +export async function prepareServiceFeeAssessmentDecision( + input: PrepareAssessmentInput, + deps: { findEffectiveExemption?: EffectiveExemptionLookup } = {} +): Promise { + assertNonEmptyAssessmentKey(input.assessmentKey); + assertNonNegativeSafeInteger(input.eligibleSubtotalMinor, 'eligibleSubtotalMinor'); + if (Number.isNaN(input.eligibilityCreatedAt.getTime())) { + throw new Error('eligibilityCreatedAt is invalid'); + } + + const owner = getServiceFeeOwner(input.flow, input); + const currency = normalizeCurrency(input.currency); + const eligibilityCreatedAt = toServiceFeeTimestamp(input.eligibilityCreatedAt); + const createdUnixSeconds = Math.floor(input.eligibilityCreatedAt.getTime() / 1000); + const stripeCustomerId = input.stripeCustomerId?.trim() ? input.stripeCustomerId.trim() : null; + const kiloUserId = owner.kind === 'personal' ? owner.kiloUserId : (owner.kiloUserId ?? null); + const organizationId = owner.kind === 'organization' ? owner.organizationId : null; + + if (!ISO_CURRENCY_PATTERN.test(currency) || !isSupportedServiceFeeCurrency(currency)) { + return { + assessmentKey: input.assessmentKey, + version: SERVICE_FEE_VERSION, + flow: input.flow, + outcome: 'unsupported_currency', + currency, + kiloUserId, + organizationId, + stripeCustomerId, + eligibilityCreatedAt, + eligibleSubtotalMinor: 0, + expectedFeeMinor: 0, + chargedFeeMinor: 0, + exemptionId: null, + failureCode: null, + metadata: {}, + }; + } + + let outcome: PreparedServiceFeeDecision['outcome'] = 'pending'; + let exemptionId: string | null = null; + + if (createdUnixSeconds < SERVICE_FEE_ACTIVATION_UNIX_SECONDS) { + outcome = 'pre_activation'; + } else if (owner.kind === 'organization') { + const exemption = deps.findEffectiveExemption + ? await deps.findEffectiveExemption(owner.organizationId, input.eligibilityCreatedAt) + : null; + if (exemption?.isExempt) { + outcome = 'exempt'; + exemptionId = exemption.id; + } + } + + const expectedFeeMinor = calculateServiceFeeMinor(input.eligibleSubtotalMinor); + if (outcome === 'pending' && expectedFeeMinor === 0) { + outcome = 'zero_rounded'; + } + + return { + assessmentKey: input.assessmentKey, + version: SERVICE_FEE_VERSION, + flow: input.flow, + outcome, + currency, + kiloUserId, + organizationId, + stripeCustomerId, + eligibilityCreatedAt, + eligibleSubtotalMinor: input.eligibleSubtotalMinor, + expectedFeeMinor, + chargedFeeMinor: 0, + exemptionId, + failureCode: null, + metadata: {}, + }; +} + +function assertImmutableFacts( + existing: ServiceFeeAssessmentRecord, + incoming: { + flow: ServiceFeeFlow; + currency: string; + organizationId: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + } +): void { + if (existing.flow !== incoming.flow) { + conflict(existing.assessmentKey, 'flow', 'flow', existing.flow, incoming.flow); + } + if (existing.currency !== incoming.currency) { + conflict(existing.assessmentKey, 'currency', 'currency', existing.currency, incoming.currency); + } + if (existing.organizationId !== incoming.organizationId) { + conflict( + existing.assessmentKey, + 'owner', + 'organizationId', + existing.organizationId, + incoming.organizationId + ); + } + if (existing.eligibleSubtotalMinor !== incoming.eligibleSubtotalMinor) { + conflict( + existing.assessmentKey, + 'eligible_subtotal', + 'eligibleSubtotalMinor', + existing.eligibleSubtotalMinor, + incoming.eligibleSubtotalMinor + ); + } + if (existing.expectedFeeMinor !== incoming.expectedFeeMinor) { + conflict( + existing.assessmentKey, + 'expected_fee', + 'expectedFeeMinor', + existing.expectedFeeMinor, + incoming.expectedFeeMinor + ); + } +} + +function buildNewAssessmentRecord(params: { + decision: PreparedServiceFeeDecision; + stripeIds?: ServiceFeeStripeIds; + now: Date; +}): ServiceFeeAssessmentRecord { + const nowIso = toServiceFeeTimestamp(params.now); + const stripeIds = params.stripeIds ?? {}; + return { + assessmentKey: params.decision.assessmentKey, + version: params.decision.version, + flow: params.decision.flow, + outcome: params.decision.outcome, + currency: params.decision.currency, + kiloUserId: params.decision.kiloUserId, + organizationId: params.decision.organizationId, + stripeCustomerId: stripeIds.stripeCustomerId ?? params.decision.stripeCustomerId, + stripeCheckoutSessionId: stripeIds.stripeCheckoutSessionId ?? null, + stripeInvoiceId: stripeIds.stripeInvoiceId ?? null, + stripePaymentIntentId: stripeIds.stripePaymentIntentId ?? null, + stripeChargeId: stripeIds.stripeChargeId ?? null, + stripeFeePriceId: stripeIds.stripeFeePriceId ?? null, + stripeCheckoutFeeLineItemId: stripeIds.stripeCheckoutFeeLineItemId ?? null, + stripeInvoiceFeeLineItemId: stripeIds.stripeInvoiceFeeLineItemId ?? null, + eligibilityCreatedAt: params.decision.eligibilityCreatedAt, + eligibleSubtotalMinor: params.decision.eligibleSubtotalMinor, + expectedFeeMinor: params.decision.expectedFeeMinor, + chargedFeeMinor: 0, + grossPaidMinor: 0, + settledProductMinor: 0, + settledAt: null, + refundedProductMinor: 0, + refundedFeeMinor: 0, + refundedGrossMinor: 0, + disputedProductMinor: 0, + disputedFeeMinor: 0, + exemptionId: params.decision.exemptionId, + failureCode: null, + metadata: sanitizeServiceFeeAssessmentMetadata(params.decision.metadata), + createdAt: nowIso, + updatedAt: nowIso, + }; +} + +export async function upsertServiceFeeAssessment(params: { + store: ServiceFeeAssessmentStore; + decision: PreparedServiceFeeDecision; + stripeIds?: ServiceFeeStripeIds; + now?: Date; +}): Promise { + const now = params.now ?? new Date(); + + return params.store.transact(async store => { + const existing = await store.findByAssessmentKey(params.decision.assessmentKey); + if (!existing) { + try { + return await store.insert( + buildNewAssessmentRecord({ + decision: params.decision, + stripeIds: params.stripeIds, + now, + }) + ); + } catch (error) { + const raced = await store.findByAssessmentKey(params.decision.assessmentKey); + if (!raced) throw error; + return mergePreparedAssessment(store, raced, params.decision, params.stripeIds, now); + } + } + + return mergePreparedAssessment(store, existing, params.decision, params.stripeIds, now); + }); +} + +async function mergePreparedAssessment( + store: ServiceFeeAssessmentStore, + existing: ServiceFeeAssessmentRecord, + decision: PreparedServiceFeeDecision, + stripeIds: ServiceFeeStripeIds | undefined, + now: Date +): Promise { + assertImmutableFacts(existing, decision); + + if (existing.kiloUserId && decision.kiloUserId && existing.kiloUserId !== decision.kiloUserId) { + conflict( + existing.assessmentKey, + 'owner', + 'kiloUserId', + existing.kiloUserId, + decision.kiloUserId + ); + } + + if (existing.exemptionId !== decision.exemptionId) { + conflict( + existing.assessmentKey, + 'illegal_transition', + 'exemptionId', + existing.exemptionId, + decision.exemptionId + ); + } + + if (existing.outcome !== decision.outcome) { + assertLegalOutcomeTransition(existing.assessmentKey, existing.outcome, decision.outcome); + } + + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, { + ...stripeIds, + stripeCustomerId: stripeIds?.stripeCustomerId ?? decision.stripeCustomerId, + }); + const kiloUserId = enrichNullableString( + existing.assessmentKey, + 'kiloUserId', + existing.kiloUserId, + decision.kiloUserId + ); + + return store.update(existing.assessmentKey, { + ...mergedIds, + kiloUserId, + outcome: existing.outcome === 'pending' ? decision.outcome : existing.outcome, + updatedAt: toServiceFeeTimestamp(now), + }); +} + +export async function markServiceFeeAssessmentCharged(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + chargedFeeMinor: number; + stripeIds?: ServiceFeeStripeIds; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + assertNonNegativeSafeInteger(params.chargedFeeMinor, 'chargedFeeMinor'); + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + + if (existing.outcome === 'charged') { + const attachmentRetryAfterSettlement = + params.chargedFeeMinor === 0 && existing.settledAt !== null; + if (existing.chargedFeeMinor !== params.chargedFeeMinor && !attachmentRetryAfterSettlement) { + conflict( + existing.assessmentKey, + 'expected_fee', + 'chargedFeeMinor', + existing.chargedFeeMinor, + params.chargedFeeMinor + ); + } + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + return store.update(existing.assessmentKey, { + ...mergedIds, + updatedAt: nowIso, + }); + } + + assertLegalOutcomeTransition(existing.assessmentKey, existing.outcome, 'charged'); + if (existing.expectedFeeMinor <= 0) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'invalid_amount', + 'expectedFeeMinor', + existing.expectedFeeMinor, + params.chargedFeeMinor, + `service fee assessment ${existing.assessmentKey} cannot be charged without a positive expected fee` + ); + } + + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + return store.update(existing.assessmentKey, { + ...mergedIds, + outcome: 'charged', + chargedFeeMinor: params.chargedFeeMinor, + failureCode: null, + updatedAt: nowIso, + }); + }); +} + +export async function markServiceFeeAssessmentMissed(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + failureCode: string; + stripeIds?: ServiceFeeStripeIds; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + const failureCode = assertServiceFeeFailureCode(params.failureCode, params.assessmentKey); + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + + if (existing.outcome === 'missed') { + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + return store.update(existing.assessmentKey, { + ...mergedIds, + chargedFeeMinor: 0, + failureCode: existing.failureCode ?? failureCode, + updatedAt: nowIso, + }); + } + + assertLegalOutcomeTransition(existing.assessmentKey, existing.outcome, 'missed'); + if (existing.expectedFeeMinor <= 0) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'invalid_amount', + 'expectedFeeMinor', + existing.expectedFeeMinor, + 0, + `service fee assessment ${existing.assessmentKey} cannot be missed without a positive expected fee` + ); + } + + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + return store.update(existing.assessmentKey, { + ...mergedIds, + outcome: 'missed', + chargedFeeMinor: 0, + failureCode, + updatedAt: nowIso, + }); + }); +} + +export async function linkServiceFeeAssessmentStripeIds(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + stripeIds: ServiceFeeStripeIds; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + return store.update(existing.assessmentKey, { + ...mergedIds, + updatedAt: nowIso, + }); + }); +} + +export async function settleServiceFeeAssessment(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + settledAt: Date | string; + settledProductMinor: number; + grossPaidMinor: number; + chargedFeeMinor?: number; + stripeIds?: ServiceFeeStripeIds; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + assertNonNegativeSafeInteger(params.settledProductMinor, 'settledProductMinor'); + assertNonNegativeSafeInteger(params.grossPaidMinor, 'grossPaidMinor'); + if (params.chargedFeeMinor !== undefined) { + assertNonNegativeSafeInteger(params.chargedFeeMinor, 'chargedFeeMinor'); + } + const settledAt = toServiceFeeTimestamp(params.settledAt); + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + + if (existing.outcome === 'pending') { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'pending_settlement', + 'outcome', + existing.outcome, + 'settled', + `service fee assessment ${existing.assessmentKey} cannot settle while pending` + ); + } + + const settledProductMinor = Math.min( + params.settledProductMinor, + existing.eligibleSubtotalMinor + ); + const chargedFeeMinor = + existing.outcome === 'charged' ? (params.chargedFeeMinor ?? existing.chargedFeeMinor) : 0; + + if (existing.outcome !== 'charged' && (params.chargedFeeMinor ?? 0) > 0) { + conflict( + existing.assessmentKey, + 'illegal_transition', + 'chargedFeeMinor', + existing.chargedFeeMinor, + params.chargedFeeMinor + ); + } + + if (existing.outcome === 'charged' && chargedFeeMinor === 0 && settledProductMinor !== 0) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'invalid_amount', + 'chargedFeeMinor', + existing.chargedFeeMinor, + chargedFeeMinor, + `service fee assessment ${existing.assessmentKey} charged fee may be zero only when settled product is zero` + ); + } + + const mergedIds = mergeStripeIds(existing.assessmentKey, existing, params.stripeIds); + + if (existing.settledAt) { + if (existing.settledProductMinor !== settledProductMinor) { + conflict( + existing.assessmentKey, + 'eligible_subtotal', + 'settledProductMinor', + existing.settledProductMinor, + settledProductMinor + ); + } + if (existing.grossPaidMinor !== params.grossPaidMinor) { + conflict( + existing.assessmentKey, + 'invalid_amount', + 'grossPaidMinor', + existing.grossPaidMinor, + params.grossPaidMinor + ); + } + if (existing.chargedFeeMinor !== chargedFeeMinor) { + conflict( + existing.assessmentKey, + 'expected_fee', + 'chargedFeeMinor', + existing.chargedFeeMinor, + chargedFeeMinor + ); + } + return store.update(existing.assessmentKey, { + ...mergedIds, + updatedAt: nowIso, + }); + } + + return store.update(existing.assessmentKey, { + ...mergedIds, + chargedFeeMinor, + settledProductMinor, + grossPaidMinor: params.grossPaidMinor, + settledAt, + updatedAt: nowIso, + }); + }); +} + +export async function observeServiceFeeAssessmentRefunds(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + refundedProductMinor: number; + refundedFeeMinor: number; + refundedGrossMinor?: number; + unresolved?: boolean; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + assertNonNegativeSafeInteger(params.refundedProductMinor, 'refundedProductMinor'); + assertNonNegativeSafeInteger(params.refundedFeeMinor, 'refundedFeeMinor'); + if (params.refundedGrossMinor !== undefined) { + assertNonNegativeSafeInteger(params.refundedGrossMinor, 'refundedGrossMinor'); + } + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + + if (params.refundedProductMinor < existing.refundedProductMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'non_monotonic_refund', + 'refundedProductMinor', + existing.refundedProductMinor, + params.refundedProductMinor + ); + } + if (params.refundedFeeMinor < existing.refundedFeeMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'non_monotonic_refund', + 'refundedFeeMinor', + existing.refundedFeeMinor, + params.refundedFeeMinor + ); + } + if ( + params.refundedGrossMinor !== undefined && + params.refundedGrossMinor < existing.refundedGrossMinor + ) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'non_monotonic_refund', + 'refundedGrossMinor', + existing.refundedGrossMinor, + params.refundedGrossMinor + ); + } + if (params.refundedProductMinor > existing.settledProductMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'refund_exceeds_settled', + 'refundedProductMinor', + existing.settledProductMinor, + params.refundedProductMinor + ); + } + if (params.refundedFeeMinor > existing.chargedFeeMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'refund_exceeds_settled', + 'refundedFeeMinor', + existing.chargedFeeMinor, + params.refundedFeeMinor + ); + } + + const nextMetadata = + params.unresolved === undefined + ? undefined + : sanitizeServiceFeeAssessmentMetadata( + params.unresolved + ? { ...existing.metadata, refund_allocation_unresolved: true } + : Object.fromEntries( + Object.entries(existing.metadata).filter( + ([key]) => key !== 'refund_allocation_unresolved' + ) + ) + ); + + return store.update(existing.assessmentKey, { + refundedProductMinor: params.refundedProductMinor, + refundedFeeMinor: params.refundedFeeMinor, + refundedGrossMinor: params.refundedGrossMinor ?? existing.refundedGrossMinor, + ...(nextMetadata === undefined ? {} : { metadata: nextMetadata }), + updatedAt: nowIso, + }); + }); +} + +export async function observeServiceFeeAssessmentDispute(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + disputedProductMinor: number; + disputedFeeMinor: number; + now?: Date; +}): Promise { + assertNonEmptyAssessmentKey(params.assessmentKey); + assertNonNegativeSafeInteger(params.disputedProductMinor, 'disputedProductMinor'); + assertNonNegativeSafeInteger(params.disputedFeeMinor, 'disputedFeeMinor'); + const nowIso = toServiceFeeTimestamp(params.now ?? new Date()); + + return params.store.transact(async store => { + const existing = requireAssessment( + params.assessmentKey, + await store.findByAssessmentKey(params.assessmentKey) + ); + + if (params.disputedProductMinor > existing.settledProductMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'dispute_exceeds_settled', + 'disputedProductMinor', + existing.settledProductMinor, + params.disputedProductMinor + ); + } + if (params.disputedFeeMinor > existing.chargedFeeMinor) { + throw new ServiceFeeAssessmentConflictError( + existing.assessmentKey, + 'dispute_exceeds_settled', + 'disputedFeeMinor', + existing.chargedFeeMinor, + params.disputedFeeMinor + ); + } + + return store.update(existing.assessmentKey, { + disputedProductMinor: params.disputedProductMinor, + disputedFeeMinor: params.disputedFeeMinor, + updatedAt: nowIso, + }); + }); +} diff --git a/apps/web/src/lib/service-fees/calculation.test.ts b/apps/web/src/lib/service-fees/calculation.test.ts new file mode 100644 index 0000000000..65692922a5 --- /dev/null +++ b/apps/web/src/lib/service-fees/calculation.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { + calculateCumulativeFeeRefundMinor, + calculateServiceFeeMinor, + getNetPretaxLineAmountMinor, +} from '@/lib/service-fees/calculation'; +import { + getServiceFeeOwner, + isOrganizationServiceFeeFlow, + isPersonalServiceFeeFlow, + isSupportedServiceFeeCurrency, +} from '@/lib/service-fees/types'; + +function invoiceLine( + overrides: Partial & + Pick & { + pretax_credit_amounts?: Stripe.InvoiceLineItem['pretax_credit_amounts']; + discount_amounts?: Stripe.InvoiceLineItem['discount_amounts']; + taxes?: Stripe.InvoiceLineItem['taxes']; + } +): Stripe.InvoiceLineItem { + return { + id: 'il_test', + object: 'line_item', + discountable: true, + discounts: [], + invoice: 'in_test', + livemode: false, + metadata: {}, + parent: null, + period: { start: 1, end: 2 }, + pricing: null, + quantity: 1, + subscription: null, + taxes: null, + pretax_credit_amounts: null, + discount_amounts: null, + description: 'Kilo Pass', + ...overrides, + } as Stripe.InvoiceLineItem; +} + +describe('service fee types', () => { + test('classifies personal and organization flows and requires the matching owner', () => { + expect(isPersonalServiceFeeFlow('personal_kilo_pass')).toBe(true); + expect(isOrganizationServiceFeeFlow('organization_top_up')).toBe(true); + expect(isSupportedServiceFeeCurrency('usd')).toBe(true); + expect(isSupportedServiceFeeCurrency('eur')).toBe(false); + + expect(getServiceFeeOwner('personal_top_up', { kiloUserId: 'user_1' })).toEqual({ + kind: 'personal', + kiloUserId: 'user_1', + }); + expect( + getServiceFeeOwner('organization_kilo_pass', { + organizationId: 'org_1', + kiloUserId: 'user_1', + }) + ).toEqual({ + kind: 'organization', + organizationId: 'org_1', + kiloUserId: 'user_1', + }); + expect(() => getServiceFeeOwner('personal_top_up', { organizationId: 'org_1' })).toThrow( + /requires kiloUserId/ + ); + expect(() => + getServiceFeeOwner('personal_top_up', { kiloUserId: 'user_1', organizationId: 'org_1' }) + ).toThrow(/forbids organizationId/); + expect(() => getServiceFeeOwner('organization_top_up', { kiloUserId: 'user_1' })).toThrow( + /requires organizationId/ + ); + }); +}); + +describe('calculateServiceFeeMinor', () => { + test.each([ + { subtotalMinor: 0, feeMinor: 0, label: '0 -> 0' }, + { subtotalMinor: 1, feeMinor: 0, label: '$0.01 -> $0.00' }, + { subtotalMinor: 10, feeMinor: 1, label: '$0.10 -> $0.01 at the half-cent boundary' }, + { subtotalMinor: 1_900, feeMinor: 95, label: '$19.00 -> $0.95' }, + { subtotalMinor: 4_900, feeMinor: 245, label: '$49.00 -> $2.45' }, + { subtotalMinor: 19_900, feeMinor: 995, label: '$199.00 -> $9.95' }, + { subtotalMinor: 10_000, feeMinor: 500, label: '$100.00 -> $5.00' }, + ])('rounds $label', ({ subtotalMinor, feeMinor }) => { + expect(calculateServiceFeeMinor(subtotalMinor)).toBe(feeMinor); + }); + + test('calculates once on the aggregate subtotal rather than per line', () => { + const perLine = calculateServiceFeeMinor(10) + calculateServiceFeeMinor(10); + const aggregate = calculateServiceFeeMinor(20); + + expect(perLine).toBe(2); + expect(aggregate).toBe(1); + expect(calculateServiceFeeMinor(3_000 + -1_000)).toBe(100); + }); + + test('rejects invalid or non-safe integers', () => { + for (const value of [ + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect(() => calculateServiceFeeMinor(value)).toThrow(/safe integer/); + } + }); +}); + +describe('getNetPretaxLineAmountMinor', () => { + test('subtracts pretax discount credits and ignores credit-balance consumption', () => { + const line = invoiceLine({ + amount: 4_900, + currency: 'usd', + pretax_credit_amounts: [ + { amount: 980, type: 'discount', discount: 'di_percent' }, + { amount: 4_900, type: 'credit_balance_transaction', credit_balance_transaction: 'cbt_1' }, + ], + taxes: [ + { + amount: 392, + tax_behavior: 'exclusive', + tax_rate_details: { tax_rate: 'txr_1' }, + taxability_reason: 'standard_rated', + taxable_amount: 3_920, + type: 'tax_rate_details', + }, + ], + }); + + expect(getNetPretaxLineAmountMinor(line)).toBe(3_920); + }); + + test('falls back to discount_amounts when pretax discount credits are absent', () => { + const line = invoiceLine({ + amount: 4_900, + currency: 'usd', + discount_amounts: [{ amount: 980, discount: 'di_percent' }], + }); + + expect(getNetPretaxLineAmountMinor(line)).toBe(3_920); + }); + + test('does not double-subtract a discount present in both Stripe arrays', () => { + const line = invoiceLine({ + amount: 4_900, + currency: 'usd', + discount_amounts: [{ amount: 980, discount: 'di_percent' }], + pretax_credit_amounts: [{ amount: 980, type: 'discount', discount: 'di_percent' }], + }); + + expect(getNetPretaxLineAmountMinor(line)).toBe(3_920); + }); + + test('subtracts only unmatched discount_amounts when pretax discount credits already exist', () => { + const line = invoiceLine({ + amount: 10_000, + currency: 'usd', + discount_amounts: [ + { amount: 500, discount: 'di_already_counted' }, + { amount: 200, discount: 'di_extra' }, + ], + pretax_credit_amounts: [{ amount: 500, type: 'discount', discount: 'di_already_counted' }], + }); + + expect(getNetPretaxLineAmountMinor(line)).toBe(9_300); + }); + + test('preserves negative proration lines and validates currency', () => { + const credit = invoiceLine({ amount: -1_000, currency: 'usd' }); + expect(getNetPretaxLineAmountMinor(credit)).toBe(-1_000); + expect(() => getNetPretaxLineAmountMinor(credit, 'eur')).toThrow(/does not match expected eur/); + expect(() => + getNetPretaxLineAmountMinor(invoiceLine({ amount: 100, currency: 'USD' })) + ).toThrow(/lowercase ISO code/); + expect(() => + getNetPretaxLineAmountMinor(invoiceLine({ amount: 1.25, currency: 'usd' })) + ).toThrow(/safe integer/); + }); +}); + +describe('calculateCumulativeFeeRefundMinor', () => { + test('returns zero for no product refund and for a zero-product settlement', () => { + expect( + calculateCumulativeFeeRefundMinor({ + originalProductMinor: 10_000, + originalFeeMinor: 500, + cumulativeProductRefundMinor: 0, + }) + ).toBe(0); + expect( + calculateCumulativeFeeRefundMinor({ + originalProductMinor: 0, + originalFeeMinor: 0, + cumulativeProductRefundMinor: 0, + }) + ).toBe(0); + }); + + test('uses cumulative half-up rounding with no drift back to the original fee', () => { + const originalProductMinor = 10_000; + const originalFeeMinor = 500; + let previous = 0; + + for (const cumulativeProductRefundMinor of [3_333, 6_666, 10_000]) { + const cumulative = calculateCumulativeFeeRefundMinor({ + originalProductMinor, + originalFeeMinor, + cumulativeProductRefundMinor, + }); + expect(cumulative).toBeGreaterThanOrEqual(previous); + expect(cumulative).toBeLessThanOrEqual(originalFeeMinor); + previous = cumulative; + } + + expect( + calculateCumulativeFeeRefundMinor({ + originalProductMinor, + originalFeeMinor, + cumulativeProductRefundMinor: originalProductMinor, + }) + ).toBe(originalFeeMinor); + + let recordedFeeRefund = 0; + for (let refundedProduct = 1; refundedProduct <= 4_900; refundedProduct += 1) { + const target = calculateCumulativeFeeRefundMinor({ + originalProductMinor: 4_900, + originalFeeMinor: 245, + cumulativeProductRefundMinor: refundedProduct, + }); + const incremental = target - recordedFeeRefund; + expect(incremental).toBeGreaterThanOrEqual(0); + expect(recordedFeeRefund + incremental).toBeLessThanOrEqual(245); + recordedFeeRefund = target; + } + expect(recordedFeeRefund).toBe(245); + }); + + test('rejects invalid integers and refunds larger than the original product', () => { + expect(() => + calculateCumulativeFeeRefundMinor({ + originalProductMinor: -1, + originalFeeMinor: 0, + cumulativeProductRefundMinor: 0, + }) + ).toThrow(/safe integer/); + expect(() => + calculateCumulativeFeeRefundMinor({ + originalProductMinor: 100, + originalFeeMinor: 5.5, + cumulativeProductRefundMinor: 10, + }) + ).toThrow(/safe integer/); + expect(() => + calculateCumulativeFeeRefundMinor({ + originalProductMinor: 100, + originalFeeMinor: 5, + cumulativeProductRefundMinor: 101, + }) + ).toThrow(/cannot exceed originalProductMinor/); + }); +}); diff --git a/apps/web/src/lib/service-fees/calculation.ts b/apps/web/src/lib/service-fees/calculation.ts new file mode 100644 index 0000000000..7dfc61dc26 --- /dev/null +++ b/apps/web/src/lib/service-fees/calculation.ts @@ -0,0 +1,151 @@ +import type Stripe from 'stripe'; + +import { + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_RATE_DENOMINATOR, +} from '@/lib/service-fees/constants'; +import type { CalculateCumulativeFeeRefundInput } from '@/lib/service-fees/types'; + +const ISO_CURRENCY_PATTERN = /^[a-z]{3}$/; +const ROUND_HALF_UP_OFFSET = BigInt(SERVICE_FEE_RATE_DENOMINATOR / 2); +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); + +export function calculateServiceFeeMinor(eligibleSubtotalMinor: number): number { + assertNonNegativeSafeInteger(eligibleSubtotalMinor, 'eligibleSubtotalMinor'); + + const rounded = + (BigInt(eligibleSubtotalMinor) * BigInt(SERVICE_FEE_RATE_BASIS_POINTS) + ROUND_HALF_UP_OFFSET) / + BigInt(SERVICE_FEE_RATE_DENOMINATOR); + + return bigintToSafeInteger(rounded, 'service fee'); +} + +export function getNetPretaxLineAmountMinor( + line: Stripe.InvoiceLineItem, + expectedCurrency: string = line.currency +): number { + assertSafeInteger(line.amount, 'line.amount'); + assertCurrency(line.currency, expectedCurrency); + + const pretaxDiscounts = collectDiscountPretaxCredits(line); + let discountMinor = pretaxDiscounts.total; + + const discountAmounts = line.discount_amounts ?? []; + for (const entry of discountAmounts) { + assertSafeInteger(entry.amount, 'discount_amounts.amount'); + if (pretaxDiscounts.hasDiscountType && isMatchedDiscount(entry.discount, pretaxDiscounts.ids)) { + continue; + } + if (pretaxDiscounts.hasDiscountType) { + const discountId = getDiscountReferenceId(entry.discount); + if (!discountId) continue; + } + discountMinor += BigInt(entry.amount); + } + + return bigintToSafeInteger(BigInt(line.amount) - discountMinor, 'net pretax line amount'); +} + +export function calculateCumulativeFeeRefundMinor({ + originalProductMinor, + originalFeeMinor, + cumulativeProductRefundMinor, +}: CalculateCumulativeFeeRefundInput): number { + assertNonNegativeSafeInteger(originalProductMinor, 'originalProductMinor'); + assertNonNegativeSafeInteger(originalFeeMinor, 'originalFeeMinor'); + assertNonNegativeSafeInteger(cumulativeProductRefundMinor, 'cumulativeProductRefundMinor'); + + if (cumulativeProductRefundMinor > originalProductMinor) { + throw new Error('cumulativeProductRefundMinor cannot exceed originalProductMinor'); + } + if (originalProductMinor === 0 || cumulativeProductRefundMinor === 0 || originalFeeMinor === 0) { + return 0; + } + if (cumulativeProductRefundMinor === originalProductMinor) { + return originalFeeMinor; + } + + const denominator = BigInt(originalProductMinor); + const rounded = + (BigInt(originalFeeMinor) * BigInt(cumulativeProductRefundMinor) + denominator / BigInt(2)) / + denominator; + const cumulative = bigintToSafeInteger(rounded, 'cumulative fee refund'); + if (cumulative < 0) return 0; + if (cumulative > originalFeeMinor) return originalFeeMinor; + return cumulative; +} + +function collectDiscountPretaxCredits(line: Stripe.InvoiceLineItem): { + total: bigint; + ids: Set; + hasDiscountType: boolean; +} { + const ids = new Set(); + let total = BigInt(0); + let hasDiscountType = false; + + for (const entry of line.pretax_credit_amounts ?? []) { + assertSafeInteger(entry.amount, 'pretax_credit_amounts.amount'); + if (entry.type !== 'discount') continue; + hasDiscountType = true; + total += BigInt(entry.amount); + const discountId = getDiscountReferenceId(entry.discount); + if (discountId) ids.add(discountId); + } + + return { total, ids, hasDiscountType }; +} + +function isMatchedDiscount( + discount: Stripe.InvoiceLineItem.DiscountAmount['discount'], + matchedIds: Set +): boolean { + if (matchedIds.size === 0) return true; + const discountId = getDiscountReferenceId(discount); + return discountId !== null && matchedIds.has(discountId); +} + +function getDiscountReferenceId( + discount: string | Stripe.Discount | Stripe.DeletedDiscount | undefined +): string | null { + if (!discount) return null; + if (typeof discount === 'string') return discount; + return typeof discount.id === 'string' ? discount.id : null; +} + +function assertCurrency(currency: string, expectedCurrency: string): void { + if (typeof currency !== 'string' || !ISO_CURRENCY_PATTERN.test(currency)) { + throw new Error(`currency must be a lowercase ISO code, received ${JSON.stringify(currency)}`); + } + if (typeof expectedCurrency !== 'string' || !ISO_CURRENCY_PATTERN.test(expectedCurrency)) { + throw new Error( + `expected currency must be a lowercase ISO code, received ${JSON.stringify(expectedCurrency)}` + ); + } + if (currency !== expectedCurrency) { + throw new Error(`line currency ${currency} does not match expected ${expectedCurrency}`); + } +} + +function assertNonNegativeSafeInteger(value: number, label: string): void { + assertSafeInteger(value, label); + if (value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } +} + +function assertSafeInteger(value: number, label: string): void { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`${label} must be a safe integer`); + } +} + +function bigintToSafeInteger(value: bigint, label: string): number { + if (value < BigInt(0) && value < -MAX_SAFE_INTEGER) { + throw new Error(`${label} exceeds safe integer range`); + } + if (value > MAX_SAFE_INTEGER) { + throw new Error(`${label} exceeds safe integer range`); + } + return Number(value); +} diff --git a/apps/web/src/lib/service-fees/checkout.test.ts b/apps/web/src/lib/service-fees/checkout.test.ts new file mode 100644 index 0000000000..42627ee4ac --- /dev/null +++ b/apps/web/src/lib/service-fees/checkout.test.ts @@ -0,0 +1,1131 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { + ServiceFeeAssessmentConflictError, + 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 { + attachPreparedAutoTopUpInvoiceFee, + buildTopUpServiceFeeCheckoutLineItem, + checkoutFeeDecisionDisagreesWithSessionCreated, + createCheckoutServiceFeeAssessmentKey, + createInvoiceServiceFeeAssessmentKey, + createTopUpCheckoutSession, + type CheckoutSessionLike, + isKiloOwnedAutoTopUpInvoice, + isWithinServiceFeeActivationBoundaryWindow, + mergeServiceFeeCommercialMetadata, + prepareAutoTopUpInvoiceFee, + prepareTopUpCheckoutFee, + resolveFixedUsdPriceUnitAmount, + settleTrustedAutoTopUpInvoice, + settleTrustedTopUpCharge, + SERVICE_FEE_FAILURE_ACTIVATION_BOUNDARY, + SERVICE_FEE_FAILURE_APPLICATION, + type ServiceFeeCheckoutDependencies, +} from '@/lib/service-fees/checkout'; +import { buildInheritedInlineServiceFeeTaxInput } from '@/lib/service-fees/tax'; + +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; +} + +const ACTIVATION = new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000); +const BEFORE_ACTIVATION = new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1) * 1000); +const NEAR_BEFORE = new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 30) * 1000); +const NEAR_AFTER = new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 30) * 1000); +const FAR_AFTER = new Date((SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 120) * 1000); + +function approvedTax() { + return buildInheritedInlineServiceFeeTaxInput(); +} + +function deps( + store: ServiceFeeAssessmentStore, + overrides: Partial = {} +): ServiceFeeCheckoutDependencies { + return { + store, + now: ACTIVATION, + sendAlert: jest.fn(async () => undefined), + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + ...overrides, + }; +} + +describe('checkout service-fee helpers', () => { + test('assessment keys and kilo-owned invoice skip', () => { + expect(createCheckoutServiceFeeAssessmentKey('11111111-1111-4111-8111-111111111111')).toBe( + 'checkout:11111111-1111-4111-8111-111111111111' + ); + expect(createInvoiceServiceFeeAssessmentKey('in_123')).toBe('invoice:in_123'); + expect(isKiloOwnedAutoTopUpInvoice({ metadata: { type: 'auto-topup' } })).toBe(true); + expect(isKiloOwnedAutoTopUpInvoice({ metadata: { type: 'org-auto-topup' } })).toBe(true); + expect(isKiloOwnedAutoTopUpInvoice({ metadata: { type: 'kilo-pass' } })).toBe(false); + }); + + test('activation boundary window and decision disagreement', () => { + expect( + isWithinServiceFeeActivationBoundaryWindow(SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 60) + ).toBe(true); + expect( + isWithinServiceFeeActivationBoundaryWindow(SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 61) + ).toBe(false); + expect( + checkoutFeeDecisionDisagreesWithSessionCreated({ + preparedOutcome: 'pending', + sessionCreatedUnixSeconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1, + }) + ).toBe(true); + expect( + checkoutFeeDecisionDisagreesWithSessionCreated({ + preparedOutcome: 'pre_activation', + sessionCreatedUnixSeconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + }) + ).toBe(true); + expect( + checkoutFeeDecisionDisagreesWithSessionCreated({ + preparedOutcome: 'pending', + sessionCreatedUnixSeconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + }) + ).toBe(false); + }); + + test('fixed usd price requires unit_amount', async () => { + await expect( + resolveFixedUsdPriceUnitAmount({ + stripe: { + prices: { + retrieve: async () => ({ + id: 'price_1', + currency: 'usd', + unit_amount: 10_000, + tax_behavior: 'unspecified', + }), + }, + }, + priceId: 'price_1', + }) + ).resolves.toBe(10_000); + + await expect( + resolveFixedUsdPriceUnitAmount({ + stripe: { + prices: { + retrieve: async () => ({ + id: 'price_2', + currency: 'eur', + unit_amount: 10_000, + tax_behavior: 'unspecified', + }), + }, + }, + priceId: 'price_2', + }) + ).rejects.toThrow(/must be usd/); + + await expect( + resolveFixedUsdPriceUnitAmount({ + stripe: { + prices: { + retrieve: async () => ({ + id: 'price_3', + currency: 'usd', + unit_amount: null, + tax_behavior: 'unspecified', + }), + }, + }, + priceId: 'price_3', + }) + ).rejects.toThrow(/fixed usd unit_amount/); + }); + + test('positive fee line carries exact product_data metadata', () => { + const line = buildTopUpServiceFeeCheckoutLineItem({ + assessmentKey: 'checkout:abc', + feeMinor: 500, + taxInput: approvedTax(), + }); + expect(line.price_data?.product_data?.name).toBe(SERVICE_FEE_DESCRIPTION); + expect(line.price_data?.unit_amount).toBe(500); + expect(line.price_data?.product_data?.metadata).toEqual({ + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: 'checkout:abc', + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }); + expect(line.price_data?.recurring).toBeUndefined(); + }); +}); + +describe('prepareTopUpCheckoutFee', () => { + test('tax resolution failure fails open with missed and no line', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store), + }); + + expect(prepared.checkoutLineItem).toBeUndefined(); + expect(prepared.outcome).toBe('missed'); + expect(prepared.failureCode).toBe(SERVICE_FEE_FAILURE_APPLICATION); + expect(prepared.expectedFeeMinor).toBe(500); + expect(prepared.commercialMetadata).toMatchObject({ + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '10000', + serviceFeeFlow: 'personal_top_up', + }); + }); + + test('repeated decision failure falls back to a terminal missed assessment', async () => { + const store = createMemoryAssessmentStore(); + const findEffectiveExemption = jest.fn(async () => { + throw new Error('exemption lookup unavailable'); + }); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'organization_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + organizationId: 'org_1', + deps: deps(store, { findEffectiveExemption }), + }); + + expect(prepared).toMatchObject({ + outcome: 'missed', + expectedFeeMinor: 500, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + }); + + await createTopUpCheckoutSession({ + prepared, + buildSessionParams: feeLine => ({ + mode: 'payment', + line_items: feeLine ? [{ quantity: 1 }, feeLine] : [{ quantity: 1 }], + }), + createSession: async () => ({ + id: 'cs_double_failure', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + }), + deps: deps(store), + }); + + expect(await store.findByAssessmentKey(prepared.assessmentKey)).toMatchObject({ + outcome: 'missed', + expectedFeeMinor: 500, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + }); + expect(findEffectiveExemption).toHaveBeenCalledTimes(2); + }); + + test('injected approved tax builds a positive fee line', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { + resolveTaxInput: async () => approvedTax(), + }), + }); + + expect(prepared.outcome).toBe('pending'); + expect(prepared.checkoutLineItem?.price_data?.unit_amount).toBe(500); + expect(prepared.checkoutLineItem?.price_data?.product_data?.name).toBe(SERVICE_FEE_DESCRIPTION); + }); + + test('exact organization exemption omits the fee line', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'organization_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + organizationId: 'org_1', + deps: deps(store, { + resolveTaxInput: async () => approvedTax(), + findEffectiveExemption: async () => ({ id: 'hist_1', isExempt: true }), + }), + }); + + expect(prepared.outcome).toBe('exempt'); + expect(prepared.checkoutLineItem).toBeUndefined(); + expect(prepared.decision.exemptionId).toBe('hist_1'); + expect(prepared.expectedFeeMinor).toBe(500); + }); + + test('pre-activation omits the fee line', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_auto_top_up_setup', + principalMinor: 5_000, + kiloUserId: 'user_1', + deps: deps(store, { + now: BEFORE_ACTIVATION, + resolveTaxInput: async () => approvedTax(), + }), + }); + expect(prepared.outcome).toBe('pre_activation'); + expect(prepared.checkoutLineItem).toBeUndefined(); + expect(prepared.expectedFeeMinor).toBe(250); + }); +}); + +describe('createTopUpCheckoutSession', () => { + test('creates principal-only checkout after fee preparation failure and persists session', async () => { + const store = createMemoryAssessmentStore(); + const createSession = jest.fn( + async (_params: Stripe.Checkout.SessionCreateParams): Promise => ({ + id: 'cs_missed', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/missed', + line_items: { data: [], has_more: false }, + }) + ); + const sendAlert = jest.fn(async () => undefined); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { sendAlert }), + }); + + const session = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: feeLine => ({ + mode: 'payment', + line_items: feeLine ? [{ quantity: 1 }, feeLine] : [{ quantity: 1 }], + }), + createSession, + deps: deps(store, { sendAlert }), + }); + + expect(session.id).toBe('cs_missed'); + expect(createSession).toHaveBeenCalledTimes(1); + const createParams = createSession.mock.calls[0]?.[0]; + expect(createParams?.line_items).toHaveLength(1); + const record = await store.findByAssessmentKey(prepared.assessmentKey); + expect(record).toMatchObject({ + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + stripeCheckoutSessionId: 'cs_missed', + chargedFeeMinor: 0, + eligibilityCreatedAt: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000).toISOString(), + }); + expect(sendAlert).toHaveBeenCalled(); + }); + + test('persists positive fee line identity after session create', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'organization_auto_top_up_setup', + principalMinor: 50_000, + kiloUserId: 'user_1', + organizationId: 'org_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + const feeLine = { + id: 'li_fee', + price: { + id: 'price_fee', + product: { + id: 'prod_fee', + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem; + + const session = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: line => ({ + mode: 'payment', + line_items: [{ quantity: 1 }, ...(line ? [line] : [])], + }), + createSession: async () => ({ + id: 'cs_fee', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/fee', + line_items: { data: [feeLine], has_more: false }, + }), + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + expect(session.id).toBe('cs_fee'); + const record = await store.findByAssessmentKey(prepared.assessmentKey); + expect(record).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 0, + stripeCheckoutSessionId: 'cs_fee', + stripeCheckoutFeeLineItemId: 'li_fee', + stripeFeePriceId: 'price_fee', + }); + }); + + test('near-boundary disagreement expires and replaces once', async () => { + const store = createMemoryAssessmentStore(); + const expire = jest.fn(async (_sessionId: string) => undefined); + const createSession = jest + .fn<(params: Stripe.Checkout.SessionCreateParams) => Promise>() + .mockResolvedValueOnce({ + id: 'cs_early', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/early', + }) + .mockResolvedValueOnce({ + id: 'cs_replaced', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/replaced', + line_items: { data: [], has_more: false }, + }); + + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { + now: NEAR_BEFORE, + resolveTaxInput: async () => approvedTax(), + }), + }); + expect(prepared.outcome).toBe('pre_activation'); + + const session = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: line => ({ + mode: 'payment', + line_items: line ? [{ quantity: 1 }, line] : [{ quantity: 1 }], + }), + createSession, + deps: deps(store, { + now: NEAR_BEFORE, + resolveTaxInput: async () => approvedTax(), + expireCheckoutSession: expire, + listCheckoutLineItems: async () => ({ + data: [ + { + id: 'li_fee_replaced', + price: { + id: 'price_fee_replaced', + product: { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem, + ], + has_more: false, + }), + }), + }); + + expect(expire).toHaveBeenCalledWith('cs_early'); + expect(createSession).toHaveBeenCalledTimes(2); + expect(session.id).toBe('cs_replaced'); + const secondParams = createSession.mock.calls[1]?.[0]; + expect(secondParams?.line_items).toHaveLength(2); + }); + + test('outside the one-minute window does not expire or replace', async () => { + const store = createMemoryAssessmentStore(); + const expire = jest.fn(async () => undefined); + const createSession = jest.fn(async () => ({ + id: 'cs_far', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 90, + url: 'https://checkout.stripe.com/far', + line_items: { data: [], has_more: false }, + })); + + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { + now: FAR_AFTER, + resolveTaxInput: async () => approvedTax(), + }), + }); + + const session = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: line => ({ + mode: 'payment', + line_items: line ? [{ quantity: 1 }, line] : [{ quantity: 1 }], + }), + createSession, + deps: deps(store, { + now: FAR_AFTER, + expireCheckoutSession: expire, + resolveTaxInput: async () => approvedTax(), + }), + }); + + expect(session.id).toBe('cs_far'); + expect(createSession).toHaveBeenCalledTimes(1); + expect(expire).not.toHaveBeenCalled(); + }); + + test('replacement that still disagrees fails open to a principal-only session', async () => { + const store = createMemoryAssessmentStore(); + const createSession = jest + .fn<(params: Stripe.Checkout.SessionCreateParams) => Promise>() + .mockResolvedValueOnce({ + id: 'cs_1', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1, + url: 'https://checkout.stripe.com/1', + }) + .mockResolvedValueOnce({ + id: 'cs_2', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/2', + }) + .mockResolvedValueOnce({ + id: 'cs_3', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/3', + line_items: { data: [], has_more: false }, + }); + + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { + now: NEAR_AFTER, + resolveTaxInput: async () => approvedTax(), + }), + }); + + const session = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: line => ({ + mode: 'payment', + line_items: line ? [{ quantity: 1 }, line] : [{ quantity: 1 }], + }), + createSession, + deps: deps(store, { + now: NEAR_AFTER, + resolveTaxInput: async () => approvedTax(), + expireCheckoutSession: async () => undefined, + }), + }); + + expect(session.id).toBe('cs_3'); + expect(createSession).toHaveBeenCalledTimes(3); + const lastParams = createSession.mock.calls[2]?.[0]; + expect(lastParams?.line_items).toHaveLength(1); + const record = await store.findByAssessmentKey(prepared.assessmentKey); + expect(record?.outcome).toBe('missed'); + expect(record?.failureCode).toBe(SERVICE_FEE_FAILURE_ACTIVATION_BOUNDARY); + }); + + test('fee-domain errors do not wrap the base Stripe create', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + await expect( + createTopUpCheckoutSession({ + prepared, + buildSessionParams: () => ({ mode: 'payment', line_items: [] }), + createSession: async () => { + throw new Error('stripe_create_failed'); + }, + deps: deps(store), + }) + ).rejects.toThrow('stripe_create_failed'); + }); +}); + +describe('auto-top-up invoice fee attachment', () => { + test('tax resolution failure persists missed and does not create a fee item', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert = jest.fn(async () => undefined); + const prepared = await prepareAutoTopUpInvoiceFee({ + flow: 'personal_auto_top_up', + invoiceId: 'in_1', + principalMinor: 5_000, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + deps: deps(store, { sendAlert }), + }); + + expect(prepared.feeInvoiceItem).toBeUndefined(); + expect(prepared.outcome).toBe('missed'); + const record = await store.findByAssessmentKey(prepared.assessmentKey); + expect(record).toMatchObject({ + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + stripeInvoiceId: 'in_1', + }); + expect(sendAlert).toHaveBeenCalled(); + }); + + test('approved tax attaches one non-discountable fee item before pay', async () => { + const store = createMemoryAssessmentStore(); + const createInvoiceItem = jest.fn(async () => ({ id: 'ii_fee' })); + const prepared = await prepareAutoTopUpInvoiceFee({ + flow: 'organization_auto_top_up', + invoiceId: 'in_org', + principalMinor: 50_000, + organizationId: 'org_1', + kiloUserId: 'user_1', + stripeCustomerId: 'cus_org', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + expect(prepared.feeInvoiceItem).toMatchObject({ + invoice: 'in_org', + amount: 2_500, + discountable: false, + description: SERVICE_FEE_DESCRIPTION, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeAssessmentKey: prepared.assessmentKey, + }, + }); + + const charged = await attachPreparedAutoTopUpInvoiceFee({ + prepared, + deps: deps(store, { createInvoiceItem }), + }); + expect(createInvoiceItem).toHaveBeenCalledTimes(1); + expect(charged).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 2_500, + stripeInvoiceFeeLineItemId: 'ii_fee', + }); + }); +}); + +describe('trusted principal settlement', () => { + test('credits principal from trusted metadata and settles before returning email amounts', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + await createTopUpCheckoutSession({ + prepared, + buildSessionParams: line => ({ + mode: 'payment', + line_items: line ? [{ quantity: 1 }, line] : [{ quantity: 1 }], + }), + createSession: async () => ({ + id: 'cs_settle', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/settle', + line_items: { + data: [ + { + id: 'li_fee_settle', + price: { + id: 'price_fee_settle', + product: { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem, + ], + has_more: false, + }, + }), + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + const result = await settleTrustedTopUpCharge({ + charge: { + id: 'ch_1', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_1', + metadata: { + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '10000', + type: 'stripe-checkout-topup', + }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store), + }); + + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 10_000, + chargedFeeMinor: 500, + grossPaidMinor: 10_500, + }); + const record = await store.findByAssessmentKey(prepared.assessmentKey); + expect(record?.settledAt).toBeTruthy(); + expect(record?.settledProductMinor).toBe(10_000); + expect(record?.stripeChargeId).toBe('ch_1'); + }); + + test('duplicate settlement is idempotent', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + await createTopUpCheckoutSession({ + prepared, + buildSessionParams: () => ({ mode: 'payment', line_items: [] }), + createSession: async () => ({ + id: 'cs_dup', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/dup', + line_items: { + data: [ + { + id: 'li_dup', + price: { + id: 'price_dup', + product: { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem, + ], + has_more: false, + }, + }), + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + const charge = { + id: 'ch_dup', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }; + const paymentIntent = { + id: 'pi_dup', + metadata: { + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '10000', + }, + customer: 'cus_1', + }; + const first = await settleTrustedTopUpCharge({ + charge, + paymentIntent, + kiloUserId: 'user_1', + deps: deps(store), + }); + const second = await settleTrustedTopUpCharge({ + charge, + paymentIntent, + kiloUserId: 'user_1', + deps: deps(store), + }); + expect(second.principalMinor).toBe(first.principalMinor); + expect(second.assessment?.settledAt).toBe(first.assessment?.settledAt); + }); + + test('legacy pre-activation without fee metadata uses charge.amount', async () => { + const store = createMemoryAssessmentStore(); + const result = await settleTrustedTopUpCharge({ + charge: { + id: 'ch_legacy', + amount: 2300, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 10, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_legacy', + metadata: { type: 'stripe-checkout-topup' }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store, { + retrieveCheckoutSessionCreated: async () => SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 5, + }), + }); + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 2300, + chargedFeeMinor: 0, + }); + }); + + test('post-activation metadata-free events do not grant gross charge.amount', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const result = await settleTrustedTopUpCharge({ + charge: { + id: 'ch_bad', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_bad', + metadata: { type: 'stripe-checkout-topup' }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store, { + sendAlert, + retrieveCheckoutSessionCreated: async () => SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + }), + }); + expect(result.shouldCredit).toBe(false); + expect(result.principalMinor).toBe(0); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: 'principal_untrusted' }) + ); + }); + + test('auto invoice settlement uses principal metadata not amount_paid', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareAutoTopUpInvoiceFee({ + flow: 'personal_auto_top_up', + invoiceId: 'in_paid', + principalMinor: 5_000, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + await attachPreparedAutoTopUpInvoiceFee({ + prepared, + deps: deps(store, { createInvoiceItem: async () => ({ id: 'ii_paid' }) }), + }); + + const result = await settleTrustedAutoTopUpInvoice({ + invoice: { + id: 'in_paid', + amount_paid: 5_250, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { + type: 'auto-topup', + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '5000', + }, + status_transitions: { + finalized_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + marked_uncollectible_at: null, + paid_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + voided_at: null, + }, + customer: 'cus_1', + }, + chargeId: 'ch_paid', + kiloUserId: 'user_1', + flow: 'personal_auto_top_up', + deps: deps(store), + }); + + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 5_000, + chargedFeeMinor: 250, + grossPaidMinor: 5_250, + }); + }); + + test('pending settlement without a trusted fee line marks missed and still credits principal', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + await upsertServiceFeeAssessment({ + store, + decision: prepared.decision, + stripeIds: { stripeCustomerId: 'cus_1' }, + }); + + const result = await settleTrustedTopUpCharge({ + charge: { + id: 'ch_pending_missed', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_pending_missed', + metadata: { + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '10000', + }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store, { sendAlert }), + }); + + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 10_000, + chargedFeeMinor: 0, + grossPaidMinor: 10_500, + }); + expect(result.assessment).toMatchObject({ + outcome: 'missed', + chargedFeeMinor: 0, + failureCode: 'fee_application_failed', + settledAt: expect.any(String), + settledProductMinor: 10_000, + }); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: 'fee_application_failed' }) + ); + }); + + test('pending settlement with a trusted fee line identity books the observed fee', async () => { + const store = createMemoryAssessmentStore(); + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey: 'checkout:trusted-pending', + flow: 'personal_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + }); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeCheckoutFeeLineItemId: 'li_fee_trusted', + stripeFeePriceId: 'price_fee_trusted', + }, + }); + + const result = await settleTrustedTopUpCharge({ + charge: { + id: 'ch_pending_trusted', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_pending_trusted', + metadata: { + serviceFeeAssessmentKey: 'checkout:trusted-pending', + serviceFeePrincipalMinor: '10000', + }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store), + }); + + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 10_000, + chargedFeeMinor: 500, + grossPaidMinor: 10_500, + }); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 500, + settledProductMinor: 10_000, + }); + }); + + test('pending auto-top-up settlement does not book the expected fee', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareAutoTopUpInvoiceFee({ + flow: 'personal_auto_top_up', + invoiceId: 'in_pending', + principalMinor: 5_000, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + expect(prepared.outcome).toBe('pending'); + + const result = await settleTrustedAutoTopUpInvoice({ + invoice: { + id: 'in_pending', + amount_paid: 5_250, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { + type: 'auto-topup', + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '5000', + }, + status_transitions: { + finalized_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + marked_uncollectible_at: null, + paid_at: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + voided_at: null, + }, + customer: 'cus_1', + }, + chargeId: 'ch_pending_auto', + kiloUserId: 'user_1', + flow: 'personal_auto_top_up', + deps: deps(store), + }); + + expect(result).toMatchObject({ + shouldCredit: true, + principalMinor: 5_000, + chargedFeeMinor: 0, + grossPaidMinor: 5_250, + }); + expect(result.assessment).toMatchObject({ + outcome: 'missed', + failureCode: 'fee_application_failed', + chargedFeeMinor: 0, + }); + }); + + test('conflicting principal throws rather than granting the wrong credits', async () => { + const store = createMemoryAssessmentStore(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_top_up', + principalMinor: 10_000, + kiloUserId: 'user_1', + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + await createTopUpCheckoutSession({ + prepared, + buildSessionParams: () => ({ mode: 'payment', line_items: [] }), + createSession: async () => ({ + id: 'cs_conflict', + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + url: 'https://checkout.stripe.com/conflict', + line_items: { + data: [ + { + id: 'li_conflict', + price: { + id: 'price_conflict', + product: { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + }, + }, + }, + } as unknown as Stripe.LineItem, + ], + has_more: false, + }, + }), + deps: deps(store, { resolveTaxInput: async () => approvedTax() }), + }); + + await expect( + settleTrustedTopUpCharge({ + charge: { + id: 'ch_c', + amount: 10_500, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + customer: 'cus_1', + }, + paymentIntent: { + id: 'pi_c', + metadata: { + serviceFeeAssessmentKey: prepared.assessmentKey, + serviceFeePrincipalMinor: '9999', + }, + customer: 'cus_1', + }, + kiloUserId: 'user_1', + deps: deps(store), + }) + ).rejects.toThrow(/principal mismatch/); + expect(ServiceFeeAssessmentConflictError).toBeDefined(); + }); +}); + +describe('mergeServiceFeeCommercialMetadata', () => { + test('preserves existing metadata', () => { + expect( + mergeServiceFeeCommercialMetadata( + { type: 'stripe-checkout-topup', kiloUserId: 'user_1' }, + { + serviceFeeAssessmentKey: 'checkout:1', + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeFlow: 'personal_top_up', + serviceFeePrincipalMinor: '10000', + } + ) + ).toEqual({ + type: 'stripe-checkout-topup', + kiloUserId: 'user_1', + serviceFeeAssessmentKey: 'checkout:1', + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeFlow: 'personal_top_up', + serviceFeePrincipalMinor: '10000', + }); + }); +}); diff --git a/apps/web/src/lib/service-fees/checkout.ts b/apps/web/src/lib/service-fees/checkout.ts new file mode 100644 index 0000000000..f40d22cf0a --- /dev/null +++ b/apps/web/src/lib/service-fees/checkout.ts @@ -0,0 +1,1384 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; + +import type Stripe from 'stripe'; + +import { + sendMissedServiceFeeAlert, + type MissedServiceFeeAlertInput, +} from '@/lib/service-fees/alerts'; +import { + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + prepareServiceFeeAssessmentDecision, + settleServiceFeeAssessment, + toServiceFeeTimestamp, + upsertServiceFeeAssessment, + type EffectiveExemptionLookup, + type PreparedServiceFeeDecision, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, + type ServiceFeeStripeIds, +} from '@/lib/service-fees/assessments'; +import { calculateServiceFeeMinor } from '@/lib/service-fees/calculation'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_DESCRIPTION, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + buildServiceFeeCommercialMetadata, + buildServiceFeeLineMetadata, + isServiceFeeCheckoutLine, +} from '@/lib/service-fees/stripe-lines'; +import { + resolveServiceFeeTaxInput, + type ServiceFeeTaxInput, + type ServiceFeeTaxPrincipal, + type StripePriceTaxReader, +} from '@/lib/service-fees/tax'; +import { + SERVICE_FEE_SUPPORTED_CURRENCY, + type ServiceFeeCommercialMetadata, + type ServiceFeeFlow, + type ServiceFeeLineMetadata, + type ServiceFeeOutcome, +} from '@/lib/service-fees/types'; + +export const SERVICE_FEE_ACTIVATION_BOUNDARY_WINDOW_SECONDS = 60; +export const KILO_OWNED_AUTO_TOP_UP_INVOICE_TYPES = ['auto-topup', 'org-auto-topup'] as const; + +export const SERVICE_FEE_FAILURE_APPLICATION = 'fee_application_failed' as const; +export const SERVICE_FEE_FAILURE_ACTIVATION_BOUNDARY = + 'activation_boundary_replace_failed' as const; +export const SERVICE_FEE_FAILURE_MISSING_ASSESSMENT = 'missing_assessment' as const; +export const SERVICE_FEE_FAILURE_PRINCIPAL_UNTRUSTED = 'principal_untrusted' as const; + +export type KiloOwnedAutoTopUpInvoiceType = (typeof KILO_OWNED_AUTO_TOP_UP_INVOICE_TYPES)[number]; + +export type CheckoutServiceFeeFlow = + | 'personal_top_up' + | 'organization_top_up' + | 'personal_auto_top_up_setup' + | 'organization_auto_top_up_setup' + | 'personal_kilo_pass'; + +export type AutoTopUpInvoiceFlow = 'personal_auto_top_up' | 'organization_auto_top_up'; + +export type CheckoutSessionLike = { + id: string; + created: number; + url?: string | null; + line_items?: Pick, 'data' | 'has_more'> | null; +}; + +export type CheckoutSessionCreateFn = ( + params: Stripe.Checkout.SessionCreateParams +) => Promise; + +export type CheckoutLineItemListFn = ( + sessionId: string, + params?: Stripe.Checkout.SessionListLineItemsParams +) => Promise, 'data' | 'has_more'>>; + +export type InvoiceItemCreateFn = ( + params: Stripe.InvoiceItemCreateParams +) => Promise>; + +export type TopUpPriceReader = { + prices: { + retrieve( + id: string, + params?: Stripe.PriceRetrieveParams + ): Promise>; + }; +}; + +export type ServiceFeeCheckoutDependencies = { + store: ServiceFeeAssessmentStore; + now?: Date; + createAssessmentKey?: () => string; + findEffectiveExemption?: EffectiveExemptionLookup; + resolveTaxInput?: (params: { + principal: ServiceFeeTaxPrincipal; + stripe?: StripePriceTaxReader; + }) => Promise; + sendAlert?: (input: MissedServiceFeeAlertInput) => Promise; + stripe?: StripePriceTaxReader & Partial; + listCheckoutLineItems?: CheckoutLineItemListFn; + expireCheckoutSession?: (sessionId: string) => Promise; + createInvoiceItem?: InvoiceItemCreateFn; + retrieveCheckoutSessionCreated?: (paymentIntentId: string) => Promise; +}; + +export type PreparedTopUpCheckoutFee = { + assessmentKey: string; + flow: CheckoutServiceFeeFlow; + principalMinor: number; + decision: PreparedServiceFeeDecision; + outcome: ServiceFeeOutcome; + expectedFeeMinor: number; + checkoutLineItem?: Stripe.Checkout.SessionCreateParams.LineItem; + commercialMetadata: ServiceFeeCommercialMetadata; + failureCode?: string; +}; + +export type PreparedAutoTopUpInvoiceFee = { + assessmentKey: string; + flow: AutoTopUpInvoiceFlow; + principalMinor: number; + invoiceId: string; + decision: PreparedServiceFeeDecision; + outcome: ServiceFeeOutcome; + expectedFeeMinor: number; + feeInvoiceItem?: Stripe.InvoiceItemCreateParams; + commercialMetadata: ServiceFeeCommercialMetadata; + failureCode?: string; +}; + +export type TopUpSettlementResult = { + principalMinor: number; + chargedFeeMinor: number; + grossPaidMinor: number; + assessment: ServiceFeeAssessmentRecord | null; + shouldCredit: boolean; +}; + +type FeeLineIdentity = { + stripeCheckoutFeeLineItemId?: string; + stripeFeePriceId?: string; +}; + +export function createCheckoutServiceFeeAssessmentKey(id: string = randomUUID()): string { + return `checkout:${id}`; +} + +export function createInvoiceServiceFeeAssessmentKey(invoiceId: string): string { + return `invoice:${invoiceId}`; +} + +export function isKiloOwnedAutoTopUpInvoice( + invoice: Pick | { metadata?: Stripe.Metadata | null } +): boolean { + const type = invoice.metadata?.type; + return type === 'auto-topup' || type === 'org-auto-topup'; +} + +export function isWithinServiceFeeActivationBoundaryWindow(unixSeconds: number): boolean { + return ( + Math.abs(unixSeconds - SERVICE_FEE_ACTIVATION_UNIX_SECONDS) <= + SERVICE_FEE_ACTIVATION_BOUNDARY_WINDOW_SECONDS + ); +} + +export function checkoutFeeDecisionDisagreesWithSessionCreated(params: { + preparedOutcome: ServiceFeeOutcome; + sessionCreatedUnixSeconds: number; +}): boolean { + const sessionEligible = params.sessionCreatedUnixSeconds >= SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + if (params.preparedOutcome === 'pre_activation') { + return sessionEligible; + } + if (params.preparedOutcome === 'pending') { + return !sessionEligible; + } + return false; +} + +export async function resolveFixedUsdPriceUnitAmount(params: { + stripe: TopUpPriceReader; + priceId: string; +}): Promise { + const price = await params.stripe.prices.retrieve(params.priceId); + if (price.currency !== SERVICE_FEE_SUPPORTED_CURRENCY) { + throw new Error(`top-up price ${params.priceId} must be usd, received ${price.currency}`); + } + if (typeof price.unit_amount !== 'number' || !Number.isSafeInteger(price.unit_amount)) { + throw new Error(`top-up price ${params.priceId} must have a fixed usd unit_amount`); + } + if (price.unit_amount <= 0) { + throw new Error(`top-up price ${params.priceId} unit_amount must be positive`); + } + return price.unit_amount; +} + +export function buildTopUpServiceFeeCheckoutLineItem(params: { + assessmentKey: string; + feeMinor: number; + taxInput: ServiceFeeTaxInput; +}): Stripe.Checkout.SessionCreateParams.LineItem { + if (params.feeMinor <= 0) { + throw new Error('positive service fee is required to build a checkout fee line'); + } + + const metadata = buildServiceFeeLineMetadata(params.assessmentKey); + return { + quantity: 1, + price_data: { + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + unit_amount: params.feeMinor, + product_data: { + name: SERVICE_FEE_DESCRIPTION, + metadata, + }, + ...(params.taxInput.taxBehavior ? { tax_behavior: params.taxInput.taxBehavior } : {}), + }, + }; +} + +export function buildAutoTopUpServiceFeeInvoiceItem(params: { + assessmentKey: string; + invoiceId: string; + customerId: string; + feeMinor: number; + taxInput: ServiceFeeTaxInput; +}): Stripe.InvoiceItemCreateParams { + if (params.feeMinor <= 0) { + throw new Error('positive service fee is required to build an invoice fee item'); + } + + return { + customer: params.customerId, + invoice: params.invoiceId, + amount: params.feeMinor, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + description: SERVICE_FEE_DESCRIPTION, + discountable: false, + metadata: buildServiceFeeLineMetadata(params.assessmentKey), + ...(params.taxInput.taxBehavior ? { tax_behavior: params.taxInput.taxBehavior } : {}), + }; +} + +export function mergeServiceFeeCommercialMetadata( + existing: Stripe.MetadataParam | null | undefined, + commercial: ServiceFeeCommercialMetadata +): Stripe.MetadataParam { + return { + ...(existing ?? {}), + ...commercial, + }; +} + +export async function prepareTopUpCheckoutFee(params: { + flow: CheckoutServiceFeeFlow; + principalMinor: number; + kiloUserId: string; + organizationId?: string; + stripeCustomerId?: string; + taxPrincipal?: ServiceFeeTaxPrincipal; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const now = params.deps.now ?? new Date(); + const assessmentKey = ( + params.deps.createAssessmentKey ?? createCheckoutServiceFeeAssessmentKey + )(); + const commercialMetadata = buildServiceFeeCommercialMetadata({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + organizationId: params.organizationId, + }); + + try { + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey, + flow: params.flow, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt: now, + eligibleSubtotalMinor: params.principalMinor, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCustomerId: params.stripeCustomerId, + }, + { findEffectiveExemption: params.deps.findEffectiveExemption } + ); + + if (decision.outcome !== 'pending') { + return { + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + decision, + outcome: decision.outcome, + expectedFeeMinor: decision.expectedFeeMinor, + commercialMetadata, + }; + } + + const resolveTax = params.deps.resolveTaxInput ?? resolveServiceFeeTaxInput; + const taxInput = await resolveTax({ + principal: params.taxPrincipal ?? { kind: 'inline' }, + stripe: params.deps.stripe, + }); + + return { + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + decision, + outcome: 'pending', + expectedFeeMinor: decision.expectedFeeMinor, + checkoutLineItem: buildTopUpServiceFeeCheckoutLineItem({ + assessmentKey, + feeMinor: decision.expectedFeeMinor, + taxInput, + }), + commercialMetadata, + }; + } catch (error) { + const failureCode = failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION); + const fallbackDecision = await safePrepareDecision({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCustomerId: params.stripeCustomerId, + now, + findEffectiveExemption: params.deps.findEffectiveExemption, + }); + return missedCheckoutPreparation({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + decision: fallbackDecision, + commercialMetadata, + failureCode, + }); + } +} + +export async function createTopUpCheckoutSession(params: { + prepared: PreparedTopUpCheckoutFee; + buildSessionParams: ( + feeLineItem?: Stripe.Checkout.SessionCreateParams.LineItem + ) => Stripe.Checkout.SessionCreateParams; + createSession: CheckoutSessionCreateFn; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const firstParams = params.buildSessionParams(params.prepared.checkoutLineItem); + const firstSession = await params.createSession(firstParams); + return finalizeTopUpCheckoutSession({ + prepared: params.prepared, + session: firstSession, + attemptUnixSeconds: Math.floor((params.deps.now ?? new Date()).getTime() / 1000), + buildSessionParams: params.buildSessionParams, + createSession: params.createSession, + deps: params.deps, + }); +} + +export async function persistTopUpCheckoutSession(params: { + prepared: PreparedTopUpCheckoutFee; + session: CheckoutSessionLike; + deps: ServiceFeeCheckoutDependencies; + replacementFailureCode?: string; +}): Promise { + const identity = await resolveCheckoutFeeLineIdentity(params.session, params.deps); + return persistPreparedAssessment({ + prepared: params.prepared, + stripeIds: { + stripeCustomerId: params.prepared.decision.stripeCustomerId, + stripeCheckoutSessionId: params.session.id, + ...identity, + }, + eligibilityCreatedAt: new Date(params.session.created * 1000), + deps: params.deps, + replacementFailureCode: params.replacementFailureCode, + }); +} + +export async function prepareAutoTopUpInvoiceFee(params: { + flow: AutoTopUpInvoiceFlow; + invoiceId: string; + principalMinor: number; + kiloUserId?: string; + organizationId?: string; + stripeCustomerId: string; + invoiceCreated?: Date; + taxPrincipal?: ServiceFeeTaxPrincipal; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const now = params.invoiceCreated ?? params.deps.now ?? new Date(); + const assessmentKey = createInvoiceServiceFeeAssessmentKey(params.invoiceId); + const commercialMetadata = buildServiceFeeCommercialMetadata({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + organizationId: params.organizationId, + }); + + try { + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey, + flow: params.flow, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt: now, + eligibleSubtotalMinor: params.principalMinor, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCustomerId: params.stripeCustomerId, + }, + { findEffectiveExemption: params.deps.findEffectiveExemption } + ); + + if (decision.outcome !== 'pending') { + await upsertServiceFeeAssessment({ + store: params.deps.store, + decision, + stripeIds: { + stripeCustomerId: params.stripeCustomerId, + stripeInvoiceId: params.invoiceId, + }, + now, + }); + return { + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + invoiceId: params.invoiceId, + decision, + outcome: decision.outcome, + expectedFeeMinor: decision.expectedFeeMinor, + commercialMetadata, + }; + } + + const resolveTax = params.deps.resolveTaxInput ?? resolveServiceFeeTaxInput; + const taxInput = await resolveTax({ + principal: params.taxPrincipal ?? { kind: 'inline' }, + stripe: params.deps.stripe, + }); + + await upsertServiceFeeAssessment({ + store: params.deps.store, + decision, + stripeIds: { + stripeCustomerId: params.stripeCustomerId, + stripeInvoiceId: params.invoiceId, + }, + now, + }); + + return { + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + invoiceId: params.invoiceId, + decision, + outcome: 'pending', + expectedFeeMinor: decision.expectedFeeMinor, + feeInvoiceItem: buildAutoTopUpServiceFeeInvoiceItem({ + assessmentKey, + invoiceId: params.invoiceId, + customerId: params.stripeCustomerId, + feeMinor: decision.expectedFeeMinor, + taxInput, + }), + commercialMetadata, + }; + } catch (error) { + const failureCode = failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION); + const fallbackDecision = await safePrepareDecision({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCustomerId: params.stripeCustomerId, + now, + findEffectiveExemption: params.deps.findEffectiveExemption, + }); + return persistMissedInvoicePreparation({ + assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + invoiceId: params.invoiceId, + decision: fallbackDecision, + commercialMetadata, + stripeCustomerId: params.stripeCustomerId, + failureCode, + deps: params.deps, + now, + }); + } +} + +export async function attachPreparedAutoTopUpInvoiceFee(params: { + prepared: PreparedAutoTopUpInvoiceFee; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const now = params.deps.now ?? new Date(); + if (!params.prepared.feeInvoiceItem || params.prepared.outcome !== 'pending') { + return params.deps.store.findByAssessmentKey(params.prepared.assessmentKey); + } + if (!params.deps.createInvoiceItem) { + return persistMissedAfterPrepare({ + prepared: toCheckoutShaped(params.prepared), + stripeIds: { + stripeCustomerId: params.prepared.decision.stripeCustomerId, + stripeInvoiceId: params.prepared.invoiceId, + }, + deps: params.deps, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + }); + } + + try { + const item = await params.deps.createInvoiceItem(params.prepared.feeInvoiceItem); + return markServiceFeeAssessmentCharged({ + store: params.deps.store, + assessmentKey: params.prepared.assessmentKey, + chargedFeeMinor: params.prepared.expectedFeeMinor, + stripeIds: { + stripeCustomerId: params.prepared.decision.stripeCustomerId, + stripeInvoiceId: params.prepared.invoiceId, + stripeInvoiceFeeLineItemId: item.id, + }, + now, + }); + } catch (error) { + return persistMissedAfterPrepare({ + prepared: toCheckoutShaped(params.prepared), + stripeIds: { + stripeCustomerId: params.prepared.decision.stripeCustomerId, + stripeInvoiceId: params.prepared.invoiceId, + }, + deps: params.deps, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + }); + } +} + +export async function settleTrustedTopUpCharge(params: { + charge: Pick; + paymentIntent: Pick; + kiloUserId?: string; + organizationId?: string; + flowHint?: ServiceFeeFlow; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const now = params.deps.now ?? new Date(); + const metadata = params.paymentIntent.metadata ?? {}; + const assessmentKey = nonempty(metadata.serviceFeeAssessmentKey); + const metadataPrincipal = parseMinor(metadata.serviceFeePrincipalMinor); + const amountCentsPrincipal = parseMinor(metadata.amountCents); + const grossPaidMinor = params.charge.amount; + + if (assessmentKey) { + const assessment = await params.deps.store.findByAssessmentKey(assessmentKey); + if (!assessment) { + await alertMissed({ + assessmentKey, + flow: params.flowHint ?? inferFlowFromMetadata(metadata, params.organizationId), + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripePaymentIntentId: params.paymentIntent.id, + stripeChargeId: params.charge.id, + eligibleSubtotalMinor: metadataPrincipal ?? 0, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_MISSING_ASSESSMENT, + deps: params.deps, + }); + const principalMinor = metadataPrincipal ?? amountCentsPrincipal; + return { + principalMinor: principalMinor ?? 0, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: principalMinor != null, + }; + } + + assertAssessmentMatchesCharge({ + assessment, + paymentIntentId: params.paymentIntent.id, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + principalMinor: metadataPrincipal ?? assessment.eligibleSubtotalMinor, + }); + + const principalMinor = metadataPrincipal ?? assessment.eligibleSubtotalMinor; + const settled = await settleLoadedAssessment({ + assessment, + principalMinor, + chargedFeeMinor: observedTopUpChargedFeeMinor(assessment), + grossPaidMinor, + stripeIds: { + stripePaymentIntentId: params.paymentIntent.id, + stripeChargeId: params.charge.id, + stripeCustomerId: customerId(params.charge.customer) ?? assessment.stripeCustomerId, + }, + settledAt: unixToDate(params.charge.created) ?? now, + deps: params.deps, + }); + + return { + principalMinor, + chargedFeeMinor: settled.chargedFeeMinor, + grossPaidMinor: settled.grossPaidMinor, + assessment: settled, + shouldCredit: true, + }; + } + + let sessionCreated: number | null = null; + if (params.deps.retrieveCheckoutSessionCreated) { + try { + sessionCreated = await params.deps.retrieveCheckoutSessionCreated(params.paymentIntent.id); + } catch { + sessionCreated = null; + } + } + const createdUnix = sessionCreated ?? null; + const isLegacyPreActivation = + createdUnix != null && createdUnix < SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + + if (isLegacyPreActivation) { + return { + principalMinor: params.charge.amount, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: true, + }; + } + + const trustedPrincipal = metadataPrincipal ?? amountCentsPrincipal; + if (trustedPrincipal != null) { + if (createdUnix == null || createdUnix >= SERVICE_FEE_ACTIVATION_UNIX_SECONDS) { + await alertMissed({ + assessmentKey: `missing:${params.paymentIntent.id}`, + flow: params.flowHint ?? inferFlowFromMetadata(metadata, params.organizationId), + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripePaymentIntentId: params.paymentIntent.id, + stripeChargeId: params.charge.id, + eligibleSubtotalMinor: trustedPrincipal, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_MISSING_ASSESSMENT, + deps: params.deps, + }); + } + return { + principalMinor: trustedPrincipal, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: true, + }; + } + + await alertMissed({ + assessmentKey: `untrusted:${params.paymentIntent.id}`, + flow: params.flowHint ?? inferFlowFromMetadata(metadata, params.organizationId), + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripePaymentIntentId: params.paymentIntent.id, + stripeChargeId: params.charge.id, + eligibleSubtotalMinor: params.charge.amount, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_PRINCIPAL_UNTRUSTED, + deps: params.deps, + }); + + return { + principalMinor: 0, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: false, + }; +} + +export async function settleTrustedAutoTopUpInvoice(params: { + invoice: Pick< + Stripe.Invoice, + 'id' | 'amount_paid' | 'created' | 'metadata' | 'status_transitions' | 'customer' + >; + chargeId: string; + kiloUserId?: string; + organizationId?: string; + flow: AutoTopUpInvoiceFlow; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const now = params.deps.now ?? new Date(); + const metadata = params.invoice.metadata ?? {}; + const assessmentKey = + nonempty(metadata.serviceFeeAssessmentKey) ?? + (params.invoice.id ? createInvoiceServiceFeeAssessmentKey(params.invoice.id) : null); + const metadataPrincipal = parseMinor(metadata.serviceFeePrincipalMinor); + const grossPaidMinor = params.invoice.amount_paid; + const paidAt = + unixToDate(params.invoice.status_transitions?.paid_at) ?? + unixToDate(params.invoice.created) ?? + now; + + if (assessmentKey) { + const assessment = await params.deps.store.findByAssessmentKey(assessmentKey); + if (assessment) { + const principalMinor = metadataPrincipal ?? assessment.eligibleSubtotalMinor; + const settled = await settleLoadedAssessment({ + assessment, + principalMinor, + chargedFeeMinor: observedTopUpChargedFeeMinor(assessment), + grossPaidMinor, + stripeIds: { + stripeInvoiceId: params.invoice.id, + stripeChargeId: params.chargeId, + stripeCustomerId: customerId(params.invoice.customer) ?? assessment.stripeCustomerId, + }, + settledAt: paidAt, + deps: params.deps, + }); + return { + principalMinor, + chargedFeeMinor: settled.chargedFeeMinor, + grossPaidMinor: settled.grossPaidMinor, + assessment: settled, + shouldCredit: true, + }; + } + } + + const invoiceCreated = params.invoice.created; + if (invoiceCreated < SERVICE_FEE_ACTIVATION_UNIX_SECONDS) { + return { + principalMinor: params.invoice.amount_paid, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: true, + }; + } + + if (metadataPrincipal != null) { + await alertMissed({ + assessmentKey: assessmentKey ?? `missing:${params.invoice.id}`, + flow: params.flow, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeInvoiceId: params.invoice.id, + stripeChargeId: params.chargeId, + eligibleSubtotalMinor: metadataPrincipal, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_MISSING_ASSESSMENT, + deps: params.deps, + }); + return { + principalMinor: metadataPrincipal, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: true, + }; + } + + await alertMissed({ + assessmentKey: assessmentKey ?? `untrusted:${params.invoice.id}`, + flow: params.flow, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeInvoiceId: params.invoice.id, + stripeChargeId: params.chargeId, + eligibleSubtotalMinor: params.invoice.amount_paid, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_PRINCIPAL_UNTRUSTED, + deps: params.deps, + }); + + return { + principalMinor: 0, + chargedFeeMinor: 0, + grossPaidMinor, + assessment: null, + shouldCredit: false, + }; +} + +async function finalizeTopUpCheckoutSession(params: { + prepared: PreparedTopUpCheckoutFee; + session: CheckoutSessionLike; + attemptUnixSeconds: number; + buildSessionParams: ( + feeLineItem?: Stripe.Checkout.SessionCreateParams.LineItem + ) => Stripe.Checkout.SessionCreateParams; + createSession: CheckoutSessionCreateFn; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const nearBoundary = isWithinServiceFeeActivationBoundaryWindow(params.attemptUnixSeconds); + const disagrees = checkoutFeeDecisionDisagreesWithSessionCreated({ + preparedOutcome: params.prepared.outcome, + sessionCreatedUnixSeconds: params.session.created, + }); + + if (!nearBoundary || !disagrees) { + await persistTopUpCheckoutSession({ + prepared: params.prepared, + session: params.session, + deps: params.deps, + }); + return params.session; + } + + if (params.deps.expireCheckoutSession) { + try { + await params.deps.expireCheckoutSession(params.session.id); + } catch { + // Replacement still proceeds; the original session is abandoned. + } + } + + const replacementPrepared = await prepareReplacementForSessionCreated({ + original: params.prepared, + sessionCreatedUnixSeconds: params.session.created, + deps: params.deps, + }); + const replacementSession = await params.createSession( + params.buildSessionParams(replacementPrepared.checkoutLineItem) + ); + + const replacementDisagrees = checkoutFeeDecisionDisagreesWithSessionCreated({ + preparedOutcome: replacementPrepared.outcome, + sessionCreatedUnixSeconds: replacementSession.created, + }); + + if (replacementDisagrees) { + const failOpen = await prepareFailOpenReplacement({ + original: params.prepared, + deps: params.deps, + }); + if (params.deps.expireCheckoutSession) { + try { + await params.deps.expireCheckoutSession(replacementSession.id); + } catch { + // Continue with a principal-only session. + } + } + const failOpenSession = await params.createSession(params.buildSessionParams(undefined)); + await persistTopUpCheckoutSession({ + prepared: failOpen, + session: failOpenSession, + deps: params.deps, + replacementFailureCode: SERVICE_FEE_FAILURE_ACTIVATION_BOUNDARY, + }); + return failOpenSession; + } + + await persistTopUpCheckoutSession({ + prepared: replacementPrepared, + session: replacementSession, + deps: params.deps, + }); + return replacementSession; +} + +async function prepareReplacementForSessionCreated(params: { + original: PreparedTopUpCheckoutFee; + sessionCreatedUnixSeconds: number; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + return prepareTopUpCheckoutFee({ + flow: params.original.flow, + principalMinor: params.original.principalMinor, + kiloUserId: params.original.decision.kiloUserId ?? '', + organizationId: params.original.decision.organizationId ?? undefined, + stripeCustomerId: params.original.decision.stripeCustomerId ?? undefined, + deps: { + ...params.deps, + now: new Date(params.sessionCreatedUnixSeconds * 1000), + createAssessmentKey: () => params.original.assessmentKey, + }, + }); +} + +async function prepareFailOpenReplacement(params: { + original: PreparedTopUpCheckoutFee; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + return { + ...params.original, + outcome: 'missed', + checkoutLineItem: undefined, + failureCode: SERVICE_FEE_FAILURE_ACTIVATION_BOUNDARY, + }; +} + +async function persistPreparedAssessment(params: { + prepared: PreparedTopUpCheckoutFee; + stripeIds: ServiceFeeStripeIds; + eligibilityCreatedAt: Date; + deps: ServiceFeeCheckoutDependencies; + replacementFailureCode?: string; +}): Promise { + const now = params.deps.now ?? new Date(); + const failureCode = params.replacementFailureCode ?? params.prepared.failureCode; + + try { + const decision = { + ...params.prepared.decision, + eligibilityCreatedAt: toServiceFeeTimestamp(params.eligibilityCreatedAt), + }; + const record = await upsertServiceFeeAssessment({ + store: params.deps.store, + decision, + stripeIds: params.stripeIds, + now, + }); + + if (failureCode) { + const missed = await markServiceFeeAssessmentMissed({ + store: params.deps.store, + assessmentKey: params.prepared.assessmentKey, + failureCode, + stripeIds: params.stripeIds, + now, + }); + await alertForRecord(missed, params.deps); + return missed; + } + + if ( + params.prepared.outcome === 'pending' && + params.prepared.checkoutLineItem && + params.stripeIds.stripeCheckoutFeeLineItemId + ) { + return markServiceFeeAssessmentCharged({ + store: params.deps.store, + assessmentKey: params.prepared.assessmentKey, + chargedFeeMinor: 0, + stripeIds: params.stripeIds, + now, + }); + } + + return record; + } catch (error) { + await alertMissed({ + assessmentKey: params.prepared.assessmentKey, + flow: params.prepared.flow, + kiloUserId: params.prepared.decision.kiloUserId, + organizationId: params.prepared.decision.organizationId, + stripeCheckoutSessionId: params.stripeIds.stripeCheckoutSessionId, + eligibleSubtotalMinor: params.prepared.principalMinor, + expectedFeeMinor: params.prepared.expectedFeeMinor, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + deps: params.deps, + }); + return null; + } +} + +async function persistMissedAfterPrepare(params: { + prepared: PreparedTopUpCheckoutFee; + stripeIds: ServiceFeeStripeIds; + deps: ServiceFeeCheckoutDependencies; + failureCode: string; +}): Promise { + const now = params.deps.now ?? new Date(); + try { + await upsertServiceFeeAssessment({ + store: params.deps.store, + decision: params.prepared.decision, + stripeIds: params.stripeIds, + now, + }); + const missed = await markServiceFeeAssessmentMissed({ + store: params.deps.store, + assessmentKey: params.prepared.assessmentKey, + failureCode: params.failureCode, + stripeIds: params.stripeIds, + now, + }); + await alertForRecord(missed, params.deps); + return missed; + } catch (error) { + await alertMissed({ + assessmentKey: params.prepared.assessmentKey, + flow: params.prepared.decision.flow, + kiloUserId: params.prepared.decision.kiloUserId, + organizationId: params.prepared.decision.organizationId, + stripeInvoiceId: params.stripeIds.stripeInvoiceId, + eligibleSubtotalMinor: params.prepared.principalMinor, + expectedFeeMinor: params.prepared.expectedFeeMinor, + failureCode: failureCodeFromUnknown(error, params.failureCode), + deps: params.deps, + }); + return null; + } +} + +async function persistMissedInvoicePreparation(params: { + assessmentKey: string; + flow: AutoTopUpInvoiceFlow; + principalMinor: number; + invoiceId: string; + decision: PreparedServiceFeeDecision; + commercialMetadata: ServiceFeeCommercialMetadata; + stripeCustomerId: string; + failureCode: string; + deps: ServiceFeeCheckoutDependencies; + now: Date; +}): Promise { + try { + await upsertServiceFeeAssessment({ + store: params.deps.store, + decision: params.decision, + stripeIds: { + stripeCustomerId: params.stripeCustomerId, + stripeInvoiceId: params.invoiceId, + }, + now: params.now, + }); + if (params.decision.expectedFeeMinor > 0) { + const missed = await markServiceFeeAssessmentMissed({ + store: params.deps.store, + assessmentKey: params.assessmentKey, + failureCode: params.failureCode, + stripeIds: { + stripeCustomerId: params.stripeCustomerId, + stripeInvoiceId: params.invoiceId, + }, + now: params.now, + }); + await alertForRecord(missed, params.deps); + } + } catch (error) { + await alertMissed({ + assessmentKey: params.assessmentKey, + flow: params.flow, + kiloUserId: params.decision.kiloUserId, + organizationId: params.decision.organizationId, + stripeInvoiceId: params.invoiceId, + eligibleSubtotalMinor: params.principalMinor, + expectedFeeMinor: params.decision.expectedFeeMinor, + failureCode: failureCodeFromUnknown(error, params.failureCode), + deps: params.deps, + }); + } + + return { + assessmentKey: params.assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + invoiceId: params.invoiceId, + decision: params.decision, + outcome: params.decision.expectedFeeMinor > 0 ? 'missed' : params.decision.outcome, + expectedFeeMinor: params.decision.expectedFeeMinor, + commercialMetadata: params.commercialMetadata, + failureCode: params.failureCode, + }; +} + +function missedCheckoutPreparation(params: { + assessmentKey: string; + flow: CheckoutServiceFeeFlow; + principalMinor: number; + decision: PreparedServiceFeeDecision; + commercialMetadata: ServiceFeeCommercialMetadata; + failureCode: string; +}): PreparedTopUpCheckoutFee { + return { + assessmentKey: params.assessmentKey, + flow: params.flow, + principalMinor: params.principalMinor, + decision: params.decision, + outcome: params.decision.expectedFeeMinor > 0 ? 'missed' : params.decision.outcome, + expectedFeeMinor: params.decision.expectedFeeMinor, + commercialMetadata: params.commercialMetadata, + failureCode: params.decision.expectedFeeMinor > 0 ? params.failureCode : undefined, + }; +} + +async function safePrepareDecision(params: { + assessmentKey: string; + flow: ServiceFeeFlow; + principalMinor: number; + kiloUserId?: string; + organizationId?: string; + stripeCustomerId?: string; + now: Date; + findEffectiveExemption?: EffectiveExemptionLookup; +}): Promise { + try { + return await prepareServiceFeeAssessmentDecision( + { + assessmentKey: params.assessmentKey, + flow: params.flow, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt: params.now, + eligibleSubtotalMinor: params.principalMinor, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCustomerId: params.stripeCustomerId, + }, + { findEffectiveExemption: params.findEffectiveExemption } + ); + } catch { + const eligibleSubtotalMinor = + Number.isSafeInteger(params.principalMinor) && params.principalMinor >= 0 + ? params.principalMinor + : 0; + const expectedFeeMinor = calculateServiceFeeMinor(eligibleSubtotalMinor); + return { + assessmentKey: params.assessmentKey, + version: SERVICE_FEE_VERSION, + flow: params.flow, + outcome: expectedFeeMinor > 0 ? 'pending' : 'zero_rounded', + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + kiloUserId: params.kiloUserId ?? null, + organizationId: params.organizationId ?? null, + stripeCustomerId: params.stripeCustomerId ?? null, + eligibilityCreatedAt: params.now.toISOString(), + eligibleSubtotalMinor, + expectedFeeMinor, + chargedFeeMinor: 0, + exemptionId: null, + failureCode: null, + metadata: {}, + }; + } +} + +async function resolveCheckoutFeeLineIdentity( + session: CheckoutSessionLike, + deps: ServiceFeeCheckoutDependencies +): Promise { + const fromEmbedded = identifyFeeLine(session.line_items?.data ?? []); + if (fromEmbedded.stripeCheckoutFeeLineItemId) { + return fromEmbedded; + } + if (!deps.listCheckoutLineItems) { + return {}; + } + try { + const page = await deps.listCheckoutLineItems(session.id, { + limit: 100, + expand: ['data.price.product'], + }); + return identifyFeeLine(page.data); + } catch { + return {}; + } +} + +function identifyFeeLine(lines: readonly Stripe.LineItem[]): FeeLineIdentity { + const feeLine = lines.find(line => isServiceFeeCheckoutLine(line)); + if (!feeLine) return {}; + const price = feeLine.price; + return { + stripeCheckoutFeeLineItemId: feeLine.id, + stripeFeePriceId: typeof price === 'string' ? price : (price?.id ?? undefined), + }; +} + +function hasTrustedTopUpFeeLineIdentity(assessment: ServiceFeeAssessmentRecord): boolean { + return Boolean( + assessment.stripeCheckoutFeeLineItemId || + assessment.stripeInvoiceFeeLineItemId || + assessment.stripeFeePriceId + ); +} + +function observedTopUpChargedFeeMinor(assessment: ServiceFeeAssessmentRecord): number { + if (assessment.outcome !== 'charged') return 0; + if (assessment.chargedFeeMinor > 0) return assessment.chargedFeeMinor; + if (hasTrustedTopUpFeeLineIdentity(assessment)) return assessment.expectedFeeMinor; + return 0; +} + +async function settleLoadedAssessment(params: { + assessment: ServiceFeeAssessmentRecord; + principalMinor: number; + chargedFeeMinor: number; + grossPaidMinor: number; + stripeIds: ServiceFeeStripeIds; + settledAt: Date; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + let assessment = params.assessment; + if (assessment.outcome === 'pending') { + const trustedObservedFee = + params.chargedFeeMinor > 0 + ? params.chargedFeeMinor + : hasTrustedTopUpFeeLineIdentity(assessment) + ? assessment.expectedFeeMinor + : 0; + if (trustedObservedFee > 0) { + assessment = await markServiceFeeAssessmentCharged({ + store: params.deps.store, + assessmentKey: assessment.assessmentKey, + chargedFeeMinor: trustedObservedFee, + stripeIds: params.stripeIds, + now: params.deps.now, + }); + } else if (assessment.expectedFeeMinor > 0) { + assessment = await markServiceFeeAssessmentMissed({ + store: params.deps.store, + assessmentKey: assessment.assessmentKey, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + stripeIds: params.stripeIds, + now: params.deps.now, + }); + await alertForRecord(assessment, params.deps); + } + } + + return settleServiceFeeAssessment({ + store: params.deps.store, + assessmentKey: assessment.assessmentKey, + settledAt: params.settledAt, + settledProductMinor: params.principalMinor, + grossPaidMinor: params.grossPaidMinor, + chargedFeeMinor: + assessment.outcome === 'charged' ? params.chargedFeeMinor || assessment.chargedFeeMinor : 0, + stripeIds: params.stripeIds, + now: params.deps.now, + }); +} + +function assertAssessmentMatchesCharge(params: { + assessment: ServiceFeeAssessmentRecord; + paymentIntentId: string; + kiloUserId?: string; + organizationId?: string; + principalMinor: number; +}): void { + if ( + params.assessment.stripePaymentIntentId && + params.assessment.stripePaymentIntentId !== params.paymentIntentId + ) { + throw new Error( + `service fee assessment ${params.assessment.assessmentKey} payment intent mismatch` + ); + } + if (params.organizationId && params.assessment.organizationId !== params.organizationId) { + throw new Error( + `service fee assessment ${params.assessment.assessmentKey} organization mismatch` + ); + } + if ( + params.kiloUserId && + params.assessment.kiloUserId && + params.assessment.kiloUserId !== params.kiloUserId + ) { + throw new Error(`service fee assessment ${params.assessment.assessmentKey} user mismatch`); + } + if (params.assessment.eligibleSubtotalMinor !== params.principalMinor) { + throw new Error(`service fee assessment ${params.assessment.assessmentKey} principal mismatch`); + } +} + +function inferFlowFromMetadata(metadata: Stripe.Metadata, organizationId?: string): ServiceFeeFlow { + const type = metadata.type; + if (type === 'org-auto-topup-setup') return 'organization_auto_top_up_setup'; + if (type === 'auto-topup-setup') return 'personal_auto_top_up_setup'; + if (type === 'org-auto-topup') return 'organization_auto_top_up'; + if (type === 'auto-topup') return 'personal_auto_top_up'; + return organizationId ? 'organization_top_up' : 'personal_top_up'; +} + +function toCheckoutShaped(prepared: PreparedAutoTopUpInvoiceFee): PreparedTopUpCheckoutFee { + return { + assessmentKey: prepared.assessmentKey, + flow: prepared.flow === 'organization_auto_top_up' ? 'organization_top_up' : 'personal_top_up', + principalMinor: prepared.principalMinor, + decision: prepared.decision, + outcome: prepared.outcome, + expectedFeeMinor: prepared.expectedFeeMinor, + commercialMetadata: prepared.commercialMetadata, + failureCode: prepared.failureCode, + }; +} + +async function alertForRecord( + record: ServiceFeeAssessmentRecord, + deps: ServiceFeeCheckoutDependencies +): Promise { + await alertMissed({ + assessmentKey: record.assessmentKey, + flow: record.flow, + kiloUserId: record.kiloUserId, + organizationId: record.organizationId, + stripeCheckoutSessionId: record.stripeCheckoutSessionId, + stripeInvoiceId: record.stripeInvoiceId, + stripePaymentIntentId: record.stripePaymentIntentId, + stripeChargeId: record.stripeChargeId, + eligibleSubtotalMinor: record.eligibleSubtotalMinor, + expectedFeeMinor: record.expectedFeeMinor, + failureCode: record.failureCode ?? SERVICE_FEE_FAILURE_APPLICATION, + deps, + }); +} + +async function alertMissed(params: { + assessmentKey: string; + flow: ServiceFeeFlow; + kiloUserId?: string | null; + organizationId?: string | null; + stripeCheckoutSessionId?: string | null; + stripeInvoiceId?: string | null; + stripePaymentIntentId?: string | null; + stripeChargeId?: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + failureCode: string; + deps: ServiceFeeCheckoutDependencies; +}): Promise { + const sendAlert = params.deps.sendAlert ?? sendMissedServiceFeeAlert; + await sendAlert({ + assessmentKey: params.assessmentKey, + flow: params.flow, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + stripeCheckoutSessionId: params.stripeCheckoutSessionId, + stripeInvoiceId: params.stripeInvoiceId, + stripePaymentIntentId: params.stripePaymentIntentId, + stripeChargeId: params.stripeChargeId, + eligibleSubtotalMinor: params.eligibleSubtotalMinor, + expectedFeeMinor: params.expectedFeeMinor, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + failureCode: params.failureCode, + attemptedAt: params.deps.now ?? new Date(), + }); +} + +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 nonempty(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parseMinor(value: string | null | undefined): number | null { + const raw = nonempty(value); + if (!raw) return null; + if (!/^-?\d+$/.test(raw)) return null; + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + return parsed; +} + +function unixToDate(value: number | null | undefined): Date | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null; + return new Date(value * 1000); +} + +function customerId( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined +): string | null { + if (typeof customer === 'string') return customer; + if (customer && typeof customer === 'object' && 'id' in customer) return customer.id; + return null; +} + +export type { ServiceFeeLineMetadata }; diff --git a/apps/web/src/lib/service-fees/constants.ts b/apps/web/src/lib/service-fees/constants.ts new file mode 100644 index 0000000000..22dd76fc1f --- /dev/null +++ b/apps/web/src/lib/service-fees/constants.ts @@ -0,0 +1,6 @@ +export const SERVICE_FEE_RATE_BASIS_POINTS = 500; +export const SERVICE_FEE_RATE_DENOMINATOR = 10_000; +export const SERVICE_FEE_ACTIVATION_UNIX_SECONDS = 1_788_220_800; // 2026-09-01T00:00:00Z +export const SERVICE_FEE_DESCRIPTION = 'Service fee (5%)'; +export const SERVICE_FEE_METADATA_TYPE = 'kilo-service-fee'; +export const SERVICE_FEE_VERSION = '2026-09-01-v1'; diff --git a/apps/web/src/lib/service-fees/disputes.test.ts b/apps/web/src/lib/service-fees/disputes.test.ts new file mode 100644 index 0000000000..eb8c4d6878 --- /dev/null +++ b/apps/web/src/lib/service-fees/disputes.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from '@jest/globals'; + +import { + markServiceFeeAssessmentCharged, + observeServiceFeeAssessmentRefunds, + prepareServiceFeeAssessmentDecision, + sanitizeServiceFeeAssessmentMetadata, + settleServiceFeeAssessment, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, +} from '@/lib/service-fees/assessments'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; +import { + observeServiceFeeDisputeClosed, + observeServiceFeeDisputeFundsWithdrawn, +} from '@/lib/service-fees/disputes'; +import { + ServiceFeeObservationNotReadyError, + type ServiceFeeRefundAssessmentStore, +} from '@/lib/service-fees/refunds'; + +const ACTIVATION = new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000); +const ASSESSMENT_KEY = 'checkout:22222222-2222-4222-8222-222222222222'; +const CHARGE_ID = 'ch_dispute_1'; +const PAYMENT_INTENT_ID = 'pi_dispute_1'; + +function cloneRecord(record: ServiceFeeAssessmentRecord): ServiceFeeAssessmentRecord { + return { ...record, metadata: { ...record.metadata } }; +} + +function createMemoryStore(): ServiceFeeRefundAssessmentStore { + const rows = new Map(); + const store: ServiceFeeRefundAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? cloneRecord(row) : null; + }, + async findByStripeChargeId(stripeChargeId) { + const row = [...rows.values()].find(candidate => candidate.stripeChargeId === stripeChargeId); + return row ? cloneRecord(row) : null; + }, + async findByStripePaymentIntentId(stripePaymentIntentId) { + const row = [...rows.values()].find( + candidate => candidate.stripePaymentIntentId === stripePaymentIntentId + ); + return row ? cloneRecord(row) : null; + }, + async findByStripeInvoiceId(stripeInvoiceId) { + const row = [...rows.values()].find( + candidate => candidate.stripeInvoiceId === stripeInvoiceId + ); + return row ? cloneRecord(row) : null; + }, + async insert(record) { + if (rows.has(record.assessmentKey)) { + throw new Error(`duplicate assessment_key ${record.assessmentKey}`); + } + const copy = cloneRecord(record); + rows.set(record.assessmentKey, copy); + return cloneRecord(copy); + }, + async update(assessmentKey, patch) { + const existing = rows.get(assessmentKey); + if (!existing) throw new Error(`missing ${assessmentKey}`); + const next = { + ...existing, + ...patch, + metadata: + patch.metadata !== undefined + ? sanitizeServiceFeeAssessmentMetadata(patch.metadata) + : { ...existing.metadata }, + }; + rows.set(assessmentKey, next); + return cloneRecord(next); + }, + }; + return store; +} + +async function persistCharged(store: ServiceFeeRefundAssessmentStore, chargeLinked = true) { + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey: ASSESSMENT_KEY, + flow: 'personal_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + kiloUserId: 'user_dispute', + }); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeChargeId: chargeLinked ? CHARGE_ID : null, + stripePaymentIntentId: PAYMENT_INTENT_ID, + }, + now: ACTIVATION, + }); + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: ASSESSMENT_KEY, + chargedFeeMinor: 500, + now: ACTIVATION, + }); +} + +async function persistSettled(store: ServiceFeeRefundAssessmentStore, chargeLinked = true) { + await persistCharged(store, chargeLinked); + await settleServiceFeeAssessment({ + store, + assessmentKey: ASSESSMENT_KEY, + settledAt: ACTIVATION, + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + now: ACTIVATION, + }); + return observeServiceFeeAssessmentRefunds({ + store, + assessmentKey: ASSESSMENT_KEY, + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + refundedGrossMinor: 2_100, + now: ACTIVATION, + }); +} + +describe('observeServiceFeeDisputeFundsWithdrawn', () => { + test('sets full settled product and charged fee without touching outcome or refunds', async () => { + const store = createMemoryStore(); + await persistSettled(store); + + const withdrawn = await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { id: 'dp_1', status: 'lost', charge: CHARGE_ID, payment_intent: PAYMENT_INTENT_ID }, + }); + + expect(withdrawn.status).toBe('withdrawn'); + expect(withdrawn.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + refundedGrossMinor: 2_100, + }); + + const again = await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { id: 'dp_1', status: 'lost', charge: CHARGE_ID }, + }); + expect(again.status).toBe('unchanged'); + expect(again.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + }); + }); + + test('out-of-order funds withdrawn before paid throws then converges after settlement', async () => { + const store = createMemoryStore(); + await persistCharged(store); + const dispute = { + id: 'dp_before_paid', + status: 'needs_response', + charge: CHARGE_ID, + payment_intent: PAYMENT_INTENT_ID, + }; + + await expect(observeServiceFeeDisputeFundsWithdrawn({ store, dispute })).rejects.toBeInstanceOf( + ServiceFeeObservationNotReadyError + ); + expect(await store.findByAssessmentKey(ASSESSMENT_KEY)).toMatchObject({ + settledAt: null, + disputedProductMinor: 0, + disputedFeeMinor: 0, + }); + + await expect(observeServiceFeeDisputeFundsWithdrawn({ store, dispute })).rejects.toBeInstanceOf( + ServiceFeeObservationNotReadyError + ); + + await settleServiceFeeAssessment({ + store, + assessmentKey: ASSESSMENT_KEY, + settledAt: ACTIVATION, + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + now: ACTIVATION, + }); + + const withdrawn = await observeServiceFeeDisputeFundsWithdrawn({ store, dispute }); + expect(withdrawn.status).toBe('withdrawn'); + expect(withdrawn.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + }); + + const again = await observeServiceFeeDisputeFundsWithdrawn({ store, dispute }); + expect(again.status).toBe('unchanged'); + expect(again.assessment).toMatchObject({ + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + }); + }); + + test('resolves the assessment by payment intent when the charge id is not linked', async () => { + const store = createMemoryStore(); + await persistSettled(store, false); + + const withdrawn = await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { + id: 'dp_pi', + status: 'needs_response', + charge: 'ch_other', + payment_intent: PAYMENT_INTENT_ID, + }, + }); + + expect(withdrawn.status).toBe('withdrawn'); + expect(withdrawn.disputedFeeMinor).toBe(500); + }); +}); + +describe('observeServiceFeeDisputeClosed', () => { + test('clears dispute columns on won and leaves refunds and outcome untouched', async () => { + const store = createMemoryStore(); + await persistSettled(store); + await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { id: 'dp_2', charge: CHARGE_ID }, + }); + + const won = await observeServiceFeeDisputeClosed({ + store, + dispute: { id: 'dp_2', status: 'won', charge: CHARGE_ID }, + }); + expect(won.status).toBe('cleared'); + expect(won.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 0, + disputedFeeMinor: 0, + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + }); + + const wonAgain = await observeServiceFeeDisputeClosed({ + store, + dispute: { id: 'dp_2', status: 'won', charge: CHARGE_ID }, + }); + expect(wonAgain.status).toBe('unchanged'); + expect(wonAgain.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 0, + disputedFeeMinor: 0, + refundedProductMinor: 2_000, + }); + }); + + test('does not clear dispute columns when the closed dispute is lost', async () => { + const store = createMemoryStore(); + await persistSettled(store); + await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { id: 'dp_lost', charge: CHARGE_ID }, + }); + + const lost = await observeServiceFeeDisputeClosed({ + store, + dispute: { id: 'dp_lost', status: 'lost', charge: CHARGE_ID }, + }); + expect(lost.status).toBe('unchanged'); + expect(lost.assessment).toMatchObject({ + outcome: 'charged', + disputedProductMinor: 10_000, + disputedFeeMinor: 500, + refundedFeeMinor: 100, + }); + }); + + test('ignores disputes with no matching assessment', async () => { + const store = createMemoryStore(); + const result = await observeServiceFeeDisputeFundsWithdrawn({ + store, + dispute: { id: 'dp_unknown', charge: 'ch_missing' }, + }); + expect(result).toEqual({ + status: 'ignored', + assessment: null, + disputedProductMinor: 0, + disputedFeeMinor: 0, + }); + }); +}); diff --git a/apps/web/src/lib/service-fees/disputes.ts b/apps/web/src/lib/service-fees/disputes.ts new file mode 100644 index 0000000000..52d570380d --- /dev/null +++ b/apps/web/src/lib/service-fees/disputes.ts @@ -0,0 +1,153 @@ +import 'server-only'; + +import { + observeServiceFeeAssessmentDispute, + type ServiceFeeAssessmentRecord, +} from '@/lib/service-fees/assessments'; +import { + resolveServiceFeeAssessmentFromStripeRefs, + ServiceFeeObservationNotReadyError, + stripeReferenceId, + type ServiceFeeRefundAssessmentStore, + type ServiceFeeStripeReference, +} from '@/lib/service-fees/refunds'; + +export const SERVICE_FEE_DISPUTE_WON_STATUSES = new Set(['won']); + +export type ServiceFeeDisputeAssessmentStore = ServiceFeeRefundAssessmentStore; + +export type ServiceFeeDisputeObservation = { + id: string; + status?: string | null; + charge?: ServiceFeeStripeReference; + payment_intent?: ServiceFeeStripeReference; +}; + +export type ServiceFeeDisputeObservationStatus = 'ignored' | 'withdrawn' | 'cleared' | 'unchanged'; + +export type ServiceFeeDisputeObservationResult = { + status: ServiceFeeDisputeObservationStatus; + assessment: ServiceFeeAssessmentRecord | null; + disputedProductMinor: number; + disputedFeeMinor: number; +}; + +/** + * Observe `charge.dispute.funds_withdrawn`. A dispute reverses the whole + * charge, so disputed product/fee are set to the full settled/charged amounts. + * Outcome and refund columns are not touched. + */ +export async function observeServiceFeeDisputeFundsWithdrawn(params: { + store: ServiceFeeDisputeAssessmentStore; + dispute: ServiceFeeDisputeObservation; + now?: Date; +}): Promise { + const assessment = await resolveDisputeAssessment(params.store, params.dispute); + if (!assessment) { + return ignoredDispute(null); + } + if (!assessment.settledAt) { + throw new ServiceFeeObservationNotReadyError(assessment.assessmentKey); + } + + if ( + assessment.disputedProductMinor === assessment.settledProductMinor && + assessment.disputedFeeMinor === assessment.chargedFeeMinor + ) { + return { + status: 'unchanged', + assessment, + disputedProductMinor: assessment.disputedProductMinor, + disputedFeeMinor: assessment.disputedFeeMinor, + }; + } + + const updated = await observeServiceFeeAssessmentDispute({ + store: params.store, + assessmentKey: assessment.assessmentKey, + disputedProductMinor: assessment.settledProductMinor, + disputedFeeMinor: assessment.chargedFeeMinor, + now: params.now, + }); + + return { + status: 'withdrawn', + assessment: updated, + disputedProductMinor: updated.disputedProductMinor, + disputedFeeMinor: updated.disputedFeeMinor, + }; +} + +/** + * Observe `charge.dispute.closed`. A won outcome clears dispute columns. Lost + * and other closed statuses leave funds-withdrawn amounts in place. Outcome + * and refund columns are not touched. + */ +export async function observeServiceFeeDisputeClosed(params: { + store: ServiceFeeDisputeAssessmentStore; + dispute: ServiceFeeDisputeObservation; + now?: Date; +}): Promise { + const assessment = await resolveDisputeAssessment(params.store, params.dispute); + if (!assessment) { + return ignoredDispute(null); + } + if (!assessment.settledAt) { + throw new ServiceFeeObservationNotReadyError(assessment.assessmentKey); + } + + if (!SERVICE_FEE_DISPUTE_WON_STATUSES.has(params.dispute.status ?? '')) { + return { + status: 'unchanged', + assessment, + disputedProductMinor: assessment.disputedProductMinor, + disputedFeeMinor: assessment.disputedFeeMinor, + }; + } + + if (assessment.disputedProductMinor === 0 && assessment.disputedFeeMinor === 0) { + return { + status: 'unchanged', + assessment, + disputedProductMinor: 0, + disputedFeeMinor: 0, + }; + } + + const updated = await observeServiceFeeAssessmentDispute({ + store: params.store, + assessmentKey: assessment.assessmentKey, + disputedProductMinor: 0, + disputedFeeMinor: 0, + now: params.now, + }); + + return { + status: 'cleared', + assessment: updated, + disputedProductMinor: updated.disputedProductMinor, + disputedFeeMinor: updated.disputedFeeMinor, + }; +} + +async function resolveDisputeAssessment( + store: ServiceFeeDisputeAssessmentStore, + dispute: ServiceFeeDisputeObservation +): Promise { + return resolveServiceFeeAssessmentFromStripeRefs({ + store, + chargeId: stripeReferenceId(dispute.charge), + paymentIntentId: stripeReferenceId(dispute.payment_intent), + }); +} + +function ignoredDispute( + assessment: ServiceFeeAssessmentRecord | null +): ServiceFeeDisputeObservationResult { + return { + status: 'ignored', + assessment, + disputedProductMinor: assessment?.disputedProductMinor ?? 0, + disputedFeeMinor: assessment?.disputedFeeMinor ?? 0, + }; +} diff --git a/apps/web/src/lib/service-fees/drizzle-store.test.ts b/apps/web/src/lib/service-fees/drizzle-store.test.ts new file mode 100644 index 0000000000..dad003b9e3 --- /dev/null +++ b/apps/web/src/lib/service-fees/drizzle-store.test.ts @@ -0,0 +1,518 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { + organization_service_fee_exemptions, + organizations, + stripe_service_fee_assessments, +} from '@kilocode/db/schema'; +import { eq, sql } from 'drizzle-orm'; + +import { + linkServiceFeeAssessmentStripeIds, + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + observeServiceFeeAssessmentDispute, + observeServiceFeeAssessmentRefunds, + prepareServiceFeeAssessmentDecision, + settleServiceFeeAssessment, + upsertServiceFeeAssessment, +} from '@/lib/service-fees/assessments'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + ServiceFeeAssessmentKeyConflictError, + createOrganizationServiceFeeExemptionStore, + createServiceFeeAssessmentStore, + createServiceFeeStores, +} from '@/lib/service-fees/drizzle-store'; +import { + getEffectiveOrganizationServiceFeeExemption, + getOrganizationServiceFeeExemption, + organizationServiceFeeExemptionLockKey, + setOrganizationServiceFeeExemption, +} from '@/lib/service-fees/organization-exemptions'; +import type { PrepareAssessmentInput } from '@/lib/service-fees/types'; +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import { createOrganization } from '@/lib/organizations/organizations'; +import { insertTestUser } from '@/tests/helpers/user.helper'; + +const ACTIVATION = new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000); + +beforeEach(async () => { + await cleanupDbForTest(); +}); + +describe('drizzle service fee assessment store', () => { + test('inserts once, enriches Stripe IDs on retry, and rejects conflicts through domain functions', async () => { + const user = await insertTestUser(); + const { assessments } = createServiceFeeStores(db); + const assessmentKey = `checkout:${crypto.randomUUID()}`; + const decision = await prepareServiceFeeAssessmentDecision( + personalInput(user.id, { assessmentKey }) + ); + + const [first, second] = await Promise.all([ + upsertServiceFeeAssessment({ + store: assessments, + decision, + stripeIds: { stripeCheckoutSessionId: 'cs_enrich_1' }, + }), + upsertServiceFeeAssessment({ + store: assessments, + decision, + stripeIds: { stripePaymentIntentId: 'pi_enrich_1' }, + }), + ]); + + expect(first.assessmentKey).toBe(second.assessmentKey); + const persisted = await assessments.findByAssessmentKey(assessmentKey); + expect(persisted).toMatchObject({ + assessmentKey, + outcome: 'pending', + expectedFeeMinor: 500, + chargedFeeMinor: 0, + stripeCheckoutSessionId: 'cs_enrich_1', + stripePaymentIntentId: 'pi_enrich_1', + }); + expect(await assessments.findByStripeCheckoutSessionId('cs_enrich_1')).toMatchObject({ + assessmentKey, + }); + expect(await assessments.findByStripePaymentIntentId('pi_enrich_1')).toMatchObject({ + assessmentKey, + }); + + const linked = await linkServiceFeeAssessmentStripeIds({ + store: assessments, + assessmentKey, + stripeIds: { + stripeInvoiceId: 'in_enrich_1', + stripeChargeId: 'ch_enrich_1', + }, + }); + expect(linked).toMatchObject({ + stripeInvoiceId: 'in_enrich_1', + stripeChargeId: 'ch_enrich_1', + }); + expect(await assessments.findByStripeInvoiceId('in_enrich_1')).toMatchObject({ assessmentKey }); + expect(await assessments.findByStripeChargeId('ch_enrich_1')).toMatchObject({ assessmentKey }); + + await expect( + upsertServiceFeeAssessment({ + store: assessments, + decision: await prepareServiceFeeAssessmentDecision( + personalInput(user.id, { assessmentKey, kiloUserId: (await insertTestUser()).id }) + ), + }) + ).rejects.toMatchObject({ reason: 'owner', field: 'kiloUserId' }); + + await expect( + linkServiceFeeAssessmentStripeIds({ + store: assessments, + assessmentKey, + stripeIds: { stripeChargeId: 'ch_other' }, + }) + ).rejects.toMatchObject({ reason: 'stripe_id', field: 'stripeChargeId' }); + + const charged = await markServiceFeeAssessmentCharged({ + store: assessments, + assessmentKey, + chargedFeeMinor: 400, + stripeIds: { stripeCheckoutFeeLineItemId: 'li_fee_discounted' }, + }); + expect(charged).toMatchObject({ + outcome: 'charged', + expectedFeeMinor: 500, + chargedFeeMinor: 400, + }); + + const settled = await settleServiceFeeAssessment({ + store: assessments, + assessmentKey, + settledAt: '2026-09-01T01:00:00.000Z', + settledProductMinor: 8_000, + grossPaidMinor: 8_400, + chargedFeeMinor: 400, + }); + const refunded = await observeServiceFeeAssessmentRefunds({ + store: assessments, + assessmentKey, + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + refundedGrossMinor: 2_100, + }); + const disputed = await observeServiceFeeAssessmentDispute({ + store: assessments, + assessmentKey, + disputedProductMinor: 1_000, + disputedFeeMinor: 50, + }); + + expect(settled.settledAt).toBe('2026-09-01T01:00:00.000Z'); + expect(refunded).toMatchObject({ + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + outcome: 'charged', + }); + expect(disputed).toMatchObject({ + disputedProductMinor: 1_000, + disputedFeeMinor: 50, + outcome: 'charged', + }); + }); + + test('surfaces assessment_key duplicates without aborting the surrounding transaction', async () => { + const user = await insertTestUser(); + const assessmentKey = `checkout:${crypto.randomUUID()}`; + const decision = await prepareServiceFeeAssessmentDecision( + personalInput(user.id, { assessmentKey }) + ); + const first = await upsertServiceFeeAssessment({ + store: createServiceFeeAssessmentStore(db), + decision, + }); + + await db.transaction(async tx => { + const store = createServiceFeeAssessmentStore(tx); + await expect(store.insert(first)).rejects.toBeInstanceOf( + ServiceFeeAssessmentKeyConflictError + ); + const found = await store.findByAssessmentKey(assessmentKey); + expect(found?.assessmentKey).toBe(first.assessmentKey); + const enriched = await store.update(assessmentKey, { + stripeCustomerId: 'cus_after_conflict', + updatedAt: new Date().toISOString(), + }); + expect(enriched.stripeCustomerId).toBe('cus_after_conflict'); + }); + + expect( + await createServiceFeeAssessmentStore(db).findByAssessmentKey(assessmentKey) + ).toMatchObject({ + assessmentKey: first.assessmentKey, + stripeCustomerId: 'cus_after_conflict', + }); + }); + + test('allows charged fee below expected fee and rejects missed rows without a failure code', async () => { + const user = await insertTestUser(); + const assessments = createServiceFeeAssessmentStore(db); + const discountedKey = `checkout:${crypto.randomUUID()}`; + const decision = await prepareServiceFeeAssessmentDecision( + personalInput(user.id, { assessmentKey: discountedKey }) + ); + await upsertServiceFeeAssessment({ store: assessments, decision }); + const charged = await markServiceFeeAssessmentCharged({ + store: assessments, + assessmentKey: discountedKey, + chargedFeeMinor: 196, + stripeIds: { stripeCheckoutFeeLineItemId: `li_fee_${crypto.randomUUID()}` }, + }); + expect(charged.expectedFeeMinor).toBe(500); + expect(charged.chargedFeeMinor).toBe(196); + + const rawRows = await db + .select() + .from(stripe_service_fee_assessments) + .where(eq(stripe_service_fee_assessments.assessment_key, discountedKey)); + expect(rawRows).toHaveLength(1); + expect(rawRows[0]?.charged_fee_minor).toBe(196); + expect(rawRows[0]?.expected_fee_minor).toBe(500); + + const missedPending = await upsertServiceFeeAssessment({ + store: assessments, + decision: await prepareServiceFeeAssessmentDecision( + personalInput(user.id, { assessmentKey: `checkout:${crypto.randomUUID()}` }) + ), + }); + const missed = await markServiceFeeAssessmentMissed({ + store: assessments, + assessmentKey: missedPending.assessmentKey, + failureCode: 'fee_application_failed', + }); + expect(missed).toMatchObject({ + outcome: 'missed', + chargedFeeMinor: 0, + failureCode: 'fee_application_failed', + }); + + const invalidMissedKey = `checkout:${crypto.randomUUID()}`; + let missedWithoutCode: unknown; + try { + await db.insert(stripe_service_fee_assessments).values({ + assessment_key: invalidMissedKey, + version: SERVICE_FEE_VERSION, + flow: 'personal_top_up', + outcome: 'missed', + currency: 'usd', + kilo_user_id: user.id, + eligibility_created_at: ACTIVATION.toISOString(), + eligible_subtotal_minor: 10_000, + expected_fee_minor: 500, + charged_fee_minor: 0, + failure_code: null, + }); + } catch (error) { + missedWithoutCode = error; + } + expectPostgresCheck(missedWithoutCode, 'stripe_service_fee_assessments_missed_check'); + + let missedZeroExpected: unknown; + try { + await db.insert(stripe_service_fee_assessments).values({ + assessment_key: `checkout:${crypto.randomUUID()}`, + version: SERVICE_FEE_VERSION, + flow: 'personal_top_up', + outcome: 'missed', + currency: 'usd', + kilo_user_id: user.id, + eligibility_created_at: ACTIVATION.toISOString(), + eligible_subtotal_minor: 1, + expected_fee_minor: 0, + charged_fee_minor: 0, + failure_code: 'fee_application_failed', + }); + } catch (error) { + missedZeroExpected = error; + } + expectPostgresCheck(missedZeroExpected, 'stripe_service_fee_assessments_missed_check'); + }); +}); + +describe('drizzle organization service fee exemption store', () => { + test('resolves exact-organization history and does not inherit a parent exemption', async () => { + const admin = await insertTestUser({ is_admin: true }); + const parent = await createOrganization(`Parent ${crypto.randomUUID()}`, admin.id); + const child = await createOrganization(`Child ${crypto.randomUUID()}`, admin.id); + await db + .update(organizations) + .set({ parent_organization_id: parent.id }) + .where(eq(organizations.id, child.id)); + + const exemptions = createOrganizationServiceFeeExemptionStore(db); + const assessments = createServiceFeeAssessmentStore(db); + + await setOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: parent.id, + isExempt: true, + reason: 'parent nonprofit grant', + changedByKiloUserId: admin.id, + now: new Date('2026-08-01T00:00:00.000Z'), + }); + await setOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: child.id, + isExempt: true, + reason: 'child historical grant', + changedByKiloUserId: admin.id, + now: new Date('2026-08-15T00:00:00.000Z'), + }); + await setOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: child.id, + isExempt: false, + reason: 'child exemption revoked', + changedByKiloUserId: admin.id, + now: new Date('2026-10-01T00:00:00.000Z'), + }); + + const childView = await getOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: child.id, + }); + expect(childView.current).toMatchObject({ + organizationId: child.id, + isExempt: false, + reason: 'child exemption revoked', + }); + expect(childView.history.map(row => row.reason)).toEqual([ + 'child exemption revoked', + 'child historical grant', + ]); + expect(childView.current?.createdAt).toBe(childView.history[0]?.createdAt); + + const childAtGrant = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: child.id, + at: new Date('2026-09-01T00:00:00.000Z'), + }); + const childAtRevoke = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: child.id, + at: new Date('2026-10-01T00:00:00.000Z'), + }); + const parentNow = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: parent.id, + at: new Date('2026-10-01T00:00:00.000Z'), + }); + expect(childAtGrant).toMatchObject({ isExempt: true, reason: 'child historical grant' }); + expect(childAtRevoke).toMatchObject({ isExempt: false, reason: 'child exemption revoked' }); + expect(parentNow).toMatchObject({ isExempt: true, reason: 'parent nonprofit grant' }); + + const sibling = await createOrganization(`Sibling ${crypto.randomUUID()}`, admin.id); + await db + .update(organizations) + .set({ parent_organization_id: parent.id }) + .where(eq(organizations.id, sibling.id)); + const siblingEffective = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: sibling.id, + at: new Date('2026-09-01T00:00:00.000Z'), + }); + expect(siblingEffective).toBeNull(); + + const childDecision = await prepareServiceFeeAssessmentDecision( + organizationInput(child.id, admin.id, { + assessmentKey: `invoice:${crypto.randomUUID()}`, + eligibilityCreatedAt: new Date('2026-09-01T00:00:00.000Z'), + }), + { + findEffectiveExemption: async (organizationId, at) => { + const row = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId, + at, + }); + return row ? { id: row.id, isExempt: row.isExempt } : null; + }, + } + ); + expect(childDecision).toMatchObject({ + outcome: 'exempt', + exemptionId: childAtGrant?.id, + }); + + const siblingDecision = await prepareServiceFeeAssessmentDecision( + organizationInput(sibling.id, admin.id, { + assessmentKey: `invoice:${crypto.randomUUID()}`, + }), + { + findEffectiveExemption: async (organizationId, at) => { + const row = await getEffectiveOrganizationServiceFeeExemption({ + store: exemptions, + organizationId, + at, + }); + return row ? { id: row.id, isExempt: row.isExempt } : null; + }, + } + ); + expect(siblingDecision).toMatchObject({ + outcome: 'pending', + exemptionId: null, + }); + + const persistedExempt = await upsertServiceFeeAssessment({ + store: assessments, + decision: childDecision, + }); + expect(persistedExempt.exemptionId).toBe(childAtGrant?.id); + + const exemptionRows = await db + .select() + .from(organization_service_fee_exemptions) + .where(eq(organization_service_fee_exemptions.organization_id, child.id)); + expect(exemptionRows).toHaveLength(2); + expect(exemptionRows.map(row => row.id)).toContain(childView.current?.id); + }); + + test('rejects deleted organizations and holds a transaction-scoped advisory lock', async () => { + const admin = await insertTestUser({ is_admin: true }); + const organization = await createOrganization(`Deleted ${crypto.randomUUID()}`, admin.id); + const exemptions = createOrganizationServiceFeeExemptionStore(db); + + await db + .update(organizations) + .set({ deleted_at: new Date().toISOString() }) + .where(eq(organizations.id, organization.id)); + + await expect( + setOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: organization.id, + isExempt: true, + reason: 'should not persist', + changedByKiloUserId: admin.id, + }) + ).rejects.toMatchObject({ code: 'organization_not_found' }); + + const view = await getOrganizationServiceFeeExemption({ + store: exemptions, + organizationId: organization.id, + }); + expect(view.current).toBeNull(); + expect(view.history).toEqual([]); + expect(await exemptions.findActiveOrganization(organization.id)).toBeNull(); + + const active = await createOrganization(`Active ${crypto.randomUUID()}`, admin.id); + await db.transaction(async tx => { + const txStore = createOrganizationServiceFeeExemptionStore(tx); + await txStore.lockOrganization(active.id); + const result = await db.execute<{ locked: boolean }>( + sql`SELECT pg_catalog.pg_try_advisory_xact_lock(pg_catalog.hashtextextended(${organizationServiceFeeExemptionLockKey(active.id)}, 0)) AS locked` + ); + expect(result.rows[0]?.locked).toBe(false); + + await setOrganizationServiceFeeExemption({ + store: txStore, + organizationId: active.id, + isExempt: true, + reason: 'locked grant', + changedByKiloUserId: admin.id, + }); + }); + + const granted = await getOrganizationServiceFeeExemption({ + store: createOrganizationServiceFeeExemptionStore(db), + organizationId: active.id, + }); + expect(granted.current?.isExempt).toBe(true); + expect(granted.history).toHaveLength(1); + }); +}); + +function personalInput( + kiloUserId: string, + overrides: Partial = {} +): PrepareAssessmentInput { + return { + assessmentKey: `checkout:${crypto.randomUUID()}`, + flow: 'personal_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + kiloUserId, + ...overrides, + }; +} + +function organizationInput( + organizationId: string, + kiloUserId: string, + overrides: Partial = {} +): PrepareAssessmentInput { + return { + assessmentKey: `invoice:${crypto.randomUUID()}`, + flow: 'organization_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + organizationId, + kiloUserId, + ...overrides, + }; +} + +function expectPostgresCheck(error: unknown, constraint: string): void { + const err = error as { + code?: string; + constraint?: string; + cause?: { code?: string; constraint?: string }; + }; + const code = err?.code ?? err?.cause?.code; + const name = err?.constraint ?? err?.cause?.constraint; + expect(code).toBe('23514'); + expect(name).toBe(constraint); +} diff --git a/apps/web/src/lib/service-fees/drizzle-store.ts b/apps/web/src/lib/service-fees/drizzle-store.ts new file mode 100644 index 0000000000..03301c0336 --- /dev/null +++ b/apps/web/src/lib/service-fees/drizzle-store.ts @@ -0,0 +1,518 @@ +import 'server-only'; + +import { + sanitizeServiceFeeAssessmentMetadata, + toServiceFeeTimestamp, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { + acquireOrganizationServiceFeeExemptionLock, + normalizeOrganizationExemptionTimestamp, + type ActiveOrganizationRef, + type OrganizationServiceFeeExemptionRecord, + type OrganizationServiceFeeExemptionStore, +} from '@/lib/service-fees/organization-exemptions'; +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + organization_service_fee_exemptions, + organizations, + stripe_service_fee_assessments, + type NewStripeServiceFeeAssessment, + type OrganizationServiceFeeExemption, + type StripeServiceFeeAssessment, +} from '@kilocode/db/schema'; +import { and, desc, eq, isNull, lte, or } from 'drizzle-orm'; + +export type ServiceFeeDbOrTx = typeof db | DrizzleTransaction; + +export class ServiceFeeAssessmentKeyConflictError extends Error { + readonly name = 'ServiceFeeAssessmentKeyConflictError'; + + constructor( + readonly assessmentKey: string, + options?: { cause?: unknown } + ) { + super(`duplicate service fee assessment_key ${assessmentKey}`, options); + } +} + +export type ServiceFeeAssessmentPersistenceStore = ServiceFeeAssessmentStore & { + findByStripeCheckoutSessionId( + stripeCheckoutSessionId: string + ): Promise; + findByStripeInvoiceId(stripeInvoiceId: string): Promise; + findByStripePaymentIntentId( + stripePaymentIntentId: string + ): Promise; + findByStripeChargeId(stripeChargeId: string): Promise; +}; + +export function createServiceFeeAssessmentStore( + dbOrTx: ServiceFeeDbOrTx +): ServiceFeeAssessmentPersistenceStore { + const store: ServiceFeeAssessmentPersistenceStore = { + async transact(fn) { + return dbOrTx.transaction(async tx => fn(createServiceFeeAssessmentStore(tx))); + }, + + async findByAssessmentKey(assessmentKey) { + if (!assessmentKey) return null; + const [row] = await dbOrTx + .select() + .from(stripe_service_fee_assessments) + .where(eq(stripe_service_fee_assessments.assessment_key, assessmentKey)) + .limit(1); + return row ? toAssessmentRecord(row) : null; + }, + + async findByStripeCheckoutSessionId(stripeCheckoutSessionId) { + return findAssessmentByNullableText( + dbOrTx, + stripe_service_fee_assessments.stripe_checkout_session_id, + stripeCheckoutSessionId + ); + }, + + async findByStripeInvoiceId(stripeInvoiceId) { + return findAssessmentByNullableText( + dbOrTx, + stripe_service_fee_assessments.stripe_invoice_id, + stripeInvoiceId + ); + }, + + async findByStripePaymentIntentId(stripePaymentIntentId) { + return findAssessmentByNullableText( + dbOrTx, + stripe_service_fee_assessments.stripe_payment_intent_id, + stripePaymentIntentId + ); + }, + + async findByStripeChargeId(stripeChargeId) { + return findAssessmentByNullableText( + dbOrTx, + stripe_service_fee_assessments.stripe_charge_id, + stripeChargeId + ); + }, + + async insert(record) { + const [row] = await dbOrTx + .insert(stripe_service_fee_assessments) + .values(toAssessmentInsert(record)) + .onConflictDoNothing({ target: stripe_service_fee_assessments.assessment_key }) + .returning(); + + if (!row) { + throw new ServiceFeeAssessmentKeyConflictError(record.assessmentKey); + } + + return toAssessmentRecord(row); + }, + + async update(assessmentKey, patch) { + const set = toAssessmentUpdate(patch); + if (Object.keys(set).length === 0) { + const existing = await store.findByAssessmentKey(assessmentKey); + if (!existing) { + throw new Error(`service fee assessment ${assessmentKey} was not found`); + } + return existing; + } + + const [row] = await dbOrTx + .update(stripe_service_fee_assessments) + .set(set) + .where(assessmentUpdateGuard(assessmentKey, patch)) + .returning(); + + if (!row) { + throw new Error(`service fee assessment ${assessmentKey} was not updated`); + } + + return toAssessmentRecord(row); + }, + }; + + return store; +} + +export function createOrganizationServiceFeeExemptionStore( + dbOrTx: ServiceFeeDbOrTx +): OrganizationServiceFeeExemptionStore { + const store: OrganizationServiceFeeExemptionStore = { + async transact(fn) { + return dbOrTx.transaction(async tx => fn(createOrganizationServiceFeeExemptionStore(tx))); + }, + + async lockOrganization(organizationId) { + await acquireOrganizationServiceFeeExemptionLock( + { + execute: query => dbOrTx.execute(query as Parameters[0]), + }, + organizationId + ); + }, + + async findActiveOrganization(organizationId) { + const [row] = await dbOrTx + .select({ id: organizations.id }) + .from(organizations) + .where(and(eq(organizations.id, organizationId), isNull(organizations.deleted_at))) + .limit(1); + return row ? ({ id: row.id } satisfies ActiveOrganizationRef) : null; + }, + + async findAtOrBefore(organizationId, at) { + const [row] = await dbOrTx + .select() + .from(organization_service_fee_exemptions) + .where( + and( + eq(organization_service_fee_exemptions.organization_id, organizationId), + lte( + organization_service_fee_exemptions.created_at, + normalizeOrganizationExemptionTimestamp(at) + ) + ) + ) + .orderBy( + desc(organization_service_fee_exemptions.created_at), + desc(organization_service_fee_exemptions.id) + ) + .limit(1); + return row ? toExemptionRecord(row) : null; + }, + + async listNewestFirst(organizationId) { + const rows = await dbOrTx + .select() + .from(organization_service_fee_exemptions) + .where(eq(organization_service_fee_exemptions.organization_id, organizationId)) + .orderBy( + desc(organization_service_fee_exemptions.created_at), + desc(organization_service_fee_exemptions.id) + ); + return rows.map(toExemptionRecord); + }, + + async getCurrent(organizationId) { + const [row] = await dbOrTx + .select() + .from(organization_service_fee_exemptions) + .where(eq(organization_service_fee_exemptions.organization_id, organizationId)) + .orderBy( + desc(organization_service_fee_exemptions.created_at), + desc(organization_service_fee_exemptions.id) + ) + .limit(1); + return row ? toExemptionRecord(row) : null; + }, + + async insert(record) { + const [row] = await dbOrTx + .insert(organization_service_fee_exemptions) + .values({ + id: record.id, + organization_id: record.organizationId, + is_exempt: record.isExempt, + reason: record.reason, + changed_by_kilo_user_id: nullableText(record.changedByKiloUserId), + created_at: record.createdAt, + }) + .returning(); + + if (!row) { + throw new Error(`organization service fee exemption ${record.id} was not inserted`); + } + + return toExemptionRecord(row); + }, + }; + + return store; +} + +export function createDefaultServiceFeeAssessmentStore(): ServiceFeeAssessmentPersistenceStore { + return createServiceFeeAssessmentStore(db); +} + +export function createDefaultOrganizationServiceFeeExemptionStore(): OrganizationServiceFeeExemptionStore { + return createOrganizationServiceFeeExemptionStore(db); +} + +export function createServiceFeeStores(dbOrTx: ServiceFeeDbOrTx = db): { + assessments: ServiceFeeAssessmentPersistenceStore; + exemptions: OrganizationServiceFeeExemptionStore; +} { + return { + assessments: createServiceFeeAssessmentStore(dbOrTx), + exemptions: createOrganizationServiceFeeExemptionStore(dbOrTx), + }; +} + +function nullableText(value: string | null | undefined): string | null { + if (value == null || value === '') return null; + return value; +} + +function toAssessmentRecord(row: StripeServiceFeeAssessment): ServiceFeeAssessmentRecord { + return { + assessmentKey: row.assessment_key, + version: row.version, + flow: row.flow, + outcome: row.outcome, + currency: row.currency, + kiloUserId: row.kilo_user_id, + organizationId: row.organization_id, + stripeCustomerId: row.stripe_customer_id, + stripeCheckoutSessionId: row.stripe_checkout_session_id, + stripeInvoiceId: row.stripe_invoice_id, + stripePaymentIntentId: row.stripe_payment_intent_id, + stripeChargeId: row.stripe_charge_id, + stripeFeePriceId: row.stripe_fee_price_id, + stripeCheckoutFeeLineItemId: row.stripe_checkout_fee_line_item_id, + stripeInvoiceFeeLineItemId: row.stripe_invoice_fee_line_item_id, + eligibilityCreatedAt: toServiceFeeTimestamp(row.eligibility_created_at), + eligibleSubtotalMinor: row.eligible_subtotal_minor, + expectedFeeMinor: row.expected_fee_minor, + chargedFeeMinor: row.charged_fee_minor, + grossPaidMinor: row.gross_paid_minor, + settledProductMinor: row.settled_product_minor, + settledAt: row.settled_at ? toServiceFeeTimestamp(row.settled_at) : null, + refundedProductMinor: row.refunded_product_minor, + refundedFeeMinor: row.refunded_fee_minor, + refundedGrossMinor: row.refunded_gross_minor, + disputedProductMinor: row.disputed_product_minor, + disputedFeeMinor: row.disputed_fee_minor, + exemptionId: row.exemption_id, + failureCode: row.failure_code, + metadata: sanitizeServiceFeeAssessmentMetadata(row.metadata), + createdAt: toServiceFeeTimestamp(row.created_at), + updatedAt: toServiceFeeTimestamp(row.updated_at), + }; +} + +function toAssessmentInsert(record: ServiceFeeAssessmentRecord): NewStripeServiceFeeAssessment { + return { + assessment_key: record.assessmentKey, + version: record.version, + flow: record.flow, + outcome: record.outcome, + currency: record.currency, + kilo_user_id: nullableText(record.kiloUserId), + organization_id: nullableText(record.organizationId), + stripe_customer_id: nullableText(record.stripeCustomerId), + stripe_checkout_session_id: nullableText(record.stripeCheckoutSessionId), + stripe_invoice_id: nullableText(record.stripeInvoiceId), + stripe_payment_intent_id: nullableText(record.stripePaymentIntentId), + stripe_charge_id: nullableText(record.stripeChargeId), + stripe_fee_price_id: nullableText(record.stripeFeePriceId), + stripe_checkout_fee_line_item_id: nullableText(record.stripeCheckoutFeeLineItemId), + stripe_invoice_fee_line_item_id: nullableText(record.stripeInvoiceFeeLineItemId), + eligibility_created_at: record.eligibilityCreatedAt, + eligible_subtotal_minor: record.eligibleSubtotalMinor, + expected_fee_minor: record.expectedFeeMinor, + charged_fee_minor: record.chargedFeeMinor, + gross_paid_minor: record.grossPaidMinor, + settled_product_minor: record.settledProductMinor, + settled_at: record.settledAt, + refunded_product_minor: record.refundedProductMinor, + refunded_fee_minor: record.refundedFeeMinor, + refunded_gross_minor: record.refundedGrossMinor, + disputed_product_minor: record.disputedProductMinor, + disputed_fee_minor: record.disputedFeeMinor, + exemption_id: nullableText(record.exemptionId), + failure_code: nullableText(record.failureCode), + metadata: sanitizeServiceFeeAssessmentMetadata(record.metadata), + created_at: record.createdAt, + updated_at: record.updatedAt, + }; +} + +function toAssessmentUpdate( + patch: Partial +): Partial { + const set: Partial = {}; + + if (patch.version !== undefined) set.version = patch.version; + if (patch.flow !== undefined) set.flow = patch.flow; + if (patch.outcome !== undefined) set.outcome = patch.outcome; + if (patch.currency !== undefined) set.currency = patch.currency; + if (patch.kiloUserId !== undefined) set.kilo_user_id = nullableText(patch.kiloUserId); + if (patch.organizationId !== undefined) { + set.organization_id = nullableText(patch.organizationId); + } + if (patch.stripeCustomerId !== undefined) { + set.stripe_customer_id = nullableText(patch.stripeCustomerId); + } + if (patch.stripeCheckoutSessionId !== undefined) { + set.stripe_checkout_session_id = nullableText(patch.stripeCheckoutSessionId); + } + if (patch.stripeInvoiceId !== undefined) { + set.stripe_invoice_id = nullableText(patch.stripeInvoiceId); + } + if (patch.stripePaymentIntentId !== undefined) { + set.stripe_payment_intent_id = nullableText(patch.stripePaymentIntentId); + } + if (patch.stripeChargeId !== undefined) { + set.stripe_charge_id = nullableText(patch.stripeChargeId); + } + if (patch.stripeFeePriceId !== undefined) { + set.stripe_fee_price_id = nullableText(patch.stripeFeePriceId); + } + if (patch.stripeCheckoutFeeLineItemId !== undefined) { + set.stripe_checkout_fee_line_item_id = nullableText(patch.stripeCheckoutFeeLineItemId); + } + if (patch.stripeInvoiceFeeLineItemId !== undefined) { + set.stripe_invoice_fee_line_item_id = nullableText(patch.stripeInvoiceFeeLineItemId); + } + if (patch.eligibilityCreatedAt !== undefined) { + set.eligibility_created_at = patch.eligibilityCreatedAt; + } + if (patch.eligibleSubtotalMinor !== undefined) { + set.eligible_subtotal_minor = patch.eligibleSubtotalMinor; + } + if (patch.expectedFeeMinor !== undefined) set.expected_fee_minor = patch.expectedFeeMinor; + if (patch.chargedFeeMinor !== undefined) set.charged_fee_minor = patch.chargedFeeMinor; + if (patch.grossPaidMinor !== undefined) set.gross_paid_minor = patch.grossPaidMinor; + if (patch.settledProductMinor !== undefined) { + set.settled_product_minor = patch.settledProductMinor; + } + if (patch.settledAt !== undefined) set.settled_at = patch.settledAt; + if (patch.refundedProductMinor !== undefined) { + set.refunded_product_minor = patch.refundedProductMinor; + } + if (patch.refundedFeeMinor !== undefined) set.refunded_fee_minor = patch.refundedFeeMinor; + if (patch.refundedGrossMinor !== undefined) set.refunded_gross_minor = patch.refundedGrossMinor; + if (patch.disputedProductMinor !== undefined) { + set.disputed_product_minor = patch.disputedProductMinor; + } + if (patch.disputedFeeMinor !== undefined) set.disputed_fee_minor = patch.disputedFeeMinor; + if (patch.exemptionId !== undefined) { + set.exemption_id = nullableText(patch.exemptionId); + } + if (patch.failureCode !== undefined) set.failure_code = nullableText(patch.failureCode); + if (patch.metadata !== undefined) { + set.metadata = sanitizeServiceFeeAssessmentMetadata(patch.metadata); + } + if (patch.updatedAt !== undefined) set.updated_at = patch.updatedAt; + + return set; +} + +function assessmentUpdateGuard(assessmentKey: string, patch: Partial) { + return and( + eq(stripe_service_fee_assessments.assessment_key, assessmentKey), + immutableTextGuard( + stripe_service_fee_assessments.kilo_user_id, + patch.kiloUserId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.organization_id, + patch.organizationId, + /* allowNullToValue */ false + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_customer_id, + patch.stripeCustomerId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_checkout_session_id, + patch.stripeCheckoutSessionId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_invoice_id, + patch.stripeInvoiceId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_payment_intent_id, + patch.stripePaymentIntentId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_charge_id, + patch.stripeChargeId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_fee_price_id, + patch.stripeFeePriceId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_checkout_fee_line_item_id, + patch.stripeCheckoutFeeLineItemId, + /* allowNullToValue */ true + ), + immutableTextGuard( + stripe_service_fee_assessments.stripe_invoice_fee_line_item_id, + patch.stripeInvoiceFeeLineItemId, + /* allowNullToValue */ true + ) + ); +} + +function immutableTextGuard( + column: + | typeof stripe_service_fee_assessments.kilo_user_id + | typeof stripe_service_fee_assessments.organization_id + | typeof stripe_service_fee_assessments.stripe_customer_id + | typeof stripe_service_fee_assessments.stripe_checkout_session_id + | typeof stripe_service_fee_assessments.stripe_invoice_id + | typeof stripe_service_fee_assessments.stripe_payment_intent_id + | typeof stripe_service_fee_assessments.stripe_charge_id + | typeof stripe_service_fee_assessments.stripe_fee_price_id + | typeof stripe_service_fee_assessments.stripe_checkout_fee_line_item_id + | typeof stripe_service_fee_assessments.stripe_invoice_fee_line_item_id, + incoming: string | null | undefined, + allowNullToValue: boolean +) { + if (incoming === undefined) return undefined; + const value = nullableText(incoming); + if (value === null) { + return isNull(column); + } + if (allowNullToValue) { + return or(isNull(column), eq(column, value)); + } + return eq(column, value); +} + +async function findAssessmentByNullableText( + dbOrTx: ServiceFeeDbOrTx, + column: + | typeof stripe_service_fee_assessments.stripe_checkout_session_id + | typeof stripe_service_fee_assessments.stripe_invoice_id + | typeof stripe_service_fee_assessments.stripe_payment_intent_id + | typeof stripe_service_fee_assessments.stripe_charge_id, + value: string +): Promise { + const id = nullableText(value); + if (!id) return null; + const [row] = await dbOrTx + .select() + .from(stripe_service_fee_assessments) + .where(eq(column, id)) + .limit(1); + return row ? toAssessmentRecord(row) : null; +} + +function toExemptionRecord( + row: OrganizationServiceFeeExemption +): OrganizationServiceFeeExemptionRecord { + return { + id: row.id, + organizationId: row.organization_id, + isExempt: row.is_exempt, + reason: row.reason, + changedByKiloUserId: row.changed_by_kilo_user_id, + createdAt: normalizeOrganizationExemptionTimestamp(row.created_at), + }; +} diff --git a/apps/web/src/lib/service-fees/invoice-created.test.ts b/apps/web/src/lib/service-fees/invoice-created.test.ts new file mode 100644 index 0000000000..f23b766a63 --- /dev/null +++ b/apps/web/src/lib/service-fees/invoice-created.test.ts @@ -0,0 +1,724 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; +import { getKnownStripePriceIdsForKiloClaw } from '@/lib/kiloclaw/stripe-price-ids.server'; +import { SEAT_PRODUCT_IDS } from '@/lib/organizations/stripe-seat-line-items'; +import { + 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 { + handleKiloPassInvoiceCreated, + SERVICE_FEE_FAILURE_APPLICATION, + type KiloPassInvoiceCreatedDependencies, + type KiloPassInvoiceCreatedStripe, +} from '@/lib/service-fees/invoice-created'; +import { createInvoiceServiceFeeAssessmentKey } from '@/lib/service-fees/checkout'; + +const KILO_PASS_PRICE_ID = getKnownStripePriceIdsForKiloPass()[0]!; +const KILOCLAW_PRICE_ID = getKnownStripePriceIdsForKiloClaw()[0]!; +const SEAT_PRODUCT_ID = [...SEAT_PRODUCT_IDS][0]!; +const SEAT_PRICE_ID = process.env.STRIPE_TEAMS_MONTHLY_PRICE_ID!; +const ACTIVATION = SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + +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; +} + +function invoiceLine( + overrides: Partial & { + amount?: number; + pricing?: Stripe.InvoiceLineItem['pricing']; + metadata?: Stripe.Metadata; + } +): Stripe.InvoiceLineItem { + return { + id: overrides.id ?? 'il_test', + object: 'line_item', + amount: overrides.amount ?? 4_900, + currency: 'usd', + description: 'line', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: 'in_test', + livemode: false, + metadata: overrides.metadata ?? {}, + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: null, + pricing: overrides.pricing ?? null, + quantity: 1, + subscription: null, + taxes: null, + ...overrides, + } as Stripe.InvoiceLineItem; +} + +function pricedLine(priceId: string, amount: number, extra: Partial = {}) { + return invoiceLine({ + amount, + pricing: { + type: 'price_details', + unit_amount_decimal: String(amount), + price_details: { + price: priceId, + product: extra.pricing?.price_details?.product ?? 'prod_pass', + }, + }, + ...extra, + }); +} + +function personalMetadata(): Stripe.Metadata { + return { + type: 'kilo-pass', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }; +} + +function draftInvoice( + overrides: { + id?: string; + created?: number; + status?: Stripe.Invoice.Status; + metadata?: Stripe.Metadata; + lines?: Stripe.InvoiceLineItem[]; + has_more?: boolean; + customer?: string; + parent?: Stripe.Invoice['parent']; + } = {} +): Stripe.Invoice { + const { lines: lineItems, has_more, metadata, ...invoiceOverrides } = overrides; + const lines = lineItems ?? [pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass' })]; + const resolvedMetadata = metadata ?? personalMetadata(); + return { + id: overrides.id ?? 'in_test', + object: 'invoice', + created: overrides.created ?? ACTIVATION, + status: overrides.status ?? 'draft', + currency: 'usd', + customer: 'cus_1', + amount_paid: 0, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: resolvedMetadata, + subscription: 'sub_1', + }, + }, + ...invoiceOverrides, + metadata: resolvedMetadata, + lines: { + object: 'list', + data: lines, + has_more: has_more ?? false, + url: '/v1/invoices/in_test/lines', + }, + } as Stripe.Invoice; +} + +function stripeClient( + overrides: Partial & { + createInvoiceItem?: KiloPassInvoiceCreatedStripe['invoiceItems']; + listLineItems?: KiloPassInvoiceCreatedStripe['invoices']['listLineItems']; + } = {} +): KiloPassInvoiceCreatedStripe { + return { + prices: { + retrieve: async id => ({ id, tax_behavior: 'exclusive' }), + }, + invoices: { + listLineItems: + overrides.listLineItems ?? + (async () => ({ + data: [], + has_more: false, + })), + }, + invoiceItems: overrides.createInvoiceItem ?? { + create: async () => ({ id: 'ii_fee', amount: 245 }), + }, + ...overrides, + }; +} + +function deps( + overrides: Partial = {} +): KiloPassInvoiceCreatedDependencies { + return { + now: new Date(ACTIVATION * 1000), + sendAlert: jest.fn(async () => undefined), + ...overrides, + }; +} + +describe('handleKiloPassInvoiceCreated', () => { + test('activation minus one second is pre_activation and does not attach a fee', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ created: ACTIVATION - 1 }), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(result.status).toBe('assessed'); + expect(result.assessment).toMatchObject({ + outcome: 'pre_activation', + expectedFeeMinor: 245, + chargedFeeMinor: 0, + }); + expect(create).not.toHaveBeenCalled(); + }); + + test('activation exact second attaches the fee when tax is available', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ created: ACTIVATION }), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(result.status).toBe('charged'); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 245, + stripeInvoiceFeeLineItemId: 'ii_fee', + eligibilityCreatedAt: new Date(ACTIVATION * 1000).toISOString(), + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('discards embedded lines and paginates when has_more is true', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const listLineItems = jest + .fn() + .mockResolvedValueOnce({ + data: [pricedLine(SEAT_PRICE_ID, 72_000, { id: 'il_seat_page' })], + has_more: true, + }) + .mockResolvedValueOnce({ + data: [pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass_page' })], + has_more: false, + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + has_more: true, + lines: [pricedLine(SEAT_PRICE_ID, 1, { id: 'il_stale_embedded' })], + }), + stripe: stripeClient({ listLineItems, createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(listLineItems).toHaveBeenCalled(); + expect(result.assessment?.eligibleSubtotalMinor).toBe(4_900); + expect(result.assessment?.chargedFeeMinor).toBe(245); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('mixed seat and Kilo Pass invoices charge only the net Kilo Pass subtotal', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async (params: Stripe.InvoiceItemCreateParams) => { + expect(params.amount).toBe(245); + return { id: 'ii_fee', amount: 245 }; + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + metadata: { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }, + lines: [ + pricedLine(SEAT_PRICE_ID, 72_000, { + id: 'il_seat', + pricing: { + type: 'price_details', + unit_amount_decimal: '72000', + price_details: { price: SEAT_PRICE_ID, product: SEAT_PRODUCT_ID }, + }, + }), + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass' }), + ], + }), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + getOrganizationPurchaseChannel: async () => 'self_serve', + }), + }); + + expect(result.assessment).toMatchObject({ + flow: 'organization_kilo_pass', + eligibleSubtotalMinor: 4_900, + chargedFeeMinor: 245, + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('skips auto-top-up and excluded invoices but records eligible non-draft leakage', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const client = stripeClient({ createInvoiceItem: { create } }); + + const auto = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ metadata: { type: 'auto-topup' } }), + stripe: client, + store, + deps: deps(), + }); + const orgAuto = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + id: 'in_org_auto', + metadata: { type: 'org-auto-topup' }, + }), + stripe: client, + store, + deps: deps(), + }); + const notDraft = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ id: 'in_open', status: 'open' }), + stripe: client, + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + const seats = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + id: 'in_seats', + metadata: {}, + lines: [ + pricedLine(SEAT_PRICE_ID, 72_000, { + id: 'il_seat_only', + pricing: { + type: 'price_details', + unit_amount_decimal: '72000', + price_details: { price: SEAT_PRICE_ID, product: SEAT_PRODUCT_ID }, + }, + }), + ], + }), + stripe: client, + store, + deps: deps(), + }); + const claw = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + id: 'in_claw', + metadata: {}, + lines: [pricedLine(KILOCLAW_PRICE_ID, 20_000, { id: 'il_claw' })], + }), + stripe: client, + store, + deps: deps(), + }); + const storeManaged = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ id: 'in_store' }), + stripe: client, + store, + deps: deps({ isStoreManaged: async () => true }), + }); + const manual = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + id: 'in_manual', + metadata: { + type: 'kilo-pass-org', + organizationId: 'org_manual', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }, + }), + stripe: client, + store, + deps: deps({ getOrganizationPurchaseChannel: async () => 'manual' }), + }); + const unknown = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + id: 'in_unknown', + metadata: {}, + lines: [invoiceLine({ id: 'il_unknown', amount: 1_000 })], + }), + stripe: client, + store, + deps: deps(), + }); + + expect(auto.assessment).toBeNull(); + expect(orgAuto.assessment).toBeNull(); + expect(notDraft).toMatchObject({ + status: 'missed', + assessment: { failureCode: 'invoice_not_draft', outcome: 'missed' }, + }); + expect(seats.assessment).toBeNull(); + expect(claw.assessment).toBeNull(); + expect(storeManaged.assessment).toBeNull(); + expect(manual.assessment).toBeNull(); + expect(unknown.assessment).toBeNull(); + expect(create).not.toHaveBeenCalled(); + expect(await store.findByAssessmentKey(createInvoiceServiceFeeAssessmentKey('in_test'))).toBe( + null + ); + }); + + test('exact organization exemption at invoice.created omits the fee line', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const findEffectiveExemption: NonNullable< + KiloPassInvoiceCreatedDependencies['findEffectiveExemption'] + > = jest.fn(async () => ({ id: 'hist_exempt', isExempt: true })); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ + metadata: { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }, + }), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + findEffectiveExemption, + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + getOrganizationPurchaseChannel: async () => 'self_serve', + }), + }); + + expect(findEffectiveExemption).toHaveBeenCalledWith('org_1', new Date(ACTIVATION * 1000)); + expect(result.assessment).toMatchObject({ + outcome: 'exempt', + exemptionId: 'hist_exempt', + expectedFeeMinor: 245, + chargedFeeMinor: 0, + }); + expect(create).not.toHaveBeenCalled(); + }); + + test('catch-all alert preserves organization flow from available metadata', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const metadata: Stripe.Metadata = { + type: 'kilo-pass-org', + organizationId: 'org_1', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }; + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ metadata, has_more: true }), + stripe: stripeClient({ + listLineItems: async () => { + throw new Error('Stripe line listing unavailable'); + }, + }), + store, + deps: deps({ sendAlert }), + }); + + expect(result.status).toBe('skipped'); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ + flow: 'organization_kilo_pass', + organizationId: 'org_1', + kiloUserId: 'user_1', + }) + ); + }); + + test('tax resolution failure persists missed, alerts, and returns normally', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice(), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + sendAlert, + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + }), + }); + + 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 available tax creates one non-discountable fee item with exact metadata and mirrored tax', async () => { + const store = createMemoryAssessmentStore(); + 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', + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: createInvoiceServiceFeeAssessmentKey('in_test'), + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + }); + return { id: 'ii_fee', amount: 245 }; + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice(), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ + source: 'price', + taxBehavior: 'exclusive', + }), + }), + }); + + expect(result.status).toBe('charged'); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 245, + stripeInvoiceId: 'in_test', + stripeInvoiceFeeLineItemId: 'ii_fee', + }); + expect(create).toHaveBeenCalledTimes(1); + }); + + test('reuses a prepared synchronous assessment key without racing its attachment owner', async () => { + const store = createMemoryAssessmentStore(); + const preparedKey = 'seat-capacity:sub_org:1788220800:12'; + const organizationId = '00000000-0000-4000-8000-000000000001'; + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey: preparedKey, + flow: 'organization_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(ACTIVATION * 1000), + eligibleSubtotalMinor: 4_900, + kiloUserId: 'user_1', + organizationId, + stripeCustomerId: 'cus_1', + }); + await upsertServiceFeeAssessment({ store, decision }); + const create = jest.fn(async () => ({ id: 'ii_prepared', amount: 245 })); + const invoice = draftInvoice({ + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: { + type: 'kilo-pass-org', + organizationId, + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + serviceFeeAssessmentKey: preparedKey, + }, + subscription: 'sub_1', + }, + }, + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice, + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(result).toMatchObject({ status: 'assessed' }); + expect(result.assessment?.assessmentKey).toBe(preparedKey); + expect( + await store.findByAssessmentKey(createInvoiceServiceFeeAssessmentKey('in_test')) + ).toBeNull(); + expect(create).not.toHaveBeenCalled(); + }); + + test('does not attach a non-invoice synchronous key when its row is not visible yet', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_race', amount: 245 })); + const invoice = draftInvoice({ + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: { + ...personalMetadata(), + serviceFeeAssessmentKey: 'org-checkout:not-visible-yet', + }, + subscription: 'sub_1', + }, + }, + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice, + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(result).toMatchObject({ status: 'skipped', assessment: null }); + expect(create).not.toHaveBeenCalled(); + }); + + test('duplicate retry does not attach a second fee and missed is never recollected', async () => { + const store = createMemoryAssessmentStore(); + const create = jest.fn(async () => ({ id: 'ii_fee', amount: 245 })); + const availableTax = deps({ + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }); + + const first = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice(), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: availableTax, + }); + const second = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice(), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: availableTax, + }); + + expect(first.assessment?.assessmentKey).toBe(second.assessment?.assessmentKey); + expect(create).toHaveBeenCalledTimes(1); + + const missedStore = createMemoryAssessmentStore(); + const missedCreate = jest.fn(async () => ({ id: 'ii_later', amount: 245 })); + const missed = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ id: 'in_missed' }), + stripe: stripeClient({ createInvoiceItem: { create: missedCreate } }), + store: missedStore, + deps: deps({ + resolveTaxInput: async () => { + throw new Error(SERVICE_FEE_FAILURE_APPLICATION); + }, + }), + }); + const retry = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice({ id: 'in_missed' }), + stripe: stripeClient({ createInvoiceItem: { create: missedCreate } }), + store: missedStore, + deps: availableTax, + }); + + expect(missed.assessment?.outcome).toBe('missed'); + expect(retry.assessment?.outcome).toBe('missed'); + expect(missedCreate).not.toHaveBeenCalled(); + }); + + test('attach failure and Slack failure both fail open', async () => { + const store = createMemoryAssessmentStore(); + const sendAlert = jest.fn(async () => { + throw new Error('slack_down'); + }); + const create = jest.fn(async () => { + throw new Error('Stripe invoice item failed'); + }); + + const result = await handleKiloPassInvoiceCreated({ + invoice: draftInvoice(), + stripe: stripeClient({ createInvoiceItem: { create } }), + store, + deps: deps({ + sendAlert, + resolveTaxInput: async () => ({ source: 'inline_inherit' }), + }), + }); + + expect(result.status).toBe('missed'); + expect(result.assessment).toMatchObject({ + outcome: 'missed', + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + chargedFeeMinor: 0, + }); + expect(sendAlert).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/service-fees/invoice-created.ts b/apps/web/src/lib/service-fees/invoice-created.ts new file mode 100644 index 0000000000..fa40c912f9 --- /dev/null +++ b/apps/web/src/lib/service-fees/invoice-created.ts @@ -0,0 +1,506 @@ +import 'server-only'; + +import type Stripe from 'stripe'; + +import { getOrganizationKiloPassMetadata } from '@/lib/kilo-pass-org/stripe-metadata'; +import { getKiloPassMetadataFromStripeMetadata } from '@/lib/kilo-pass/stripe-handlers-metadata'; +import { + sendMissedServiceFeeAlert, + type MissedServiceFeeAlertInput, +} from '@/lib/service-fees/alerts'; +import { + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + prepareServiceFeeAssessmentDecision, + upsertServiceFeeAssessment, + type EffectiveExemptionLookup, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, + type ServiceFeeStripeIds, +} from '@/lib/service-fees/assessments'; +import { + buildAutoTopUpServiceFeeInvoiceItem, + createInvoiceServiceFeeAssessmentKey, + isKiloOwnedAutoTopUpInvoice, + SERVICE_FEE_FAILURE_APPLICATION, +} from '@/lib/service-fees/checkout'; +import { + isEligibleKiloPassInvoiceLine, + isServiceFeeInvoiceLine, + listAllInvoiceLineItems, + sumEligibleKiloPassSubtotalMinor, + type InvoiceLineItemListClient, +} from '@/lib/service-fees/stripe-lines'; +import { + resolveServiceFeeTaxInput, + type ServiceFeeTaxInput, + type ServiceFeeTaxPrincipal, + type StripePriceTaxReader, +} from '@/lib/service-fees/tax'; +import { SERVICE_FEE_SUPPORTED_CURRENCY, type ServiceFeeFlow } from '@/lib/service-fees/types'; + +export type KiloPassInvoiceCreatedStripe = InvoiceLineItemListClient & { + prices?: StripePriceTaxReader['prices']; + invoiceItems?: { + create( + params: Stripe.InvoiceItemCreateParams + ): Promise>; + }; + subscriptions?: { + retrieve(id: string): Promise; + }; +}; + +export type KiloPassInvoiceCreatedDependencies = { + now?: Date; + findEffectiveExemption?: EffectiveExemptionLookup; + resolveTaxInput?: (params: { + principal: ServiceFeeTaxPrincipal; + stripe?: StripePriceTaxReader; + }) => Promise; + sendAlert?: (input: MissedServiceFeeAlertInput) => Promise; + getOrganizationPurchaseChannel?: ( + organizationId: string + ) => Promise<'self_serve' | 'manual' | null>; + isStoreManaged?: (input: { + invoice: Stripe.Invoice; + subscription: Stripe.Subscription | null; + }) => Promise; + ownsSynchronousAttachment?: boolean; +}; + +export type KiloPassInvoiceCreatedResult = { + status: 'skipped' | 'assessed' | 'charged' | 'missed'; + assessment: ServiceFeeAssessmentRecord | null; +}; + +type ClassifiedKiloPassInvoice = { + flow: Extract; + kiloUserId?: string; + organizationId?: string; +}; + +/** + * Attach at most one Kilo Pass service-fee item to a Stripe-owned draft invoice. + * Kilo-owned auto-top-up invoices, non-draft invoices, and excluded products are + * skipped. Fee-domain failures persist `missed` and return normally. + */ +export async function handleKiloPassInvoiceCreated(params: { + invoice: Stripe.Invoice; + stripe: KiloPassInvoiceCreatedStripe; + store: ServiceFeeAssessmentStore; + deps?: KiloPassInvoiceCreatedDependencies; +}): Promise { + const deps = params.deps ?? {}; + const now = deps.now ?? new Date(); + + if (isKiloOwnedAutoTopUpInvoice(params.invoice)) { + return skipped(); + } + if (!params.invoice.id) { + return skipped(); + } + + try { + const subscription = await loadSubscription(params.invoice, params.stripe); + if (await deps.isStoreManaged?.({ invoice: params.invoice, subscription })) { + return skipped(); + } + + const lines = await listAllInvoiceLineItems({ + invoice: params.invoice, + stripe: params.stripe, + }); + const classified = await classifyEligibleKiloPassInvoice({ + invoice: params.invoice, + lines, + subscription, + getOrganizationPurchaseChannel: deps.getOrganizationPurchaseChannel, + }); + if (!classified) { + return skipped(); + } + + const assessmentKey = resolveInvoiceAssessmentKey(params.invoice, subscription, lines); + const existingFeeLine = findAssessmentFeeLine(lines, assessmentKey); + const existing = await params.store.findByAssessmentKey(assessmentKey); + if ( + !assessmentKey.startsWith('invoice:') && + !existingFeeLine && + !deps.ownsSynchronousAttachment + ) { + return existing ? { status: 'assessed', assessment: existing } : skipped(); + } + if (existing && (existing.outcome === 'charged' || existing.outcome === 'missed')) { + return { + status: existing.outcome, + assessment: existing, + }; + } + const eligibleSubtotalMinor = sumEligibleKiloPassSubtotalMinor({ + lines, + currency: params.invoice.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + subscription, + }); + const eligibilityCreatedAt = unixToDate(params.invoice.created) ?? now; + const stripeIds = invoiceStripeIds(params.invoice); + + const decision = await prepareServiceFeeAssessmentDecision( + { + assessmentKey, + flow: classified.flow, + currency: params.invoice.currency || SERVICE_FEE_SUPPORTED_CURRENCY, + eligibilityCreatedAt, + eligibleSubtotalMinor, + kiloUserId: classified.kiloUserId, + organizationId: classified.organizationId, + stripeCustomerId: stripeIds.stripeCustomerId ?? undefined, + }, + { findEffectiveExemption: deps.findEffectiveExemption } + ); + + const record = await upsertServiceFeeAssessment({ + store: params.store, + decision, + stripeIds, + now, + }); + + if (record.outcome !== 'pending') { + return { status: 'assessed', assessment: record }; + } + + if (existingFeeLine) { + const charged = await markServiceFeeAssessmentCharged({ + store: params.store, + assessmentKey, + chargedFeeMinor: Math.max(0, existingFeeLine.amount), + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: existingFeeLine.id, + }, + now, + }); + return { status: 'charged', assessment: charged }; + } + + if (params.invoice.status !== 'draft') { + return persistMissed({ + store: params.store, + assessmentKey, + stripeIds, + failureCode: 'invoice_not_draft', + record, + deps, + now, + }); + } + + const resolveTax = deps.resolveTaxInput ?? resolveServiceFeeTaxInput; + let taxInput: ServiceFeeTaxInput; + try { + taxInput = await resolveTax({ + principal: taxPrincipalFromLines(lines, subscription), + stripe: params.stripe.prices ? { prices: params.stripe.prices } : undefined, + }); + } catch { + return persistMissed({ + store: params.store, + assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + } + if (!params.stripe.invoiceItems?.create || !stripeIds.stripeCustomerId) { + return persistMissed({ + store: params.store, + assessmentKey, + stripeIds, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + record, + deps, + now, + }); + } + + try { + const item = await params.stripe.invoiceItems.create( + buildAutoTopUpServiceFeeInvoiceItem({ + assessmentKey, + invoiceId: params.invoice.id, + customerId: stripeIds.stripeCustomerId, + feeMinor: decision.expectedFeeMinor, + taxInput, + }) + ); + const charged = await markServiceFeeAssessmentCharged({ + store: params.store, + assessmentKey, + chargedFeeMinor: decision.expectedFeeMinor, + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: item.id, + }, + now, + }); + return { status: 'charged', assessment: charged }; + } catch (error) { + return persistMissed({ + store: params.store, + assessmentKey, + stripeIds, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + record, + deps, + now, + }); + } + } catch (error) { + const metadataSources = collectMetadata(params.invoice, null); + const organization = firstPresent(metadataSources, getOrganizationKiloPassMetadata); + const personal = firstPresent(metadataSources, getKiloPassMetadataFromStripeMetadata); + await alertSafely({ + assessmentKey: params.invoice.id + ? createInvoiceServiceFeeAssessmentKey(params.invoice.id) + : 'invoice:unknown', + flow: organization ? 'organization_kilo_pass' : 'personal_kilo_pass', + kiloUserId: organization?.kiloUserId ?? personal?.kiloUserId, + organizationId: organization?.organizationId, + stripeInvoiceId: params.invoice.id, + eligibleSubtotalMinor: 0, + expectedFeeMinor: 0, + failureCode: failureCodeFromUnknown(error, SERVICE_FEE_FAILURE_APPLICATION), + deps, + now, + }); + return skipped(); + } +} + +async function classifyEligibleKiloPassInvoice(input: { + invoice: Stripe.Invoice; + lines: readonly Stripe.InvoiceLineItem[]; + subscription: Stripe.Subscription | null; + getOrganizationPurchaseChannel?: KiloPassInvoiceCreatedDependencies['getOrganizationPurchaseChannel']; +}): Promise { + const metadataSources = collectMetadata(input.invoice, input.subscription); + const organization = firstPresent(metadataSources, getOrganizationKiloPassMetadata); + const personal = firstPresent(metadataSources, getKiloPassMetadataFromStripeMetadata); + const hasEligibleLine = input.lines.some(line => + isEligibleKiloPassInvoiceLine(line, input.subscription) + ); + + if (organization) { + const channel = input.getOrganizationPurchaseChannel + ? await input.getOrganizationPurchaseChannel(organization.organizationId) + : 'self_serve'; + if (channel !== 'self_serve') { + return null; + } + if (!hasEligibleLine) { + return null; + } + return { + flow: 'organization_kilo_pass', + organizationId: organization.organizationId, + kiloUserId: organization.kiloUserId, + }; + } + + if (!hasEligibleLine) { + return null; + } + if (!personal?.kiloUserId) { + return null; + } + return { + flow: 'personal_kilo_pass', + kiloUserId: personal.kiloUserId, + }; +} + +function collectMetadata( + invoice: Stripe.Invoice, + subscription: Stripe.Subscription | null +): Array { + return [invoice.metadata, invoice.parent?.subscription_details?.metadata, subscription?.metadata]; +} + +function firstPresent( + sources: Array, + read: (metadata: Stripe.Metadata | null | undefined) => T | null +): T | null { + for (const source of sources) { + const value = read(source); + if (value) return value; + } + return null; +} + +function resolveInvoiceAssessmentKey( + invoice: Stripe.Invoice, + subscription: Stripe.Subscription | null, + lines: readonly Stripe.InvoiceLineItem[] +): string { + const metadataKey = collectMetadata(invoice, subscription) + .map(metadata => metadata?.serviceFeeAssessmentKey?.trim()) + .find(Boolean); + if (metadataKey) return metadataKey; + + const feeLineKey = lines + .filter(isServiceFeeInvoiceLine) + .map(line => line.metadata?.serviceFeeAssessmentKey?.trim()) + .find(Boolean); + return feeLineKey || createInvoiceServiceFeeAssessmentKey(invoice.id); +} + +function findAssessmentFeeLine( + lines: readonly Stripe.InvoiceLineItem[], + assessmentKey: string | null +): Stripe.InvoiceLineItem | undefined { + return lines.find(line => { + if (!isServiceFeeInvoiceLine(line)) return false; + if (!assessmentKey) return true; + return line.metadata?.serviceFeeAssessmentKey === assessmentKey; + }); +} + +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' }; +} + +async function loadSubscription( + invoice: Stripe.Invoice, + stripe: KiloPassInvoiceCreatedStripe +): Promise { + const reference = invoice.parent?.subscription_details?.subscription; + if (!reference) return null; + if (typeof reference !== 'string') return reference; + if (!stripe.subscriptions?.retrieve) return null; + try { + return await stripe.subscriptions.retrieve(reference); + } catch { + return null; + } +} + +async function persistMissed(params: { + store: ServiceFeeAssessmentStore; + assessmentKey: string; + stripeIds: ServiceFeeStripeIds; + failureCode: string; + record: ServiceFeeAssessmentRecord; + deps: KiloPassInvoiceCreatedDependencies; + now: Date; +}): Promise { + try { + const missed = await markServiceFeeAssessmentMissed({ + store: params.store, + assessmentKey: params.assessmentKey, + failureCode: params.failureCode, + stripeIds: params.stripeIds, + now: params.now, + }); + await alertSafely({ + 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 { status: 'missed', assessment: missed }; + } catch (error) { + await alertSafely({ + assessmentKey: params.assessmentKey, + flow: params.record.flow, + kiloUserId: params.record.kiloUserId, + organizationId: params.record.organizationId, + stripeInvoiceId: params.stripeIds.stripeInvoiceId, + eligibleSubtotalMinor: params.record.eligibleSubtotalMinor, + expectedFeeMinor: params.record.expectedFeeMinor, + failureCode: failureCodeFromUnknown(error, params.failureCode), + deps: params.deps, + now: params.now, + }); + return { status: 'missed', assessment: params.record }; + } +} + +async function alertSafely(params: { + assessmentKey: string; + flow: ServiceFeeFlow; + kiloUserId?: string | null; + organizationId?: string | null; + stripeInvoiceId?: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + failureCode: string; + deps: KiloPassInvoiceCreatedDependencies; + 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 invoice-created outcome. + } +} + +function invoiceStripeIds(invoice: Stripe.Invoice): ServiceFeeStripeIds { + return { + stripeInvoiceId: invoice.id, + stripeCustomerId: customerId(invoice.customer), + }; +} + +function customerId( + 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; +} + +function unixToDate(value: number | null | undefined): Date | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null; + return new Date(value * 1000); +} + +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 skipped(): KiloPassInvoiceCreatedResult { + return { status: 'skipped', assessment: null }; +} + +export { SERVICE_FEE_FAILURE_APPLICATION }; diff --git a/apps/web/src/lib/service-fees/organization-exemptions.test.ts b/apps/web/src/lib/service-fees/organization-exemptions.test.ts new file mode 100644 index 0000000000..8c63acf492 --- /dev/null +++ b/apps/web/src/lib/service-fees/organization-exemptions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from '@jest/globals'; + +import { + OrganizationServiceFeeExemptionError, + getEffectiveOrganizationServiceFeeExemption, + getOrganizationServiceFeeExemption, + normalizeOrganizationExemptionTimestamp, + normalizeOrganizationServiceFeeExemptionReason, + organizationServiceFeeExemptionLockKey, + setOrganizationServiceFeeExemption, + type OrganizationServiceFeeExemptionRecord, + type OrganizationServiceFeeExemptionStore, +} from '@/lib/service-fees/organization-exemptions'; + +function createMemoryExemptionStore(options?: { + activeOrganizationIds?: Iterable; +}): OrganizationServiceFeeExemptionStore & { + calls: string[]; +} { + const active = new Set(options?.activeOrganizationIds ?? []); + const history: OrganizationServiceFeeExemptionRecord[] = []; + const calls: string[] = []; + + const store: OrganizationServiceFeeExemptionStore & { calls: string[] } = { + calls, + async transact(fn) { + calls.push('transact'); + return fn(store); + }, + async lockOrganization(organizationId) { + calls.push(`lock:${organizationId}`); + }, + async findActiveOrganization(organizationId) { + calls.push(`findOrg:${organizationId}`); + return active.has(organizationId) ? { id: organizationId } : null; + }, + async findAtOrBefore(organizationId, at) { + const atMs = at.getTime(); + return ( + history + .filter( + row => + row.organizationId === organizationId && new Date(row.createdAt).getTime() <= atMs + ) + .at(-1) ?? null + ); + }, + async listNewestFirst(organizationId) { + return history + .filter(row => row.organizationId === organizationId) + .slice() + .reverse(); + }, + async getCurrent(organizationId) { + return history.filter(row => row.organizationId === organizationId).at(-1) ?? null; + }, + async insert(record) { + calls.push(`insert:${record.id}`); + history.push(record); + return record; + }, + }; + + return store; +} + +const ORG_A = '11111111-1111-4111-8111-111111111111'; +const ORG_B = '22222222-2222-4222-8222-222222222222'; + +describe('organization service fee exemptions', () => { + test('normalizes production timestamptz text and trims reasons', () => { + expect(normalizeOrganizationExemptionTimestamp('2026-04-29 01:16:12.945+00')).toBe( + '2026-04-29T01:16:12.945Z' + ); + expect(normalizeOrganizationServiceFeeExemptionReason(' granted for nonprofit ')).toBe( + 'granted for nonprofit' + ); + expect(() => normalizeOrganizationServiceFeeExemptionReason('no')).toThrow( + OrganizationServiceFeeExemptionError + ); + expect(() => normalizeOrganizationServiceFeeExemptionReason('x'.repeat(501))).toThrow( + /3 to 500/ + ); + expect(organizationServiceFeeExemptionLockKey(ORG_A)).toBe(`service-fee-exemption:${ORG_A}`); + }); + + test('reads the latest history row at or before the eligibility instant', async () => { + const store = createMemoryExemptionStore({ activeOrganizationIds: [ORG_A] }); + await setOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + isExempt: true, + reason: 'historical grant', + changedByKiloUserId: 'admin_1', + now: new Date('2026-08-01T00:00:00.000Z'), + }); + await setOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + isExempt: false, + reason: 'revoked after review', + changedByKiloUserId: 'admin_2', + now: new Date('2026-10-01T00:00:00.000Z'), + }); + + const atGrant = await getEffectiveOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + at: new Date('2026-09-01T00:00:00.000Z'), + }); + const atRevoke = await getEffectiveOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + at: new Date('2026-10-01T00:00:00.000Z'), + }); + const otherOrg = await getEffectiveOrganizationServiceFeeExemption({ + store, + organizationId: ORG_B, + at: new Date('2026-09-01T00:00:00.000Z'), + }); + + expect(atGrant).toMatchObject({ isExempt: true, reason: 'historical grant' }); + expect(atRevoke).toMatchObject({ isExempt: false, reason: 'revoked after review' }); + expect(otherOrg).toBeNull(); + }); + + test('set appends each change and allows the same state with a new reason', async () => { + const store = createMemoryExemptionStore({ activeOrganizationIds: [ORG_A] }); + + const first = await setOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + isExempt: true, + reason: ' initial grant ', + changedByKiloUserId: 'admin_1', + now: new Date('2026-09-02T00:00:00.000Z'), + }); + const second = await setOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + isExempt: true, + reason: 'renewed with new documentation', + changedByKiloUserId: 'admin_2', + now: new Date('2026-09-02T00:00:00.000Z'), + }); + + const view = await getOrganizationServiceFeeExemption({ store, organizationId: ORG_A }); + + expect(first.current.isExempt).toBe(true); + expect(first.current.reason).toBe('initial grant'); + expect(second.current.id).toBe(second.history.id); + expect(second.current.createdAt).toBe('2026-09-02T00:00:00.001Z'); + expect(view.history.map(row => row.reason)).toEqual([ + 'renewed with new documentation', + 'initial grant', + ]); + expect(view.current?.reason).toBe('renewed with new documentation'); + expect(store.calls.filter(call => call.startsWith('lock:'))).toHaveLength(2); + expect(store.calls.filter(call => call.startsWith('insert:'))).toHaveLength(2); + }); + + test('rejects missing or deleted organizations', async () => { + const store = createMemoryExemptionStore({ activeOrganizationIds: [] }); + + await expect( + setOrganizationServiceFeeExemption({ + store, + organizationId: ORG_A, + isExempt: true, + reason: 'should not persist', + changedByKiloUserId: 'admin_1', + }) + ).rejects.toMatchObject({ code: 'organization_not_found' }); + + const view = await getOrganizationServiceFeeExemption({ store, organizationId: ORG_A }); + expect(view.current).toBeNull(); + expect(view.history).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/service-fees/organization-exemptions.ts b/apps/web/src/lib/service-fees/organization-exemptions.ts new file mode 100644 index 0000000000..0494ce1392 --- /dev/null +++ b/apps/web/src/lib/service-fees/organization-exemptions.ts @@ -0,0 +1,185 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; + +import { sql } from 'drizzle-orm'; + +export const ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH = 3; +export const ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH = 500; +export const ORGANIZATION_SERVICE_FEE_EXEMPTION_LOCK_PREFIX = 'service-fee-exemption:'; + +export type OrganizationServiceFeeExemptionErrorCode = 'organization_not_found' | 'invalid_reason'; + +export class OrganizationServiceFeeExemptionError extends Error { + readonly name = 'OrganizationServiceFeeExemptionError'; + + constructor( + readonly code: OrganizationServiceFeeExemptionErrorCode, + message: string + ) { + super(message); + } +} + +export type OrganizationServiceFeeExemptionRecord = { + id: string; + organizationId: string; + isExempt: boolean; + reason: string; + changedByKiloUserId: string | null; + createdAt: string; +}; + +export type OrganizationServiceFeeExemptionView = { + current: OrganizationServiceFeeExemptionRecord | null; + history: OrganizationServiceFeeExemptionRecord[]; +}; + +export type ActiveOrganizationRef = { + id: string; +}; + +/** + * Drizzle-compatible executor used for organization-scoped advisory locks and + * optional transactional wrappers. Matches `db` / `tx.execute` / `tx.transaction`. + */ +export type OrganizationExemptionExecutor = { + execute: (query: unknown) => Promise; + transaction?: (fn: (tx: OrganizationExemptionExecutor) => Promise) => Promise; +}; + +export type OrganizationServiceFeeExemptionStore = { + transact(fn: (store: OrganizationServiceFeeExemptionStore) => Promise): Promise; + lockOrganization(organizationId: string): Promise; + findActiveOrganization(organizationId: string): Promise; + findAtOrBefore( + organizationId: string, + at: Date + ): Promise; + listNewestFirst(organizationId: string): Promise; + getCurrent(organizationId: string): Promise; + insert( + record: OrganizationServiceFeeExemptionRecord + ): Promise; +}; + +export function organizationServiceFeeExemptionLockKey(organizationId: string): string { + return `${ORGANIZATION_SERVICE_FEE_EXEMPTION_LOCK_PREFIX}${organizationId}`; +} + +export async function acquireOrganizationServiceFeeExemptionLock( + executor: Pick, + organizationId: string +): Promise { + await executor.execute( + sql`SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${organizationServiceFeeExemptionLockKey(organizationId)}, 0))` + ); +} + +/** + * Normalize a database timestamptz string (including production shapes such as + * `2026-04-29 01:16:12.945+00`) to UTC ISO-8601 at the API boundary. + */ +export function normalizeOrganizationExemptionTimestamp(value: string | Date): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error('organization exemption timestamp is invalid'); + } + return date.toISOString(); +} + +export function normalizeOrganizationServiceFeeExemptionReason(reason: string): string { + const trimmed = reason.trim(); + if ( + trimmed.length < ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH || + trimmed.length > ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH + ) { + throw new OrganizationServiceFeeExemptionError( + 'invalid_reason', + `reason must be ${ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH} to ${ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH} characters after trimming` + ); + } + return trimmed; +} + +function withNormalizedTimestamp( + record: OrganizationServiceFeeExemptionRecord +): OrganizationServiceFeeExemptionRecord { + return { + ...record, + createdAt: normalizeOrganizationExemptionTimestamp(record.createdAt), + }; +} + +export async function getEffectiveOrganizationServiceFeeExemption(params: { + store: OrganizationServiceFeeExemptionStore; + organizationId: string; + at: Date; +}): Promise { + if (Number.isNaN(params.at.getTime())) { + throw new Error('eligibility timestamp is invalid'); + } + const exemption = await params.store.findAtOrBefore(params.organizationId, params.at); + return exemption ? withNormalizedTimestamp(exemption) : null; +} + +export async function getOrganizationServiceFeeExemption(params: { + store: OrganizationServiceFeeExemptionStore; + organizationId: string; +}): Promise { + const [current, history] = await Promise.all([ + params.store.getCurrent(params.organizationId), + params.store.listNewestFirst(params.organizationId), + ]); + + return { + current: current ? withNormalizedTimestamp(current) : null, + history: history.map(withNormalizedTimestamp), + }; +} + +export async function setOrganizationServiceFeeExemption(params: { + store: OrganizationServiceFeeExemptionStore; + organizationId: string; + isExempt: boolean; + reason: string; + changedByKiloUserId: string | null; + now?: Date; +}): Promise<{ + current: OrganizationServiceFeeExemptionRecord; + history: OrganizationServiceFeeExemptionRecord; +}> { + const reason = normalizeOrganizationServiceFeeExemptionReason(params.reason); + const requestedAt = params.now ?? new Date(); + const requestedAtIso = normalizeOrganizationExemptionTimestamp(requestedAt); + + return params.store.transact(async store => { + await store.lockOrganization(params.organizationId); + + const organization = await store.findActiveOrganization(params.organizationId); + if (!organization) { + throw new OrganizationServiceFeeExemptionError( + 'organization_not_found', + 'organization is missing or deleted' + ); + } + + const current = await store.getCurrent(params.organizationId); + const currentAt = current ? new Date(current.createdAt).getTime() : Number.NEGATIVE_INFINITY; + const createdAt = + requestedAt.getTime() > currentAt ? requestedAtIso : new Date(currentAt + 1).toISOString(); + + const exemption = withNormalizedTimestamp( + await store.insert({ + id: randomUUID(), + organizationId: params.organizationId, + isExempt: params.isExempt, + reason, + changedByKiloUserId: params.changedByKiloUserId, + createdAt, + }) + ); + + return { current: exemption, history: exemption }; + }); +} diff --git a/apps/web/src/lib/service-fees/read-only.ts b/apps/web/src/lib/service-fees/read-only.ts new file mode 100644 index 0000000000..88de4c7d0c --- /dev/null +++ b/apps/web/src/lib/service-fees/read-only.ts @@ -0,0 +1,8 @@ +const MUTATING_FLAGS = new Set(['--execute', '--run-actually', '--write', '--mutate']); + +export function assertServiceFeeAuditReadOnly(args: readonly string[]): void { + const mutating = args.filter(arg => MUTATING_FLAGS.has(arg)); + if (mutating.length > 0) { + throw new Error(`service_fee_audit_is_read_only rejected ${mutating.join(' ')}`); + } +} diff --git a/apps/web/src/lib/service-fees/refunds.test.ts b/apps/web/src/lib/service-fees/refunds.test.ts new file mode 100644 index 0000000000..1ad6a8ad2b --- /dev/null +++ b/apps/web/src/lib/service-fees/refunds.test.ts @@ -0,0 +1,842 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { + markServiceFeeAssessmentCharged, + prepareServiceFeeAssessmentDecision, + sanitizeServiceFeeAssessmentMetadata, + settleServiceFeeAssessment, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, +} from '@/lib/service-fees/assessments'; +import { + SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + applyDeferredServiceFeeRefunds, + buildServiceFeeRefundAllocationMetadata, + buildUnresolvedServiceFeeRefundAllocationAlertText, + calculateServiceFeeRefundIncrement, + observeServiceFeeChargeRefunded, + observeServiceFeeCreditNote, + parseServiceFeeRefundAllocationMetadata, + SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED, + ServiceFeeObservationNotReadyError, + type ServiceFeeChargeRefundObservation, + type ServiceFeeCreditNoteObservation, + type ServiceFeeRefundAssessmentStore, + type ServiceFeeRefundPage, + type ServiceFeeRefundStripeClient, + type UnresolvedServiceFeeRefundAllocationAlertInput, +} from '@/lib/service-fees/refunds'; + +const ACTIVATION = new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000); +const ASSESSMENT_KEY = 'checkout:11111111-1111-4111-8111-111111111111'; +const CHARGE_ID = 'ch_test_1'; +const PAYMENT_INTENT_ID = 'pi_test_1'; +const INVOICE_ID = 'in_test_1'; +const PRODUCT_LINE_ID = 'il_product_1'; +const FEE_LINE_ID = 'il_fee_1'; + +function cloneRecord(record: ServiceFeeAssessmentRecord): ServiceFeeAssessmentRecord { + return { ...record, metadata: { ...record.metadata } }; +} + +function createMemoryRefundStore(): ServiceFeeRefundAssessmentStore { + const rows = new Map(); + const store: ServiceFeeRefundAssessmentStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? cloneRecord(row) : null; + }, + async findByStripeChargeId(stripeChargeId) { + const row = [...rows.values()].find(candidate => candidate.stripeChargeId === stripeChargeId); + return row ? cloneRecord(row) : null; + }, + async findByStripePaymentIntentId(stripePaymentIntentId) { + const row = [...rows.values()].find( + candidate => candidate.stripePaymentIntentId === stripePaymentIntentId + ); + return row ? cloneRecord(row) : null; + }, + async findByStripeInvoiceId(stripeInvoiceId) { + const row = [...rows.values()].find( + candidate => candidate.stripeInvoiceId === stripeInvoiceId + ); + return row ? cloneRecord(row) : null; + }, + async insert(record) { + if (rows.has(record.assessmentKey)) { + throw new Error(`duplicate assessment_key ${record.assessmentKey}`); + } + const copy = cloneRecord(record); + rows.set(record.assessmentKey, copy); + return cloneRecord(copy); + }, + async update(assessmentKey, patch) { + const existing = rows.get(assessmentKey); + if (!existing) throw new Error(`missing ${assessmentKey}`); + const next = { + ...existing, + ...patch, + metadata: + patch.metadata !== undefined + ? sanitizeServiceFeeAssessmentMetadata(patch.metadata) + : { ...existing.metadata }, + }; + rows.set(assessmentKey, next); + return cloneRecord(next); + }, + }; + return store; +} + +async function persistChargedAssessment( + store: ServiceFeeRefundAssessmentStore, + stripeIds: { + stripeChargeId?: string | null; + stripePaymentIntentId?: string | null; + stripeInvoiceId?: string | null; + stripeInvoiceFeeLineItemId?: string | null; + } = { + stripeChargeId: CHARGE_ID, + stripePaymentIntentId: PAYMENT_INTENT_ID, + stripeInvoiceId: INVOICE_ID, + stripeInvoiceFeeLineItemId: FEE_LINE_ID, + } +) { + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey: ASSESSMENT_KEY, + flow: 'personal_top_up', + currency: 'usd', + eligibilityCreatedAt: ACTIVATION, + eligibleSubtotalMinor: 10_000, + kiloUserId: 'user_1', + }); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds, + now: ACTIVATION, + }); + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: ASSESSMENT_KEY, + chargedFeeMinor: 500, + now: ACTIVATION, + }); +} + +async function persistSettledAssessment( + store: ServiceFeeRefundAssessmentStore, + stripeIds: { + stripeChargeId?: string | null; + stripePaymentIntentId?: string | null; + stripeInvoiceId?: string | null; + stripeInvoiceFeeLineItemId?: string | null; + } = { + stripeChargeId: CHARGE_ID, + stripePaymentIntentId: PAYMENT_INTENT_ID, + stripeInvoiceId: INVOICE_ID, + stripeInvoiceFeeLineItemId: FEE_LINE_ID, + } +) { + await persistChargedAssessment(store, stripeIds); + return settleServiceFeeAssessment({ + store, + assessmentKey: ASSESSMENT_KEY, + settledAt: ACTIVATION, + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + now: ACTIVATION, + }); +} + +function refund( + overrides: Partial & Pick +): Stripe.Refund { + return { + object: 'refund', + balance_transaction: null, + charge: CHARGE_ID, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + currency: 'usd', + metadata: {}, + payment_intent: PAYMENT_INTENT_ID, + reason: null, + receipt_number: null, + source_transfer_reversal: null, + status: 'succeeded', + transfer_reversal: null, + ...overrides, + } as Stripe.Refund; +} + +function charge( + overrides: Partial = {} +): ServiceFeeChargeRefundObservation { + return { + id: CHARGE_ID, + amount: 10_500, + amount_refunded: 10_500, + payment_intent: PAYMENT_INTENT_ID, + invoice: INVOICE_ID, + refunds: { data: [], has_more: false }, + metadata: {}, + ...overrides, + }; +} + +function invoiceLine(id: string, amount: number, fee: boolean): Stripe.InvoiceLineItem { + return { + id, + object: 'line_item', + amount, + currency: 'usd', + description: fee ? 'Service fee (5%)' : 'Credits', + discountable: !fee, + discount_amounts: null, + discounts: [], + invoice: INVOICE_ID, + livemode: false, + metadata: fee + ? { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: ASSESSMENT_KEY, + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + } + : {}, + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: null, + pricing: null, + quantity: 1, + subscription: null, + taxes: null, + } as Stripe.InvoiceLineItem; +} + +function creditNoteLine( + id: string, + invoiceLineItem: string, + amount: number +): Stripe.CreditNoteLineItem { + return { + id, + object: 'credit_note_line_item', + amount, + description: null, + discount_amount: 0, + discount_amounts: [], + invoice_line_item: invoiceLineItem, + livemode: false, + pretax_credit_amounts: [], + quantity: 1, + tax_rates: [], + taxes: null, + type: 'invoice_line_item', + unit_amount: amount, + unit_amount_decimal: String(amount), + } as Stripe.CreditNoteLineItem; +} + +function creditNote(params: { + id: string; + lines: Stripe.CreditNoteLineItem[]; + hasMore?: boolean; + status?: Stripe.CreditNote.Status; +}): ServiceFeeCreditNoteObservation { + return { + id: params.id, + invoice: INVOICE_ID, + status: params.status ?? 'issued', + lines: { + object: 'list', + data: params.lines, + has_more: params.hasMore ?? false, + url: `/v1/credit_notes/${params.id}/lines`, + }, + } as ServiceFeeCreditNoteObservation; +} + +function createStripeMock( + options: { + refundPages?: Array<{ data: Stripe.Refund[]; has_more: boolean }>; + creditNotes?: ServiceFeeCreditNoteObservation[]; + creditNoteLinePages?: Record< + string, + Array<{ data: Stripe.CreditNoteLineItem[]; has_more: boolean }> + >; + invoiceLines?: Stripe.InvoiceLineItem[]; + listCreditNotes?: () => Promise>; + } = {} +): ServiceFeeRefundStripeClient & { refunds: { list: jest.Mock; create: jest.Mock } } { + const refundPages = options.refundPages ?? []; + let refundPageIndex = 0; + const listRefunds = jest.fn(async () => { + const page = refundPages[refundPageIndex] ?? { data: [], has_more: false }; + refundPageIndex += 1; + return page; + }); + const createRefund = jest.fn(async () => { + throw new Error('observe helpers must not create Stripe refunds'); + }); + const listCreditNotes = jest.fn( + options.listCreditNotes ?? + (async () => ({ + data: options.creditNotes ?? [], + has_more: false, + })) + ); + + return { + refunds: { + list: listRefunds, + create: createRefund, + }, + creditNotes: { + list: listCreditNotes, + listLineItems: jest.fn(async (id: string) => { + const pages = options.creditNoteLinePages?.[id]; + return pages?.[0] ?? { data: [], has_more: false }; + }), + }, + invoices: { + listLineItems: jest.fn(async () => ({ + data: options.invoiceLines ?? [ + invoiceLine(PRODUCT_LINE_ID, 10_000, false), + invoiceLine(FEE_LINE_ID, 500, true), + ], + has_more: false, + })), + }, + }; +} + +describe('calculateServiceFeeRefundIncrement', () => { + test('returns the ops-doc incremental product plus fee and finishes at the original fee', () => { + const first = calculateServiceFeeRefundIncrement({ + originalProductMinor: 4_900, + originalFeeMinor: 245, + alreadyRefundedProductMinor: 0, + alreadyRefundedFeeMinor: 0, + additionalProductRefundMinor: 2_000, + }); + expect(first).toEqual({ + cumulativeProductRefundMinor: 2_000, + cumulativeFeeRefundMinor: 100, + incrementalProductRefundMinor: 2_000, + incrementalFeeRefundMinor: 100, + incrementalGrossRefundMinor: 2_100, + }); + + const remainder = calculateServiceFeeRefundIncrement({ + originalProductMinor: 4_900, + originalFeeMinor: 245, + alreadyRefundedProductMinor: first.cumulativeProductRefundMinor, + alreadyRefundedFeeMinor: first.cumulativeFeeRefundMinor, + additionalProductRefundMinor: 2_900, + }); + expect(remainder).toEqual({ + cumulativeProductRefundMinor: 4_900, + cumulativeFeeRefundMinor: 245, + incrementalProductRefundMinor: 2_900, + incrementalFeeRefundMinor: 145, + incrementalGrossRefundMinor: 3_045, + }); + }); + + test('uses half-up on the aggregate and never drifts past the remaining fee', () => { + const increment = calculateServiceFeeRefundIncrement({ + originalProductMinor: 10_000, + originalFeeMinor: 500, + alreadyRefundedProductMinor: 0, + alreadyRefundedFeeMinor: 0, + additionalProductRefundMinor: 3_333, + }); + expect(increment.cumulativeFeeRefundMinor).toBe(167); + expect(increment.incrementalGrossRefundMinor).toBe(3_500); + + let alreadyProduct = 0; + let alreadyFee = 0; + for (let step = 0; step < 10_000; step += 1) { + const next = calculateServiceFeeRefundIncrement({ + originalProductMinor: 10_000, + originalFeeMinor: 500, + alreadyRefundedProductMinor: alreadyProduct, + alreadyRefundedFeeMinor: alreadyFee, + additionalProductRefundMinor: 1, + }); + expect(next.incrementalFeeRefundMinor).toBeGreaterThanOrEqual(0); + expect(next.cumulativeFeeRefundMinor).toBeLessThanOrEqual(500); + alreadyProduct = next.cumulativeProductRefundMinor; + alreadyFee = next.cumulativeFeeRefundMinor; + } + expect(alreadyProduct).toBe(10_000); + expect(alreadyFee).toBe(500); + + const capped = calculateServiceFeeRefundIncrement({ + originalProductMinor: 10_000, + originalFeeMinor: 500, + alreadyRefundedProductMinor: 10_000, + alreadyRefundedFeeMinor: 500, + additionalProductRefundMinor: 50, + }); + expect(capped).toEqual({ + cumulativeProductRefundMinor: 10_000, + cumulativeFeeRefundMinor: 500, + incrementalProductRefundMinor: 0, + incrementalFeeRefundMinor: 0, + incrementalGrossRefundMinor: 0, + }); + }); +}); + +describe('observeServiceFeeChargeRefunded', () => { + test('maps a no-amount full remaining refund to full product and fee', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const stripe = createStripeMock(); + const sendAlert = jest.fn( + async (_input: UnresolvedServiceFeeRefundAllocationAlertInput) => undefined + ); + + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 10_500, + refunds: { + data: [refund({ id: 're_full', amount: 10_500 })], + has_more: false, + }, + }), + stripe, + deps: { sendAlert }, + }); + + expect(result.status).toBe('full'); + expect(result.createdStripeRefund).toBe(false); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + refundedProductMinor: 10_000, + refundedFeeMinor: 500, + refundedGrossMinor: 10_500, + metadata: {}, + }); + expect(sendAlert).not.toHaveBeenCalled(); + expect(stripe.refunds.create).not.toHaveBeenCalled(); + }); + + test('records unresolved metadata when credit-note lookup throws and never auto-refunds', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const stripe = createStripeMock({ + listCreditNotes: async () => { + throw new Error('stripe unavailable'); + }, + }); + const sendAlert = jest.fn( + async (_input: UnresolvedServiceFeeRefundAllocationAlertInput) => undefined + ); + + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 2_000, + refunds: { + data: [refund({ id: 're_partial_lookup_failed', amount: 2_000 })], + has_more: false, + }, + }), + stripe, + deps: { sendAlert }, + }); + + expect(result.status).toBe('unresolved'); + expect(result.createdStripeRefund).toBe(false); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + refundedProductMinor: 0, + refundedFeeMinor: 0, + refundedGrossMinor: 2_000, + metadata: { refund_allocation_unresolved: true }, + }); + expect(sendAlert).toHaveBeenCalledTimes(1); + expect(stripe.refunds.create).not.toHaveBeenCalled(); + }); + + test('records unresolved metadata and never auto-refunds an ambiguous partial charge refund', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const stripe = createStripeMock(); + const sendAlert = jest.fn( + async (_input: UnresolvedServiceFeeRefundAllocationAlertInput) => undefined + ); + + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 2_000, + refunds: { + data: [refund({ id: 're_partial', amount: 2_000 })], + has_more: false, + }, + }), + stripe, + deps: { sendAlert }, + }); + + expect(result.status).toBe('unresolved'); + expect(result.createdStripeRefund).toBe(false); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + refundedProductMinor: 0, + refundedFeeMinor: 0, + refundedGrossMinor: 2_000, + metadata: { refund_allocation_unresolved: true }, + }); + expect(sendAlert).toHaveBeenCalledTimes(1); + const alert = sendAlert.mock.calls[0]?.[0]; + expect(alert.assessmentKey).toBe(ASSESSMENT_KEY); + expect(buildUnresolvedServiceFeeRefundAllocationAlertText(alert)).toContain( + `failure_code=${SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED}` + ); + expect(stripe.refunds.create).not.toHaveBeenCalled(); + }); + + test('applies a supplied trusted Kilo allocation on a partial refund', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const stripe = createStripeMock(); + + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 2_100, + refunds: { + data: [refund({ id: 're_kilo', amount: 2_100 })], + has_more: false, + }, + }), + stripe, + trustedAllocation: { productMinor: 2_000, feeMinor: 100 }, + }); + + expect(result.status).toBe('allocated'); + expect(result.assessment).toMatchObject({ + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + refundedGrossMinor: 2_100, + metadata: {}, + }); + expect(stripe.refunds.create).not.toHaveBeenCalled(); + }); + + test('paginates an incomplete refund list before trusting Kilo refund metadata', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const first = refund({ + id: 're_page_1', + amount: 2_100, + metadata: buildServiceFeeRefundAllocationMetadata({ productMinor: 2_000, feeMinor: 100 }), + }); + const second = refund({ + id: 're_page_2', + amount: 2_100, + metadata: buildServiceFeeRefundAllocationMetadata({ productMinor: 2_000, feeMinor: 100 }), + }); + const stripe = createStripeMock({ + refundPages: [ + { data: [first], has_more: true }, + { data: [second], has_more: false }, + ], + }); + + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 4_200, + refunds: { data: [first], has_more: true }, + }), + stripe, + }); + + expect(stripe.refunds.list).toHaveBeenCalledTimes(2); + expect(stripe.refunds.list).toHaveBeenNthCalledWith(1, { + charge: CHARGE_ID, + limit: 100, + }); + expect(stripe.refunds.list).toHaveBeenNthCalledWith(2, { + charge: CHARGE_ID, + limit: 100, + starting_after: 're_page_1', + }); + expect(result.status).toBe('allocated'); + expect(result.assessment).toMatchObject({ + refundedProductMinor: 4_000, + refundedFeeMinor: 200, + refundedGrossMinor: 4_200, + }); + expect(parseServiceFeeRefundAllocationMetadata(first.metadata)).toEqual({ + productMinor: 2_000, + feeMinor: 100, + }); + expect(stripe.refunds.create).not.toHaveBeenCalled(); + }); + + test('resolves the assessment by payment intent or invoice when charge id is not yet linked', async () => { + const byPaymentIntent = createMemoryRefundStore(); + await persistSettledAssessment(byPaymentIntent, { + stripeChargeId: null, + stripePaymentIntentId: PAYMENT_INTENT_ID, + stripeInvoiceId: null, + }); + const piResult = await observeServiceFeeChargeRefunded({ + store: byPaymentIntent, + charge: charge({ + id: 'ch_unlinked', + invoice: null, + amount_refunded: 10_500, + refunds: { data: [refund({ id: 're_pi', amount: 10_500 })], has_more: false }, + }), + }); + expect(piResult.status).toBe('full'); + expect(piResult.assessment?.assessmentKey).toBe(ASSESSMENT_KEY); + + const byInvoice = createMemoryRefundStore(); + await persistSettledAssessment(byInvoice, { + stripeChargeId: null, + stripePaymentIntentId: null, + stripeInvoiceId: INVOICE_ID, + stripeInvoiceFeeLineItemId: FEE_LINE_ID, + }); + const invoiceResult = await observeServiceFeeChargeRefunded({ + store: byInvoice, + charge: charge({ + id: 'ch_invoice_only', + payment_intent: null, + amount_refunded: 10_500, + refunds: { data: [refund({ id: 're_in', amount: 10_500 })], has_more: false }, + }), + }); + expect(invoiceResult.status).toBe('full'); + expect(invoiceResult.assessment?.refundedFeeMinor).toBe(500); + }); + + test('uses credit-note line allocation to resolve a previously unresolved partial refund', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const sendAlert = jest.fn( + async (_input: UnresolvedServiceFeeRefundAllocationAlertInput) => undefined + ); + const note = creditNote({ + id: 'cn_1', + lines: [ + creditNoteLine('cnli_product', PRODUCT_LINE_ID, 2_000), + creditNoteLine('cnli_fee', FEE_LINE_ID, 100), + ], + }); + const stripe = createStripeMock({ + creditNotes: [note], + invoiceLines: [ + invoiceLine(PRODUCT_LINE_ID, 10_000, false), + invoiceLine(FEE_LINE_ID, 500, true), + ], + }); + + const unresolved = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 2_100, + refunds: { data: [refund({ id: 're_open', amount: 2_100 })], has_more: false }, + }), + deps: { sendAlert }, + }); + expect(unresolved.status).toBe('unresolved'); + expect(unresolved.assessment?.metadata.refund_allocation_unresolved).toBe(true); + + const allocated = await observeServiceFeeCreditNote({ + store, + creditNote: note, + stripe, + }); + expect(allocated.status).toBe('allocated'); + expect(allocated.createdStripeRefund).toBe(false); + expect(allocated.assessment).toMatchObject({ + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + refundedGrossMinor: 2_100, + metadata: {}, + }); + + const retry = await observeServiceFeeCreditNote({ + store, + creditNote: note, + stripe, + }); + expect(retry.assessment).toMatchObject({ + refundedProductMinor: 2_000, + refundedFeeMinor: 100, + }); + }); + + test('ignores refunds with no matching assessment', async () => { + const store = createMemoryRefundStore(); + const result = await observeServiceFeeChargeRefunded({ + store, + charge: charge({ + amount_refunded: 10_500, + refunds: { data: [refund({ id: 're_unknown', amount: 10_500 })], has_more: false }, + }), + }); + expect(result).toEqual({ + status: 'ignored', + assessment: null, + refundedGrossMinor: 10_500, + refundedProductMinor: 0, + refundedFeeMinor: 0, + createdStripeRefund: false, + }); + }); + + test('out-of-order full refund before paid persists gross and converges after settlement', async () => { + const store = createMemoryRefundStore(); + await persistChargedAssessment(store); + const fullCharge = charge({ + amount_refunded: 10_500, + refunds: { data: [refund({ id: 're_before_paid', amount: 10_500 })], has_more: false }, + }); + + await expect( + observeServiceFeeChargeRefunded({ + store, + charge: fullCharge, + }) + ).rejects.toBeInstanceOf(ServiceFeeObservationNotReadyError); + + const pending = await store.findByAssessmentKey(ASSESSMENT_KEY); + expect(pending).toMatchObject({ + settledAt: null, + refundedGrossMinor: 10_500, + refundedProductMinor: 0, + refundedFeeMinor: 0, + outcome: 'charged', + }); + + await expect( + observeServiceFeeChargeRefunded({ + store, + charge: fullCharge, + }) + ).rejects.toBeInstanceOf(ServiceFeeObservationNotReadyError); + expect(await store.findByAssessmentKey(ASSESSMENT_KEY)).toMatchObject({ + refundedGrossMinor: 10_500, + refundedProductMinor: 0, + refundedFeeMinor: 0, + }); + + const settled = await settleServiceFeeAssessment({ + store, + assessmentKey: ASSESSMENT_KEY, + settledAt: ACTIVATION, + settledProductMinor: 10_000, + grossPaidMinor: 10_500, + chargedFeeMinor: 500, + now: ACTIVATION, + }); + const applied = await applyDeferredServiceFeeRefunds({ + store, + assessment: settled, + }); + expect(applied).toMatchObject({ + refundedProductMinor: 10_000, + refundedFeeMinor: 500, + refundedGrossMinor: 10_500, + outcome: 'charged', + }); + + const replay = await observeServiceFeeChargeRefunded({ + store, + charge: fullCharge, + }); + expect(replay.status).toBe('full'); + expect(replay.createdStripeRefund).toBe(false); + expect(replay.assessment).toMatchObject({ + refundedProductMinor: 10_000, + refundedFeeMinor: 500, + refundedGrossMinor: 10_500, + }); + }); + + test('throws for a credit note that arrives before settlement so Stripe retries', async () => { + const store = createMemoryRefundStore(); + await persistChargedAssessment(store); + const note = creditNote({ + id: 'cn_before_paid', + lines: [ + creditNoteLine('cnli_product', PRODUCT_LINE_ID, 2_000), + creditNoteLine('cnli_fee', FEE_LINE_ID, 100), + ], + }); + + await expect( + observeServiceFeeCreditNote({ + store, + creditNote: note, + }) + ).rejects.toBeInstanceOf(ServiceFeeObservationNotReadyError); + expect(await store.findByAssessmentKey(ASSESSMENT_KEY)).toMatchObject({ + settledAt: null, + refundedProductMinor: 0, + refundedFeeMinor: 0, + }); + }); + + test('accumulates known credit-note line allocations across notes without drift', async () => { + const store = createMemoryRefundStore(); + await persistSettledAssessment(store); + const first = creditNote({ + id: 'cn_a', + lines: [ + creditNoteLine('cnli_a_product', PRODUCT_LINE_ID, 2_000), + creditNoteLine('cnli_a_fee', FEE_LINE_ID, 100), + ], + }); + const second = creditNote({ + id: 'cn_b', + lines: [ + creditNoteLine('cnli_b_product', PRODUCT_LINE_ID, 8_000), + creditNoteLine('cnli_b_fee', FEE_LINE_ID, 400), + ], + }); + const stripe = createStripeMock({ + creditNotes: [first, second], + invoiceLines: [ + invoiceLine(PRODUCT_LINE_ID, 10_000, false), + invoiceLine(FEE_LINE_ID, 500, true), + ], + }); + + await observeServiceFeeCreditNote({ store, creditNote: first, stripe }); + const afterSecond = await observeServiceFeeCreditNote({ store, creditNote: second, stripe }); + expect(afterSecond.assessment).toMatchObject({ + refundedProductMinor: 10_000, + refundedFeeMinor: 500, + outcome: 'charged', + }); + + const retry = await observeServiceFeeCreditNote({ store, creditNote: second, stripe }); + expect(retry.assessment).toMatchObject({ + refundedProductMinor: 10_000, + refundedFeeMinor: 500, + }); + }); +}); diff --git a/apps/web/src/lib/service-fees/refunds.ts b/apps/web/src/lib/service-fees/refunds.ts new file mode 100644 index 0000000000..67ad642db7 --- /dev/null +++ b/apps/web/src/lib/service-fees/refunds.ts @@ -0,0 +1,827 @@ +import 'server-only'; + +import { captureException } from '@sentry/nextjs'; +import type Stripe from 'stripe'; + +import { + AdminSlackNotificationError, + sendAdminSlackNotification, +} from '@/lib/slack/admin-notifications'; +import { + observeServiceFeeAssessmentRefunds, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, +} from '@/lib/service-fees/assessments'; +import { calculateCumulativeFeeRefundMinor } from '@/lib/service-fees/calculation'; +import { + isKiloClawInvoiceLine, + isSeatInvoiceLine, + isServiceFeeInvoiceLine, +} from '@/lib/service-fees/stripe-lines'; + +export const SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED = 'refund_allocation_unresolved'; +export const SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED_SENTRY_TAG = + 'service_fee_refund_allocation_unresolved'; +export const SERVICE_FEE_REFUND_PRODUCT_METADATA_KEY = 'serviceFeeRefundProductMinor'; +export const SERVICE_FEE_REFUND_FEE_METADATA_KEY = 'serviceFeeRefundFeeMinor'; + +const PAGE_SIZE = 100; +const COUNTED_REFUND_STATUSES = new Set(['succeeded', 'pending']); + +export class ServiceFeeObservationNotReadyError extends Error { + readonly name = 'ServiceFeeObservationNotReadyError'; + + constructor(readonly assessmentKey: string) { + super( + `service fee assessment ${assessmentKey} cannot record product or fee observation until settlement` + ); + } +} + +export type ServiceFeeStripeReference = string | { id: string } | null | undefined; + +export type ServiceFeeRefundAssessmentStore = ServiceFeeAssessmentStore & { + findByStripeInvoiceId(stripeInvoiceId: string): Promise; + findByStripePaymentIntentId( + stripePaymentIntentId: string + ): Promise; + findByStripeChargeId(stripeChargeId: string): Promise; +}; + +export type ServiceFeeRefundPage = { + data: T[]; + has_more: boolean; +}; + +export type ServiceFeeCreditNoteObservation = Pick< + Stripe.CreditNote, + 'id' | 'invoice' | 'status' | 'lines' +>; + +export type ServiceFeeRefundStripeClient = { + refunds: { + list(params: { + charge: string; + limit?: number; + starting_after?: string; + }): Promise>; + }; + creditNotes?: { + list(params: { + invoice: string; + limit?: number; + starting_after?: string; + }): Promise>; + listLineItems( + id: string, + params?: { limit?: number; starting_after?: string } + ): Promise>; + }; + invoices?: { + listLineItems( + invoiceId: string, + params?: Stripe.InvoiceListLineItemsParams + ): Promise>; + }; +}; + +export type ServiceFeeRefundAllocation = { + productMinor: number; + feeMinor: number; +}; + +export type ServiceFeeRefundIncrement = { + cumulativeProductRefundMinor: number; + cumulativeFeeRefundMinor: number; + incrementalProductRefundMinor: number; + incrementalFeeRefundMinor: number; + incrementalGrossRefundMinor: number; +}; + +export type ServiceFeeChargeRefundObservation = { + id: string; + amount: number; + amount_refunded: number; + payment_intent?: ServiceFeeStripeReference; + invoice?: ServiceFeeStripeReference; + refunds?: ServiceFeeRefundPage | null; + metadata?: Stripe.Metadata | null; +}; + +export type ServiceFeeRefundObservationStatus = 'ignored' | 'full' | 'allocated' | 'unresolved'; + +export type ServiceFeeRefundObservationResult = { + status: ServiceFeeRefundObservationStatus; + assessment: ServiceFeeAssessmentRecord | null; + refundedGrossMinor: number; + refundedProductMinor: number; + refundedFeeMinor: number; + createdStripeRefund: false; +}; + +export type UnresolvedServiceFeeRefundAllocationAlertInput = { + assessmentKey: string; + flow: ServiceFeeAssessmentRecord['flow']; + kiloUserId?: string | null; + organizationId?: string | null; + stripeCheckoutSessionId?: string | null; + stripeInvoiceId?: string | null; + stripePaymentIntentId?: string | null; + stripeChargeId?: string | null; + refundedGrossMinor: number; + chargeAmountMinor: number; + settledProductMinor: number; + chargedFeeMinor: number; + currency: string; + observedAt: Date | string; +}; + +export type ServiceFeeRefundObservationDependencies = { + sendAlert?: (input: UnresolvedServiceFeeRefundAllocationAlertInput) => Promise; + captureException?: typeof captureException; +}; + +export function stripeReferenceId(reference: ServiceFeeStripeReference): string | null { + if (typeof reference === 'string') { + return reference.trim() ? reference : null; + } + if (reference && typeof reference.id === 'string' && reference.id.trim()) { + return reference.id; + } + return null; +} + +export async function resolveServiceFeeAssessmentFromStripeRefs(params: { + store: ServiceFeeRefundAssessmentStore; + chargeId?: string | null; + paymentIntentId?: string | null; + invoiceId?: string | null; +}): Promise { + if (params.chargeId) { + const byCharge = await params.store.findByStripeChargeId(params.chargeId); + if (byCharge) return byCharge; + } + if (params.paymentIntentId) { + const byPaymentIntent = await params.store.findByStripePaymentIntentId(params.paymentIntentId); + if (byPaymentIntent) return byPaymentIntent; + } + if (params.invoiceId) { + const byInvoice = await params.store.findByStripeInvoiceId(params.invoiceId); + if (byInvoice) return byInvoice; + } + return null; +} + +export function calculateServiceFeeRefundIncrement(input: { + originalProductMinor: number; + originalFeeMinor: number; + alreadyRefundedProductMinor: number; + alreadyRefundedFeeMinor: number; + additionalProductRefundMinor: number; +}): ServiceFeeRefundIncrement { + assertNonNegativeSafeInteger(input.originalProductMinor, 'originalProductMinor'); + assertNonNegativeSafeInteger(input.originalFeeMinor, 'originalFeeMinor'); + assertNonNegativeSafeInteger(input.alreadyRefundedProductMinor, 'alreadyRefundedProductMinor'); + assertNonNegativeSafeInteger(input.alreadyRefundedFeeMinor, 'alreadyRefundedFeeMinor'); + assertNonNegativeSafeInteger(input.additionalProductRefundMinor, 'additionalProductRefundMinor'); + + if (input.alreadyRefundedProductMinor > input.originalProductMinor) { + throw new Error('alreadyRefundedProductMinor cannot exceed originalProductMinor'); + } + if (input.alreadyRefundedFeeMinor > input.originalFeeMinor) { + throw new Error('alreadyRefundedFeeMinor cannot exceed originalFeeMinor'); + } + + const remainingProductMinor = input.originalProductMinor - input.alreadyRefundedProductMinor; + const incrementalProductRefundMinor = Math.min( + input.additionalProductRefundMinor, + remainingProductMinor + ); + const cumulativeProductRefundMinor = + input.alreadyRefundedProductMinor + incrementalProductRefundMinor; + const targetFeeMinor = calculateCumulativeFeeRefundMinor({ + originalProductMinor: input.originalProductMinor, + originalFeeMinor: input.originalFeeMinor, + cumulativeProductRefundMinor, + }); + const remainingFeeMinor = input.originalFeeMinor - input.alreadyRefundedFeeMinor; + const incrementalFeeRefundMinor = Math.max( + 0, + Math.min(targetFeeMinor - input.alreadyRefundedFeeMinor, remainingFeeMinor) + ); + + return { + cumulativeProductRefundMinor, + cumulativeFeeRefundMinor: input.alreadyRefundedFeeMinor + incrementalFeeRefundMinor, + incrementalProductRefundMinor, + incrementalFeeRefundMinor, + incrementalGrossRefundMinor: incrementalProductRefundMinor + incrementalFeeRefundMinor, + }; +} + +export function buildServiceFeeRefundAllocationMetadata(allocation: ServiceFeeRefundAllocation): { + [SERVICE_FEE_REFUND_PRODUCT_METADATA_KEY]: string; + [SERVICE_FEE_REFUND_FEE_METADATA_KEY]: string; +} { + assertNonNegativeSafeInteger(allocation.productMinor, 'productMinor'); + assertNonNegativeSafeInteger(allocation.feeMinor, 'feeMinor'); + return { + [SERVICE_FEE_REFUND_PRODUCT_METADATA_KEY]: String(allocation.productMinor), + [SERVICE_FEE_REFUND_FEE_METADATA_KEY]: String(allocation.feeMinor), + }; +} + +export function parseServiceFeeRefundAllocationMetadata( + metadata: Stripe.Metadata | Stripe.MetadataParam | null | undefined +): ServiceFeeRefundAllocation | null { + if (!metadata) return null; + const productMinor = parseMinorMetadata(metadata[SERVICE_FEE_REFUND_PRODUCT_METADATA_KEY]); + const feeMinor = parseMinorMetadata(metadata[SERVICE_FEE_REFUND_FEE_METADATA_KEY]); + if (productMinor === null || feeMinor === null) return null; + return { productMinor, feeMinor }; +} + +/** + * Observe `charge.refunded`. Never creates a Stripe refund. Full remaining + * no-amount Kilo refunds are treated as full product+fee once cumulative gross + * equals the charge amount. + * + * An existing unsettled assessment still records `refundedGrossMinor`. Product + * and fee columns cannot be written until settlement without violating refund + * caps, so this throws for webhook retry after persisting the gross. + */ +export async function observeServiceFeeChargeRefunded(params: { + store: ServiceFeeRefundAssessmentStore; + charge: ServiceFeeChargeRefundObservation; + stripe?: ServiceFeeRefundStripeClient; + trustedAllocation?: ServiceFeeRefundAllocation | null; + now?: Date; + deps?: ServiceFeeRefundObservationDependencies; +}): Promise { + const assessment = await resolveServiceFeeAssessmentFromStripeRefs({ + store: params.store, + chargeId: params.charge.id, + paymentIntentId: stripeReferenceId(params.charge.payment_intent), + invoiceId: stripeReferenceId(params.charge.invoice), + }); + if (!assessment) { + return ignoredResult(null, params.charge.amount_refunded); + } + if (!assessment.settledAt) { + const refundedGrossMinor = Math.max(0, params.charge.amount_refunded); + const isFullRefund = params.charge.amount > 0 && refundedGrossMinor >= params.charge.amount; + await persistRefundObservation({ + store: params.store, + assessment, + refundedGrossMinor, + refundedProductMinor: assessment.refundedProductMinor, + refundedFeeMinor: assessment.refundedFeeMinor, + unresolved: !isFullRefund, + now: params.now, + }); + throw new ServiceFeeObservationNotReadyError(assessment.assessmentKey); + } + + const refundedGrossMinor = Math.max(0, params.charge.amount_refunded); + const isFullRefund = params.charge.amount > 0 && refundedGrossMinor >= params.charge.amount; + let refunds: { items: Stripe.Refund[]; complete: boolean }; + try { + refunds = await listChargeRefunds(params.charge, params.stripe); + } catch { + refunds = { items: params.charge.refunds?.data ?? [], complete: false }; + } + + let allocation: ServiceFeeRefundAllocation | null = null; + let complete = false; + + if (isFullRefund) { + allocation = { + productMinor: assessment.settledProductMinor, + feeMinor: assessment.chargedFeeMinor, + }; + complete = true; + } else if (params.trustedAllocation) { + allocation = params.trustedAllocation; + complete = true; + } else if (refunds.complete) { + const fromRefundMetadata = sumRefundMetadataAllocations(refunds.items); + if (fromRefundMetadata) { + allocation = fromRefundMetadata; + complete = true; + } + } + + if (!complete) { + try { + const fromCreditNotes = await collectCreditNoteAllocations({ + assessment, + invoiceId: stripeReferenceId(params.charge.invoice) ?? assessment.stripeInvoiceId, + stripe: params.stripe, + }); + if ( + fromCreditNotes.complete && + (fromCreditNotes.allocation.productMinor > 0 || fromCreditNotes.allocation.feeMinor > 0) + ) { + allocation = fromCreditNotes.allocation; + complete = true; + } + } catch { + // Listing credit notes or invoice lines is supporting evidence only. + // A Stripe follow-up failure must not block recording the observed gross. + } + } + + if (!complete || !allocation) { + const unresolved = await persistRefundObservation({ + store: params.store, + assessment, + refundedGrossMinor, + refundedProductMinor: assessment.refundedProductMinor, + refundedFeeMinor: assessment.refundedFeeMinor, + unresolved: true, + now: params.now, + }); + await emitUnresolvedAllocationAlert(unresolved, params.charge, params.deps); + return { + status: 'unresolved', + assessment: unresolved, + refundedGrossMinor: unresolved.refundedGrossMinor, + refundedProductMinor: unresolved.refundedProductMinor, + refundedFeeMinor: unresolved.refundedFeeMinor, + createdStripeRefund: false, + }; + } + + const applied = applyAllocation(assessment, allocation); + const updated = await persistRefundObservation({ + store: params.store, + assessment, + refundedGrossMinor, + refundedProductMinor: applied.productMinor, + refundedFeeMinor: applied.feeMinor, + unresolved: false, + now: params.now, + }); + + return { + status: isFullRefund ? 'full' : 'allocated', + assessment: updated, + refundedGrossMinor: updated.refundedGrossMinor, + refundedProductMinor: updated.refundedProductMinor, + refundedFeeMinor: updated.refundedFeeMinor, + createdStripeRefund: false, + }; +} + +/** + * Observe `credit_note.created` / `credit_note.updated`. Known invoice-line + * allocations are cumulative and idempotent. Unknown lines are ignored. + */ +export async function observeServiceFeeCreditNote(params: { + store: ServiceFeeRefundAssessmentStore; + creditNote: ServiceFeeCreditNoteObservation; + stripe?: ServiceFeeRefundStripeClient; + now?: Date; +}): Promise { + const invoiceId = stripeReferenceId(params.creditNote.invoice); + const assessment = await resolveServiceFeeAssessmentFromStripeRefs({ + store: params.store, + invoiceId, + }); + if (!assessment || !invoiceId) { + return ignoredResult(assessment, assessment?.refundedGrossMinor ?? 0); + } + if (!assessment.settledAt) { + throw new ServiceFeeObservationNotReadyError(assessment.assessmentKey); + } + + const collected = await collectCreditNoteAllocations({ + assessment, + invoiceId, + stripe: params.stripe, + currentCreditNote: params.creditNote, + }); + if ( + !collected.complete && + collected.allocation.productMinor === 0 && + collected.allocation.feeMinor === 0 + ) { + return { + status: 'ignored', + assessment, + refundedGrossMinor: assessment.refundedGrossMinor, + refundedProductMinor: assessment.refundedProductMinor, + refundedFeeMinor: assessment.refundedFeeMinor, + createdStripeRefund: false, + }; + } + + const applied = applyAllocation(assessment, collected.allocation); + const updated = await persistRefundObservation({ + store: params.store, + assessment, + refundedGrossMinor: assessment.refundedGrossMinor, + refundedProductMinor: applied.productMinor, + refundedFeeMinor: applied.feeMinor, + unresolved: false, + now: params.now, + }); + + return { + status: 'allocated', + assessment: updated, + refundedGrossMinor: updated.refundedGrossMinor, + refundedProductMinor: updated.refundedProductMinor, + refundedFeeMinor: updated.refundedFeeMinor, + createdStripeRefund: false, + }; +} + +export function buildUnresolvedServiceFeeRefundAllocationAlertText( + input: UnresolvedServiceFeeRefundAllocationAlertInput +): string { + return [ + 'Service fee refund allocation unresolved', + `assessment_key=${input.assessmentKey}`, + `flow=${input.flow}`, + `owner_id=${nonEmpty(input.organizationId) ?? nonEmpty(input.kiloUserId) ?? 'unknown'}`, + `stripe_checkout_session_id=${nonEmpty(input.stripeCheckoutSessionId) ?? 'none'}`, + `stripe_invoice_id=${nonEmpty(input.stripeInvoiceId) ?? 'none'}`, + `stripe_payment_intent_id=${nonEmpty(input.stripePaymentIntentId) ?? 'none'}`, + `stripe_charge_id=${nonEmpty(input.stripeChargeId) ?? 'none'}`, + `refunded_gross_minor=${input.refundedGrossMinor}`, + `charge_amount_minor=${input.chargeAmountMinor}`, + `settled_product_minor=${input.settledProductMinor}`, + `charged_fee_minor=${input.chargedFeeMinor}`, + `currency=${input.currency}`, + `failure_code=${SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED}`, + `observed_at=${toIsoTimestamp(input.observedAt)}`, + ].join('\n'); +} + +/** + * Apply a refund observed before settlement. Only a full gross refund can be + * reconstructed from persisted columns; partial allocations wait for webhook + * retry after `settled_at` is written. + */ +export async function applyDeferredServiceFeeRefunds(params: { + store: ServiceFeeAssessmentStore; + assessment: ServiceFeeAssessmentRecord; + now?: Date; +}): Promise { + const assessment = params.assessment; + if (!assessment.settledAt) return assessment; + if (assessment.grossPaidMinor <= 0 || assessment.refundedGrossMinor < assessment.grossPaidMinor) { + return assessment; + } + if ( + assessment.refundedProductMinor === assessment.settledProductMinor && + assessment.refundedFeeMinor === assessment.chargedFeeMinor + ) { + return assessment; + } + + return persistRefundObservation({ + store: params.store, + assessment, + refundedGrossMinor: assessment.refundedGrossMinor, + refundedProductMinor: assessment.settledProductMinor, + refundedFeeMinor: assessment.chargedFeeMinor, + unresolved: false, + now: params.now, + }); +} + +export async function sendUnresolvedServiceFeeRefundAllocationAlert( + input: UnresolvedServiceFeeRefundAllocationAlertInput, + deps: ServiceFeeRefundObservationDependencies = {} +): Promise { + const sendNotification = deps.sendAlert + ? async (alertInput: UnresolvedServiceFeeRefundAllocationAlertInput) => { + await deps.sendAlert?.(alertInput); + } + : async (alertInput: UnresolvedServiceFeeRefundAllocationAlertInput) => { + await sendAdminSlackNotification({ + text: buildUnresolvedServiceFeeRefundAllocationAlertText(alertInput), + unfurl_links: false, + unfurl_media: false, + }); + }; + const capture = deps.captureException ?? captureException; + + try { + await sendNotification(input); + } catch (error) { + const isSlackError = error instanceof AdminSlackNotificationError; + capture(isSlackError ? error : new Error('Admin Slack notification failed'), { + tags: { source: SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED_SENTRY_TAG }, + extra: { + kind: isSlackError ? error.kind : 'unexpected', + status: isSlackError ? (error.status ?? null) : null, + assessmentKey: input.assessmentKey, + flow: input.flow, + failureCode: SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED, + }, + }); + } +} + +function applyAllocation( + assessment: ServiceFeeAssessmentRecord, + allocation: ServiceFeeRefundAllocation +): ServiceFeeRefundAllocation { + return { + productMinor: Math.max( + assessment.refundedProductMinor, + Math.min(allocation.productMinor, assessment.settledProductMinor) + ), + feeMinor: Math.max( + assessment.refundedFeeMinor, + Math.min(allocation.feeMinor, assessment.chargedFeeMinor) + ), + }; +} + +async function persistRefundObservation(params: { + store: ServiceFeeAssessmentStore; + assessment: ServiceFeeAssessmentRecord; + refundedGrossMinor: number; + refundedProductMinor: number; + refundedFeeMinor: number; + unresolved: boolean; + now?: Date; +}): Promise { + return observeServiceFeeAssessmentRefunds({ + store: params.store, + assessmentKey: params.assessment.assessmentKey, + refundedProductMinor: params.refundedProductMinor, + refundedFeeMinor: params.refundedFeeMinor, + refundedGrossMinor: params.refundedGrossMinor, + unresolved: params.unresolved, + now: params.now, + }); +} + +async function emitUnresolvedAllocationAlert( + assessment: ServiceFeeAssessmentRecord, + charge: ServiceFeeChargeRefundObservation, + deps: ServiceFeeRefundObservationDependencies | undefined +): Promise { + const input: UnresolvedServiceFeeRefundAllocationAlertInput = { + assessmentKey: assessment.assessmentKey, + flow: assessment.flow, + kiloUserId: assessment.kiloUserId, + organizationId: assessment.organizationId, + stripeCheckoutSessionId: assessment.stripeCheckoutSessionId, + stripeInvoiceId: assessment.stripeInvoiceId, + stripePaymentIntentId: assessment.stripePaymentIntentId, + stripeChargeId: assessment.stripeChargeId ?? charge.id, + refundedGrossMinor: assessment.refundedGrossMinor, + chargeAmountMinor: charge.amount, + settledProductMinor: assessment.settledProductMinor, + chargedFeeMinor: assessment.chargedFeeMinor, + currency: assessment.currency, + observedAt: new Date(), + }; + + if (deps?.sendAlert) { + try { + await deps.sendAlert(input); + } catch (error) { + const capture = deps.captureException ?? captureException; + capture(error instanceof Error ? error : new Error('Admin Slack notification failed'), { + tags: { source: SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED_SENTRY_TAG }, + extra: { + kind: 'unexpected', + status: null, + assessmentKey: assessment.assessmentKey, + flow: assessment.flow, + failureCode: SERVICE_FEE_REFUND_ALLOCATION_UNRESOLVED, + }, + }); + } + return; + } + + await sendUnresolvedServiceFeeRefundAllocationAlert(input, deps); +} + +async function listChargeRefunds( + charge: ServiceFeeChargeRefundObservation, + stripe: ServiceFeeRefundStripeClient | undefined +): Promise<{ items: Stripe.Refund[]; complete: boolean }> { + if (charge.refunds && !charge.refunds.has_more) { + return { items: charge.refunds.data, complete: true }; + } + if (!stripe) { + return { items: charge.refunds?.data ?? [], complete: false }; + } + + const items = await listAllPages(startingAfter => + stripe.refunds.list({ + charge: charge.id, + limit: PAGE_SIZE, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }) + ); + return { items, complete: true }; +} + +function sumRefundMetadataAllocations( + refunds: readonly Stripe.Refund[] +): ServiceFeeRefundAllocation | null { + const counted = refunds.filter(refund => COUNTED_REFUND_STATUSES.has(refund.status ?? '')); + if (counted.length === 0) return null; + + let productMinor = 0; + let feeMinor = 0; + for (const refund of counted) { + const allocation = parseServiceFeeRefundAllocationMetadata(refund.metadata); + if (!allocation) return null; + productMinor += allocation.productMinor; + feeMinor += allocation.feeMinor; + } + return { productMinor, feeMinor }; +} + +async function collectCreditNoteAllocations(params: { + assessment: ServiceFeeAssessmentRecord; + invoiceId: string | null; + stripe?: ServiceFeeRefundStripeClient; + currentCreditNote?: ServiceFeeCreditNoteObservation; +}): Promise<{ allocation: ServiceFeeRefundAllocation; complete: boolean }> { + if (!params.invoiceId) { + return { allocation: { productMinor: 0, feeMinor: 0 }, complete: false }; + } + + const creditNotes = await listInvoiceCreditNotes( + params.invoiceId, + params.stripe, + params.currentCreditNote + ); + const invoiceLines = await listInvoiceLines(params.invoiceId, params.stripe); + const invoiceLinesById = new Map(invoiceLines.items.map(line => [line.id, line])); + + let productMinor = 0; + let feeMinor = 0; + let complete = creditNotes.complete && invoiceLines.complete; + + for (const creditNote of creditNotes.items) { + if (creditNote.status === 'void') continue; + const lines = await listCreditNoteLines(creditNote, params.stripe); + if (!lines.complete) complete = false; + for (const line of lines.items) { + const classified = classifyCreditNoteLine(line, params.assessment, invoiceLinesById); + if (classified === 'fee') { + feeMinor += Math.max(0, line.amount); + continue; + } + if (classified === 'product') { + productMinor += Math.max(0, line.amount); + continue; + } + if (classified === 'ignored') continue; + complete = false; + } + } + + return { allocation: { productMinor, feeMinor }, complete }; +} + +async function listInvoiceCreditNotes( + invoiceId: string, + stripe: ServiceFeeRefundStripeClient | undefined, + current?: ServiceFeeCreditNoteObservation +): Promise<{ items: ServiceFeeCreditNoteObservation[]; complete: boolean }> { + const listCreditNotes = stripe?.creditNotes?.list; + if (!listCreditNotes) { + return { + items: current ? [current] : [], + complete: Boolean(current && current.lines && !current.lines.has_more), + }; + } + + const items = await listAllPages(startingAfter => + listCreditNotes({ + invoice: invoiceId, + limit: PAGE_SIZE, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }) + ); + return { items, complete: true }; +} + +async function listCreditNoteLines( + creditNote: ServiceFeeCreditNoteObservation, + stripe: ServiceFeeRefundStripeClient | undefined +): Promise<{ items: Stripe.CreditNoteLineItem[]; complete: boolean }> { + if (creditNote.lines && !creditNote.lines.has_more) { + return { items: creditNote.lines.data, complete: true }; + } + const listLineItems = stripe?.creditNotes?.listLineItems; + if (!listLineItems) { + return { items: creditNote.lines?.data ?? [], complete: false }; + } + const items = await listAllPages(startingAfter => + listLineItems(creditNote.id, { + limit: PAGE_SIZE, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }) + ); + return { items, complete: true }; +} + +async function listInvoiceLines( + invoiceId: string, + stripe: ServiceFeeRefundStripeClient | undefined +): Promise<{ items: Stripe.InvoiceLineItem[]; complete: boolean }> { + const listLineItems = stripe?.invoices?.listLineItems; + if (!listLineItems) { + return { items: [], complete: false }; + } + const items = await listAllPages(startingAfter => + listLineItems(invoiceId, { + limit: PAGE_SIZE, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }) + ); + return { items, complete: true }; +} + +function classifyCreditNoteLine( + line: Stripe.CreditNoteLineItem, + assessment: ServiceFeeAssessmentRecord, + invoiceLinesById: Map +): 'fee' | 'product' | 'ignored' | 'unknown' { + if (line.type !== 'invoice_line_item' || !line.invoice_line_item) { + return line.amount === 0 ? 'ignored' : 'unknown'; + } + if (line.invoice_line_item === assessment.stripeInvoiceFeeLineItemId) { + return 'fee'; + } + + const invoiceLine = invoiceLinesById.get(line.invoice_line_item); + if (!invoiceLine) { + return assessment.stripeInvoiceFeeLineItemId ? 'product' : 'unknown'; + } + if (isServiceFeeInvoiceLine(invoiceLine)) return 'fee'; + if (isSeatInvoiceLine(invoiceLine) || isKiloClawInvoiceLine(invoiceLine)) return 'ignored'; + return 'product'; +} + +async function listAllPages( + listPage: (startingAfter: string | undefined) => Promise> +): Promise { + const rows: T[] = []; + let startingAfter: string | undefined; + + for (;;) { + const page = await listPage(startingAfter); + rows.push(...page.data); + if (!page.has_more) return rows; + const cursor = page.data.at(-1)?.id; + if (!cursor) { + throw new Error('stripe page is marked has_more without a cursor'); + } + startingAfter = cursor; + } +} + +function ignoredResult( + assessment: ServiceFeeAssessmentRecord | null, + refundedGrossMinor: number +): ServiceFeeRefundObservationResult { + return { + status: 'ignored', + assessment, + refundedGrossMinor, + refundedProductMinor: assessment?.refundedProductMinor ?? 0, + refundedFeeMinor: assessment?.refundedFeeMinor ?? 0, + createdStripeRefund: false, + }; +} + +function parseMinorMetadata(value: unknown): number | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + return parsed; +} + +function nonEmpty(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toIsoTimestamp(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return 'invalid_timestamp'; + return date.toISOString(); +} + +function assertNonNegativeSafeInteger(value: number, label: string): void { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } +} diff --git a/apps/web/src/lib/service-fees/settlement.test.ts b/apps/web/src/lib/service-fees/settlement.test.ts new file mode 100644 index 0000000000..517b366f0f --- /dev/null +++ b/apps/web/src/lib/service-fees/settlement.test.ts @@ -0,0 +1,806 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; +import { + markServiceFeeAssessmentCharged, + prepareServiceFeeAssessmentDecision, + sanitizeServiceFeeAssessmentMetadata, + upsertServiceFeeAssessment, + type ServiceFeeAssessmentRecord, +} from '@/lib/service-fees/assessments'; +import { calculateServiceFeeMinor } from '@/lib/service-fees/calculation'; +import { createInvoiceServiceFeeAssessmentKey } from '@/lib/service-fees/checkout'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; +import { + settleKiloPassInvoiceServiceFee, + SERVICE_FEE_FAILURE_RATE_DEVIATION, + type KiloPassServiceFeeSettlementDependencies, + type ServiceFeeSettlementStore, +} from '@/lib/service-fees/settlement'; +import { buildServiceFeeLineMetadata } from '@/lib/service-fees/stripe-lines'; + +const KILO_PASS_PRICE_ID = getKnownStripePriceIdsForKiloPass()[0]!; +const ACTIVATION = SERVICE_FEE_ACTIVATION_UNIX_SECONDS; + +function createMemorySettlementStore(): ServiceFeeSettlementStore { + const rows = new Map(); + const store: ServiceFeeSettlementStore = { + async transact(fn) { + return fn(store); + }, + async findByAssessmentKey(assessmentKey) { + const row = rows.get(assessmentKey); + return row ? { ...row, metadata: { ...row.metadata } } : null; + }, + async findByStripeInvoiceId(stripeInvoiceId) { + const row = [...rows.values()].find( + candidate => candidate.stripeInvoiceId === stripeInvoiceId + ); + 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: + patch.metadata !== undefined + ? sanitizeServiceFeeAssessmentMetadata(patch.metadata) + : { ...existing.metadata }, + }; + rows.set(assessmentKey, next); + return { ...next }; + }, + }; + return store; +} + +function invoiceLine( + overrides: Partial & { + amount?: number; + metadata?: Stripe.Metadata; + pricing?: Stripe.InvoiceLineItem['pricing']; + pretax_credit_amounts?: Stripe.InvoiceLineItem['pretax_credit_amounts']; + } +): Stripe.InvoiceLineItem { + return { + id: overrides.id ?? 'il_test', + object: 'line_item', + amount: overrides.amount ?? 4_900, + currency: 'usd', + description: 'line', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: 'in_paid', + livemode: false, + metadata: overrides.metadata ?? {}, + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: overrides.pretax_credit_amounts ?? null, + pricing: overrides.pricing ?? null, + quantity: 1, + subscription: null, + taxes: null, + ...overrides, + } as Stripe.InvoiceLineItem; +} + +function pricedLine( + priceId: string, + amount: number, + extra: Partial[0]> = {} +) { + return invoiceLine({ + amount, + pricing: { + type: 'price_details', + unit_amount_decimal: String(amount), + price_details: { price: priceId, product: 'prod_pass' }, + }, + ...extra, + }); +} + +function feeLine( + assessmentKey: string, + amount: number, + extra: Partial = {} +) { + return invoiceLine({ + id: extra.id ?? 'il_fee', + amount, + metadata: buildServiceFeeLineMetadata(assessmentKey), + pretax_credit_amounts: extra.pretax_credit_amounts ?? null, + ...extra, + }); +} + +function paidInvoice( + lines: Stripe.InvoiceLineItem[], + overrides: Partial & { has_more?: boolean } = {} +): Stripe.Invoice { + const { has_more, metadata, amount_paid, id, ...invoiceOverrides } = overrides; + return { + id: id ?? 'in_paid', + object: 'invoice', + created: ACTIVATION, + status: 'paid', + currency: 'usd', + customer: 'cus_1', + amount_paid: amount_paid ?? 5_145, + status_transitions: { + finalized_at: ACTIVATION, + marked_uncollectible_at: null, + paid_at: ACTIVATION + 10, + voided_at: null, + }, + payments: { + object: 'list', + has_more: false, + url: '/v1/invoices/in_paid/payments', + data: [ + { + id: 'inpay_1', + object: 'invoice_payment', + amount_paid: amount_paid ?? 5_145, + amount_requested: amount_paid ?? 5_145, + created: ACTIVATION, + currency: 'usd', + invoice: id ?? 'in_paid', + is_default: true, + livemode: false, + status: 'paid', + status_transitions: { canceled_at: null, paid_at: ACTIVATION + 10 }, + payment: { + type: 'payment_intent', + payment_intent: { + id: 'pi_1', + latest_charge: 'ch_1', + } as Stripe.PaymentIntent, + }, + } as Stripe.InvoicePayment, + ], + }, + ...invoiceOverrides, + metadata: metadata ?? {}, + lines: { + object: 'list', + data: lines, + has_more: has_more ?? false, + url: '/v1/invoices/in_paid/lines', + }, + } as Stripe.Invoice; +} + +async function persistCheckoutAssessment( + store: ServiceFeeSettlementStore, + input: { + assessmentKey: string; + eligibleSubtotalMinor: number; + invoiceId?: string | null; + chargedFeeMinor?: number; + } +) { + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey: input.assessmentKey, + flow: 'personal_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(ACTIVATION * 1000), + eligibleSubtotalMinor: input.eligibleSubtotalMinor, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + }); + const record = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeInvoiceId: input.invoiceId === undefined ? 'in_paid' : input.invoiceId, + stripeCheckoutSessionId: 'cs_1', + }, + }); + if (input.chargedFeeMinor !== undefined) { + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: record.assessmentKey, + chargedFeeMinor: input.chargedFeeMinor, + }); + } + if (decision.outcome === 'pending') { + return markServiceFeeAssessmentCharged({ + store, + assessmentKey: record.assessmentKey, + chargedFeeMinor: 0, + }); + } + return record; +} + +describe('settleKiloPassInvoiceServiceFee', () => { + test('ignores invoices without paid evidence', async () => { + const store = createMemorySettlementStore(); + await persistCheckoutAssessment(store, { + assessmentKey: 'checkout:abc', + eligibleSubtotalMinor: 4_900, + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: { + ...paidInvoice([pricedLine(KILO_PASS_PRICE_ID, 4_900)]), + status: 'open', + status_transitions: { + finalized_at: ACTIVATION, + marked_uncollectible_at: null, + paid_at: null, + voided_at: null, + }, + }, + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + + expect(result.status).toBe('ignored'); + expect(result.assessment?.settledAt ?? null).toBeNull(); + }); + + test('paginates lines and resolves the assessment by metadata then invoice id', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = 'checkout:meta'; + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + invoiceId: null, + }); + + const pages = [ + [pricedLine(KILO_PASS_PRICE_ID, 3_920, { id: 'il_1' })], + [feeLine(assessmentKey, 196, { id: 'il_fee_page' })], + ]; + const listLineItems = jest + .fn< + ( + invoiceId: string, + params?: Stripe.InvoiceListLineItemsParams + ) => Promise<{ + data: Stripe.InvoiceLineItem[]; + has_more: boolean; + }> + >() + .mockResolvedValueOnce({ data: pages[0]!, has_more: true }) + .mockResolvedValueOnce({ data: pages[1]!, has_more: false }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice(pages[0]!, { + has_more: true, + amount_paid: 4_116, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + }), + stripe: { + invoices: { + listLineItems: listLineItems as never, + }, + }, + store, + }); + + expect(listLineItems).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 3_920, + chargedFeeMinor: 196, + grossPaidMinor: 4_116, + }); + expect(result.assessment?.assessmentKey).toBe(assessmentKey); + + const byInvoice = await persistCheckoutAssessment(store, { + assessmentKey: createInvoiceServiceFeeAssessmentKey('in_by_id'), + eligibleSubtotalMinor: 4_900, + invoiceId: 'in_by_id', + }); + const fromStore = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_full' }), + feeLine(byInvoice.assessmentKey, 245, { id: 'il_fee_full' }), + ], + { id: 'in_by_id', amount_paid: 5_145, metadata: {} } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + expect(fromStore.assessment?.assessmentKey).toBe(byInvoice.assessmentKey); + expect(fromStore.chargedFeeMinor).toBe(245); + }); + + test('discounted checkout settles below expected from the observed fee line', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = 'checkout:discount'; + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 3_920, { + id: 'il_pass', + pretax_credit_amounts: [{ amount: 980, type: 'discount', discount: 'di_1' }], + amount: 4_900, + }), + feeLine(assessmentKey, 196, { + pretax_credit_amounts: [{ amount: 49, type: 'discount', discount: 'di_1' }], + amount: 245, + }), + ], + { + amount_paid: 4_116, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 3_920, + chargedFeeMinor: 196, + grossPaidMinor: 4_116, + }); + expect(result.assessment?.expectedFeeMinor).toBe(245); + expect(result.assessment?.metadata.service_fee_rate_deviation).toBeUndefined(); + }); + + test('100% discount settles product 0 fee 0 as charged', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = 'checkout:free'; + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 0, { + id: 'il_pass', + amount: 4_900, + pretax_credit_amounts: [{ amount: 4_900, type: 'discount', discount: 'di_100' }], + }), + feeLine(assessmentKey, 0, { + amount: 245, + pretax_credit_amounts: [{ amount: 245, type: 'discount', discount: 'di_100' }], + }), + ], + { + amount_paid: 0, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 0, + chargedFeeMinor: 0, + grossPaidMinor: 0, + }); + expect(result.assessment?.outcome).toBe('charged'); + }); + + test('restricted coupon deviation is recorded and alerted without correction', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = 'checkout:restricted'; + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + }); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 3_920, { + id: 'il_pass', + amount: 4_900, + pretax_credit_amounts: [{ amount: 980, type: 'discount', discount: 'di_restricted' }], + }), + feeLine(assessmentKey, 245), + ], + { + amount_paid: 4_165, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + deps: { sendAlert }, + }); + + expect(calculateServiceFeeMinor(3_920)).toBe(196); + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 3_920, + chargedFeeMinor: 245, + grossPaidMinor: 4_165, + }); + expect(result.assessment?.metadata.service_fee_rate_deviation).toBe(true); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: SERVICE_FEE_FAILURE_RATE_DEVIATION }) + ); + }); + + test('links invoice, payment intent, and charge ids and is idempotent', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = createInvoiceServiceFeeAssessmentKey('in_ids'); + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + invoiceId: 'in_ids', + }); + + const invoice = paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass' }), + feeLine(assessmentKey, 245, { id: 'il_fee' }), + ], + { id: 'in_ids', amount_paid: 5_145 } + ); + const stripe = { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + }; + + const first = await settleKiloPassInvoiceServiceFee({ + invoice, + stripe, + store, + paymentIntentId: 'pi_1', + chargeId: 'ch_1', + }); + const second = await settleKiloPassInvoiceServiceFee({ + invoice, + stripe, + store, + paymentIntentId: 'pi_1', + chargeId: 'ch_1', + }); + + expect(first).toMatchObject({ + status: 'settled', + settledProductMinor: 4_900, + chargedFeeMinor: 245, + grossPaidMinor: 5_145, + }); + expect(first.assessment).toMatchObject({ + stripeInvoiceId: 'in_ids', + stripePaymentIntentId: 'pi_1', + stripeChargeId: 'ch_1', + stripeInvoiceFeeLineItemId: 'il_fee', + settledAt: new Date((ACTIVATION + 10) * 1000).toISOString(), + }); + expect(second.assessment?.settledAt).toBe(first.assessment?.settledAt); + expect(second.settledProductMinor).toBe(first.settledProductMinor); + expect(second.chargedFeeMinor).toBe(first.chargedFeeMinor); + expect(second.grossPaidMinor).toBe(first.grossPaidMinor); + }); + + test('reconciles a hosted Checkout fee line by persisted price id without metadata', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = 'checkout:price-id'; + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey, + flow: 'personal_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(ACTIVATION * 1000), + eligibleSubtotalMinor: 4_900, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + }); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeInvoiceId: 'in_price_id', + stripeCheckoutSessionId: 'cs_price_id', + stripeFeePriceId: 'price_fee_generated', + }, + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass_price' }), + invoiceLine({ + id: 'il_fee_no_meta', + amount: 245, + metadata: {}, + pricing: { + type: 'price_details', + unit_amount_decimal: '245', + price_details: { price: 'price_fee_generated', product: 'prod_fee' }, + }, + }), + ], + { + id: 'in_price_id', + amount_paid: 5_145, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 4_900, + chargedFeeMinor: 245, + grossPaidMinor: 5_145, + }); + expect(result.assessment).toMatchObject({ + outcome: 'charged', + chargedFeeMinor: 245, + stripeInvoiceFeeLineItemId: 'il_fee_no_meta', + }); + }); + + test('uses the subscription-aware classifier so unknown-price pass items still settle', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = createInvoiceServiceFeeAssessmentKey('in_sub_aware'); + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + invoiceId: 'in_sub_aware', + }); + const subscription = { + id: 'sub_aware', + items: { + object: 'list', + data: [ + { + id: 'si_pass_aware', + price: { id: 'price_unknown_pass' }, + metadata: { + type: 'kilo-pass', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }, + }, + { + id: 'si_other', + price: { id: 'price_unrelated' }, + metadata: {}, + }, + ], + has_more: false, + url: '/v1/subscription_items', + }, + } as unknown as Stripe.Subscription; + const retrieve = jest.fn(async (id: string): Promise => { + expect(id).toBe('sub_aware'); + return subscription; + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + invoiceLine({ + id: 'il_pass_unknown', + amount: 4_900, + metadata: {}, + pricing: { + type: 'price_details', + unit_amount_decimal: '4900', + price_details: { price: 'price_unknown_pass', product: 'prod_unknown' }, + }, + parent: { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: false, + proration_details: { credited_items: null }, + subscription: 'sub_aware', + subscription_item: 'si_pass_aware', + }, + }, + subscription: 'sub_aware', + }), + invoiceLine({ + id: 'il_other', + amount: 8_000, + metadata: {}, + pricing: { + type: 'price_details', + unit_amount_decimal: '8000', + price_details: { price: 'price_unrelated', product: 'prod_other' }, + }, + parent: { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + proration: false, + proration_details: { credited_items: null }, + subscription: 'sub_aware', + subscription_item: 'si_other', + }, + }, + subscription: 'sub_aware', + }), + feeLine(assessmentKey, 245, { id: 'il_fee_aware' }), + ], + { + id: 'in_sub_aware', + amount_paid: 13_145, + parent: { + type: 'subscription_details', + quote_details: null, + subscription_details: { + metadata: { serviceFeeAssessmentKey: assessmentKey }, + subscription: 'sub_aware', + }, + }, + } + ), + stripe: { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + subscriptions: { retrieve }, + }, + store, + }); + + expect(retrieve).toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 4_900, + chargedFeeMinor: 245, + grossPaidMinor: 13_145, + }); + }); + + test('pending without an observed fee line marks missed and still settles product', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = createInvoiceServiceFeeAssessmentKey('in_pending_missed'); + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey, + flow: 'personal_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(ACTIVATION * 1000), + eligibleSubtotalMinor: 4_900, + kiloUserId: 'user_1', + stripeCustomerId: 'cus_1', + }); + await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_1', + stripeInvoiceId: 'in_pending_missed', + }, + }); + + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice([pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass_pending' })], { + id: 'in_pending_missed', + amount_paid: 4_900, + metadata: { serviceFeeAssessmentKey: assessmentKey }, + }), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + }); + + expect(result).toMatchObject({ + status: 'settled', + settledProductMinor: 4_900, + chargedFeeMinor: 0, + grossPaidMinor: 4_900, + }); + expect(result.assessment).toMatchObject({ + outcome: 'missed', + failureCode: 'fee_application_failed', + chargedFeeMinor: 0, + settledAt: expect.any(String), + }); + }); + + test('missing assessment ignores fee revenue and returns product-only amount', async () => { + const store = createMemorySettlementStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass_missing' }), + feeLine('checkout:missing', 245, { id: 'il_fee_missing' }), + ], + { id: 'in_missing', amount_paid: 5_145, metadata: {} } + ), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + deps: { sendAlert }, + }); + + expect(result).toMatchObject({ + status: 'ignored', + settledProductMinor: 4_900, + chargedFeeMinor: 0, + grossPaidMinor: 5_145, + assessment: null, + }); + expect(sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ failureCode: 'missing_assessment' }) + ); + }); + + test('applies a full refund observed before paid and is idempotent on replay', async () => { + const store = createMemorySettlementStore(); + const assessmentKey = createInvoiceServiceFeeAssessmentKey('in_refund_first'); + await persistCheckoutAssessment(store, { + assessmentKey, + eligibleSubtotalMinor: 4_900, + invoiceId: 'in_refund_first', + }); + await store.update(assessmentKey, { refundedGrossMinor: 5_145 }); + + const invoice = paidInvoice( + [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_pass_refund_first' }), + feeLine(assessmentKey, 245, { id: 'il_fee_refund_first' }), + ], + { id: 'in_refund_first', amount_paid: 5_145 } + ); + const stripe = { + invoices: { listLineItems: async () => ({ data: [], has_more: false }) }, + }; + + const first = await settleKiloPassInvoiceServiceFee({ + invoice, + stripe, + store, + paymentIntentId: 'pi_refund_first', + chargeId: 'ch_refund_first', + }); + const second = await settleKiloPassInvoiceServiceFee({ + invoice, + stripe, + store, + paymentIntentId: 'pi_refund_first', + chargeId: 'ch_refund_first', + }); + + expect(first.assessment).toMatchObject({ + settledProductMinor: 4_900, + chargedFeeMinor: 245, + refundedGrossMinor: 5_145, + refundedProductMinor: 4_900, + refundedFeeMinor: 245, + outcome: 'charged', + }); + expect(second.assessment).toMatchObject({ + refundedProductMinor: 4_900, + refundedFeeMinor: 245, + refundedGrossMinor: 5_145, + settledAt: first.assessment?.settledAt, + }); + }); +}); diff --git a/apps/web/src/lib/service-fees/settlement.ts b/apps/web/src/lib/service-fees/settlement.ts new file mode 100644 index 0000000000..50e92f18e5 --- /dev/null +++ b/apps/web/src/lib/service-fees/settlement.ts @@ -0,0 +1,442 @@ +import 'server-only'; + +import type Stripe from 'stripe'; + +import { + sendMissedServiceFeeAlert, + type MissedServiceFeeAlertInput, +} from '@/lib/service-fees/alerts'; +import { + linkServiceFeeAssessmentStripeIds, + markServiceFeeAssessmentCharged, + markServiceFeeAssessmentMissed, + sanitizeServiceFeeAssessmentMetadata, + settleServiceFeeAssessment, + type ServiceFeeAssessmentRecord, + type ServiceFeeAssessmentStore, + type ServiceFeeStripeIds, +} from '@/lib/service-fees/assessments'; +import { + calculateServiceFeeMinor, + getNetPretaxLineAmountMinor, +} from '@/lib/service-fees/calculation'; +import { createInvoiceServiceFeeAssessmentKey } from '@/lib/service-fees/checkout'; +import { applyDeferredServiceFeeRefunds } from '@/lib/service-fees/refunds'; +import { + isServiceFeeInvoiceLine, + listAllInvoiceLineItems, + sumEligibleKiloPassSubtotalMinor, + type InvoiceLineItemListClient, +} from '@/lib/service-fees/stripe-lines'; +import { SERVICE_FEE_SUPPORTED_CURRENCY } from '@/lib/service-fees/types'; + +export const SERVICE_FEE_RATE_DEVIATION_THRESHOLD_MINOR = 1; +export const SERVICE_FEE_FAILURE_RATE_DEVIATION = 'service_fee_rate_deviation' as const; +export const SERVICE_FEE_FAILURE_MISSING_ASSESSMENT = 'missing_assessment' as const; +export const SERVICE_FEE_FAILURE_APPLICATION = 'fee_application_failed' as const; + +export type ServiceFeeSettlementStore = ServiceFeeAssessmentStore & { + findByStripeInvoiceId?(stripeInvoiceId: string): Promise; +}; + +export type KiloPassServiceFeeSettlementStripe = InvoiceLineItemListClient & { + subscriptions?: { + retrieve(id: string): Promise; + }; +}; + +export type KiloPassServiceFeeSettlementResult = { + status: 'settled' | 'ignored'; + settledProductMinor: number; + chargedFeeMinor: number; + grossPaidMinor: number; + assessment: ServiceFeeAssessmentRecord | null; +}; + +export type KiloPassServiceFeeSettlementDependencies = { + now?: Date; + sendAlert?: (input: MissedServiceFeeAlertInput) => Promise; + sendRateDeviationAlert?: (input: MissedServiceFeeAlertInput) => Promise; +}; + +/** + * Settle a paid Kilo Pass invoice against its durable assessment. + * Collection is observed from the actual fee line, including a zero amount. + */ +export async function settleKiloPassInvoiceServiceFee(params: { + invoice: Stripe.Invoice; + stripe: KiloPassServiceFeeSettlementStripe; + store: ServiceFeeSettlementStore; + paymentIntentId?: string | null; + chargeId?: string | null; + subscription?: Stripe.Subscription | null; + lines?: readonly Stripe.InvoiceLineItem[]; + deps?: KiloPassServiceFeeSettlementDependencies; +}): Promise { + const deps = params.deps ?? {}; + const now = deps.now ?? new Date(); + const invoiceId = params.invoice.id; + const stripeIds = invoiceStripeIds(params.invoice, params.paymentIntentId, params.chargeId); + + if (!invoiceId || !hasPaidEvidence(params.invoice)) { + return ignored(null, params.invoice.amount_paid ?? 0); + } + + const lines = + params.lines ?? + (await listAllInvoiceLineItems({ + invoice: params.invoice, + stripe: params.stripe, + })); + const subscription = + params.subscription !== undefined + ? params.subscription + : await loadSubscription(params.invoice, params.stripe); + const assessment = await resolveSettlementAssessment({ + invoice: params.invoice, + lines, + store: params.store, + }); + const currency = + params.invoice.currency || assessment?.currency || SERVICE_FEE_SUPPORTED_CURRENCY; + const productOnlyMinor = sumEligibleKiloPassSubtotalMinor({ + lines, + currency, + subscription, + }); + if (!assessment) { + await alertSafely({ + assessmentKey: createInvoiceServiceFeeAssessmentKey(invoiceId), + flow: 'personal_kilo_pass', + stripeInvoiceId: invoiceId, + stripePaymentIntentId: stripeIds.stripePaymentIntentId, + stripeChargeId: stripeIds.stripeChargeId, + eligibleSubtotalMinor: productOnlyMinor, + expectedFeeMinor: 0, + failureCode: SERVICE_FEE_FAILURE_MISSING_ASSESSMENT, + deps, + now, + }); + return { + status: 'ignored', + settledProductMinor: productOnlyMinor, + chargedFeeMinor: 0, + grossPaidMinor: Math.max(0, params.invoice.amount_paid ?? 0), + assessment: null, + }; + } + + const settledProductMinor = productOnlyMinor; + const feeLine = findFeeLine(lines, assessment); + const observedFeeMinor = feeLine + ? Math.max(0, getNetPretaxLineAmountMinor(feeLine, currency)) + : settledProductMinor === 0 + ? 0 + : assessment.outcome === 'charged' + ? assessment.chargedFeeMinor + : 0; + const grossPaidMinor = Math.max(0, params.invoice.amount_paid ?? 0); + const settledAt = + unixToDate(params.invoice.status_transitions?.paid_at) ?? + unixToDate(params.invoice.created) ?? + now; + + let current = await linkServiceFeeAssessmentStripeIds({ + store: params.store, + assessmentKey: assessment.assessmentKey, + stripeIds, + now, + }); + + if (current.outcome === 'pending') { + const canChargeObservedFee = Boolean(feeLine) || settledProductMinor === 0; + if (canChargeObservedFee && current.expectedFeeMinor > 0) { + current = await markServiceFeeAssessmentCharged({ + store: params.store, + assessmentKey: current.assessmentKey, + chargedFeeMinor: observedFeeMinor, + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: feeLine?.id ?? current.stripeInvoiceFeeLineItemId, + }, + now, + }); + } else if (current.expectedFeeMinor > 0) { + current = await markServiceFeeAssessmentMissed({ + store: params.store, + assessmentKey: current.assessmentKey, + failureCode: SERVICE_FEE_FAILURE_APPLICATION, + stripeIds, + now, + }); + } + } + + const settled = await settleServiceFeeAssessment({ + store: params.store, + assessmentKey: current.assessmentKey, + settledAt, + settledProductMinor, + grossPaidMinor, + chargedFeeMinor: current.outcome === 'charged' ? observedFeeMinor : 0, + stripeIds: { + ...stripeIds, + stripeInvoiceFeeLineItemId: feeLine?.id ?? current.stripeInvoiceFeeLineItemId, + }, + now, + }); + + const withDeviation = await recordEffectiveRateDeviation({ + store: params.store, + assessment: settled, + settledProductMinor: settled.settledProductMinor, + chargedFeeMinor: settled.chargedFeeMinor, + deps, + now, + }); + const reconciled = await applyDeferredServiceFeeRefunds({ + store: params.store, + assessment: withDeviation, + now, + }); + + return { + status: 'settled', + settledProductMinor: reconciled.settledProductMinor, + chargedFeeMinor: reconciled.chargedFeeMinor, + grossPaidMinor: reconciled.grossPaidMinor, + assessment: reconciled, + }; +} + +async function resolveSettlementAssessment(params: { + invoice: Stripe.Invoice; + lines: readonly Stripe.InvoiceLineItem[]; + store: ServiceFeeSettlementStore; +}): Promise { + const keys = uniqueNonEmpty([ + nonempty(params.invoice.metadata?.serviceFeeAssessmentKey), + nonempty(params.invoice.parent?.subscription_details?.metadata?.serviceFeeAssessmentKey), + ...params.lines.map(line => nonempty(line.metadata?.serviceFeeAssessmentKey)), + ]); + + for (const assessmentKey of keys) { + const byKey = await params.store.findByAssessmentKey(assessmentKey); + if (byKey) return byKey; + } + + if (params.invoice.id) { + const byInvoiceId = params.store.findByStripeInvoiceId + ? await params.store.findByStripeInvoiceId(params.invoice.id) + : null; + if (byInvoiceId) return byInvoiceId; + return params.store.findByAssessmentKey( + createInvoiceServiceFeeAssessmentKey(params.invoice.id) + ); + } + + return null; +} + +async function recordEffectiveRateDeviation(params: { + store: ServiceFeeSettlementStore; + assessment: ServiceFeeAssessmentRecord; + settledProductMinor: number; + chargedFeeMinor: number; + deps: KiloPassServiceFeeSettlementDependencies; + now: Date; +}): Promise { + if (params.assessment.outcome !== 'charged') { + return params.assessment; + } + + const expectedFromSettled = calculateServiceFeeMinor(params.settledProductMinor); + const deviation = Math.abs(params.chargedFeeMinor - expectedFromSettled); + if (deviation <= SERVICE_FEE_RATE_DEVIATION_THRESHOLD_MINOR) { + return params.assessment; + } + + const metadata = sanitizeServiceFeeAssessmentMetadata({ + ...params.assessment.metadata, + service_fee_rate_deviation: true, + }); + const updated = metadata.service_fee_rate_deviation + ? await params.store.update(params.assessment.assessmentKey, { metadata }) + : params.assessment; + + await alertSafely({ + assessmentKey: params.assessment.assessmentKey, + flow: params.assessment.flow, + kiloUserId: params.assessment.kiloUserId, + organizationId: params.assessment.organizationId, + stripeInvoiceId: params.assessment.stripeInvoiceId, + stripePaymentIntentId: params.assessment.stripePaymentIntentId, + stripeChargeId: params.assessment.stripeChargeId, + eligibleSubtotalMinor: params.settledProductMinor, + expectedFeeMinor: expectedFromSettled, + failureCode: SERVICE_FEE_FAILURE_RATE_DEVIATION, + deps: { + ...params.deps, + sendAlert: params.deps.sendRateDeviationAlert ?? params.deps.sendAlert, + }, + now: params.now, + }); + + return updated; +} + +function findFeeLine( + lines: readonly Stripe.InvoiceLineItem[], + assessment: ServiceFeeAssessmentRecord +): Stripe.InvoiceLineItem | undefined { + const byAssessmentKey = lines.find( + line => + isServiceFeeInvoiceLine(line) && + line.metadata?.serviceFeeAssessmentKey === assessment.assessmentKey + ); + if (byAssessmentKey) return byAssessmentKey; + + const byMetadata = lines.find(isServiceFeeInvoiceLine); + if (byMetadata) return byMetadata; + + const feePriceId = nonempty(assessment.stripeFeePriceId); + if (!feePriceId) return undefined; + return lines.find(line => invoiceLinePriceId(line) === feePriceId); +} + +function invoiceLinePriceId(line: Stripe.InvoiceLineItem): string | null { + return nonempty(line.pricing?.price_details?.price); +} + +async function loadSubscription( + invoice: Stripe.Invoice, + stripe: KiloPassServiceFeeSettlementStripe +): Promise { + const reference = invoice.parent?.subscription_details?.subscription; + if (!reference) return null; + if (typeof reference !== 'string') return reference; + if (!stripe.subscriptions?.retrieve) return null; + try { + return await stripe.subscriptions.retrieve(reference); + } catch { + return null; + } +} + +function hasPaidEvidence(invoice: Stripe.Invoice): boolean { + return invoice.status === 'paid' || typeof invoice.status_transitions?.paid_at === 'number'; +} + +function invoiceStripeIds( + invoice: Stripe.Invoice, + paymentIntentId?: string | null, + chargeId?: string | null +): ServiceFeeStripeIds { + let resolvedPaymentIntentId = nonempty(paymentIntentId); + let resolvedChargeId = nonempty(chargeId); + + for (const payment of invoice.payments?.data ?? []) { + if (payment.status && payment.status !== 'paid') continue; + const paymentRef = payment.payment; + if (!paymentRef) continue; + if (paymentRef.type === 'payment_intent' && !resolvedPaymentIntentId) { + resolvedPaymentIntentId = referenceId(paymentRef.payment_intent); + if ( + !resolvedChargeId && + paymentRef.payment_intent && + typeof paymentRef.payment_intent !== 'string' + ) { + resolvedChargeId = referenceId(paymentRef.payment_intent.latest_charge); + } + } + if (paymentRef.type === 'charge' && !resolvedChargeId) { + resolvedChargeId = referenceId(paymentRef.charge); + } + } + + return { + stripeInvoiceId: invoice.id, + stripeCustomerId: customerId(invoice.customer), + stripePaymentIntentId: resolvedPaymentIntentId, + stripeChargeId: resolvedChargeId, + }; +} + +function referenceId(value: string | { id?: string } | null | undefined): string | null { + if (typeof value === 'string') return nonempty(value); + if (value && typeof value.id === 'string') return nonempty(value.id); + return null; +} + +function customerId( + 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; +} + +function nonempty(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function uniqueNonEmpty(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function unixToDate(value: number | null | undefined): Date | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null; + return new Date(value * 1000); +} + +function ignored( + assessment: ServiceFeeAssessmentRecord | null, + grossPaidMinor: number +): KiloPassServiceFeeSettlementResult { + return { + status: 'ignored', + settledProductMinor: assessment?.settledProductMinor ?? 0, + chargedFeeMinor: assessment?.chargedFeeMinor ?? 0, + grossPaidMinor, + assessment, + }; +} + +async function alertSafely(params: { + assessmentKey: string; + flow: ServiceFeeAssessmentRecord['flow'] | 'personal_kilo_pass'; + kiloUserId?: string | null; + organizationId?: string | null; + stripeInvoiceId?: string | null; + stripePaymentIntentId?: string | null; + stripeChargeId?: string | null; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + failureCode: string; + deps: KiloPassServiceFeeSettlementDependencies; + 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, + stripePaymentIntentId: params.stripePaymentIntentId, + stripeChargeId: params.stripeChargeId, + eligibleSubtotalMinor: params.eligibleSubtotalMinor, + expectedFeeMinor: params.expectedFeeMinor, + currency: SERVICE_FEE_SUPPORTED_CURRENCY, + failureCode: params.failureCode, + attemptedAt: params.now, + }); + } catch { + // Alert failure must not change settlement outcome. + } +} diff --git a/apps/web/src/lib/service-fees/stripe-lines.test.ts b/apps/web/src/lib/service-fees/stripe-lines.test.ts new file mode 100644 index 0000000000..1b6421535d --- /dev/null +++ b/apps/web/src/lib/service-fees/stripe-lines.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, test, jest } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; +import { getKnownStripePriceIdsForKiloClaw } from '@/lib/kiloclaw/stripe-price-ids.server'; +import { SEAT_PRODUCT_IDS } from '@/lib/organizations/stripe-seat-line-items'; +import { calculateServiceFeeMinor } from '@/lib/service-fees/calculation'; +import { + SERVICE_FEE_DESCRIPTION, + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { + buildServiceFeeLineMetadata, + getEligibleKiloPassSubtotalMinor, + isEligibleKiloPassInvoiceLine, + isKiloClawInvoiceLine, + isKnownKiloPassInvoiceLine, + isSeatInvoiceLine, + isServiceFeeCheckoutLine, + isServiceFeeInvoiceLine, + isServiceFeeMetadata, + listAllInvoiceLineItems, + sumEligibleKiloPassSubtotalMinor, + type InvoiceLineItemListClient, +} from '@/lib/service-fees/stripe-lines'; + +const KILO_PASS_PRICE_ID = getKnownStripePriceIdsForKiloPass()[0]!; +const KILOCLAW_PRICE_ID = getKnownStripePriceIdsForKiloClaw()[0]!; +const SEAT_PRODUCT_ID = [...SEAT_PRODUCT_IDS][0]!; +const SEAT_PRICE_ID = process.env.STRIPE_TEAMS_MONTHLY_PRICE_ID!; + +function invoiceLine( + overrides: Partial & { + amount?: number; + currency?: string; + metadata?: Stripe.Metadata; + pricing?: Stripe.InvoiceLineItem['pricing']; + parent?: Stripe.InvoiceLineItem['parent']; + pretax_credit_amounts?: Stripe.InvoiceLineItem['pretax_credit_amounts']; + discount_amounts?: Stripe.InvoiceLineItem['discount_amounts']; + taxes?: Stripe.InvoiceLineItem['taxes']; + description?: string | null; + } +): Stripe.InvoiceLineItem { + return { + id: overrides.id ?? 'il_test', + object: 'line_item', + amount: overrides.amount ?? 4_900, + currency: overrides.currency ?? 'usd', + description: overrides.description ?? 'line', + discountable: true, + discount_amounts: overrides.discount_amounts ?? null, + discounts: [], + invoice: 'in_test', + livemode: false, + metadata: overrides.metadata ?? {}, + parent: overrides.parent ?? null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: overrides.pretax_credit_amounts ?? null, + pricing: overrides.pricing ?? null, + quantity: 1, + subscription: overrides.subscription ?? null, + taxes: overrides.taxes ?? null, + ...overrides, + } as Stripe.InvoiceLineItem; +} + +function pricedLine( + priceId: string, + amount: number, + extra: Partial[0]> = {} +) { + return invoiceLine({ + amount, + pricing: { + type: 'price_details', + unit_amount_decimal: String(amount), + price_details: { price: priceId, product: extra.pricing?.price_details?.product ?? 'prod_x' }, + }, + ...extra, + }); +} + +function invoiceWithLines( + lines: Stripe.InvoiceLineItem[], + hasMore = false +): Pick & Stripe.Invoice { + return { + id: 'in_test', + currency: 'usd', + lines: { + object: 'list', + data: lines, + has_more: hasMore, + url: '/v1/invoices/in_test/lines', + }, + } as Stripe.Invoice; +} + +describe('service fee metadata and line classifiers', () => { + test('recognizes namespaced fee metadata and ignores description-only lines', () => { + const metadata = buildServiceFeeLineMetadata('checkout:abc'); + expect(metadata).toEqual({ + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: 'checkout:abc', + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }); + expect(isServiceFeeMetadata(metadata)).toBe(true); + expect(isServiceFeeMetadata({ type: SERVICE_FEE_METADATA_TYPE })).toBe(false); + expect(isServiceFeeMetadata({ serviceFeeVersion: SERVICE_FEE_VERSION })).toBe(false); + + expect( + isServiceFeeInvoiceLine( + invoiceLine({ + description: SERVICE_FEE_DESCRIPTION, + metadata: {}, + }) + ) + ).toBe(false); + expect( + isServiceFeeInvoiceLine( + invoiceLine({ + description: SERVICE_FEE_DESCRIPTION, + metadata, + }) + ) + ).toBe(true); + }); + + test('recognizes checkout fee lines from product metadata, not description', () => { + expect( + isServiceFeeCheckoutLine({ + price_data: { + currency: 'usd', + unit_amount: 245, + product_data: { + name: SERVICE_FEE_DESCRIPTION, + metadata: buildServiceFeeLineMetadata('checkout:abc'), + }, + }, + }) + ).toBe(true); + expect( + isServiceFeeCheckoutLine({ + description: SERVICE_FEE_DESCRIPTION, + price: { + id: 'price_fee', + object: 'price', + product: { + id: 'prod_fee', + object: 'product', + metadata: buildServiceFeeLineMetadata('checkout:abc'), + }, + }, + } as unknown as Stripe.LineItem) + ).toBe(true); + expect( + isServiceFeeCheckoutLine({ + description: SERVICE_FEE_DESCRIPTION, + price: { id: 'price_pass', object: 'price', product: 'prod_pass' }, + } as Stripe.LineItem) + ).toBe(false); + }); + + test('classifies Kilo Pass, seats, and KiloClaw without treating a fee line as any of them', () => { + const kiloPassLine = pricedLine(KILO_PASS_PRICE_ID, 4_900); + const seatLine = pricedLine(SEAT_PRICE_ID, 72_000, { + pricing: { + type: 'price_details', + unit_amount_decimal: '72000', + price_details: { price: SEAT_PRICE_ID, product: SEAT_PRODUCT_ID }, + }, + }); + const freeSeatLine = pricedLine('price_free_seats', 0, { + pricing: { + type: 'price_details', + unit_amount_decimal: '0', + price_details: { price: 'price_free_seats', product: SEAT_PRODUCT_ID }, + }, + }); + const kiloClawLine = pricedLine(KILOCLAW_PRICE_ID, 20_000); + const feeLine = invoiceLine({ + amount: 245, + description: SERVICE_FEE_DESCRIPTION, + metadata: buildServiceFeeLineMetadata('invoice:in_test'), + pricing: { + type: 'price_details', + unit_amount_decimal: '245', + price_details: { price: KILO_PASS_PRICE_ID, product: 'prod_fee' }, + }, + }); + + expect(isKnownKiloPassInvoiceLine(kiloPassLine)).toBe(true); + expect(isSeatInvoiceLine(seatLine)).toBe(true); + expect(isSeatInvoiceLine(freeSeatLine)).toBe(true); + expect(isKiloClawInvoiceLine(kiloClawLine)).toBe(true); + expect(isServiceFeeInvoiceLine(feeLine)).toBe(true); + expect(isKnownKiloPassInvoiceLine(feeLine)).toBe(false); + expect(isSeatInvoiceLine(feeLine)).toBe(false); + expect(isKiloClawInvoiceLine(feeLine)).toBe(false); + expect(isEligibleKiloPassInvoiceLine(feeLine)).toBe(false); + }); +}); + +describe('eligible Kilo Pass subtotal', () => { + test('nets positive and negative Kilo Pass prorations before the fee and ignores tax fields', () => { + const lines = [ + pricedLine(KILO_PASS_PRICE_ID, 3_000, { + id: 'il_proration_debit', + taxes: [ + { + amount: 240, + tax_behavior: 'exclusive', + tax_rate_details: { tax_rate: 'txr_1' }, + taxability_reason: 'standard_rated', + taxable_amount: 3_000, + type: 'tax_rate_details', + }, + ], + }), + pricedLine(KILO_PASS_PRICE_ID, -1_000, { id: 'il_proration_credit' }), + ]; + + expect(sumEligibleKiloPassSubtotalMinor({ lines, currency: 'usd' })).toBe(2_000); + expect( + calculateServiceFeeMinor(sumEligibleKiloPassSubtotalMinor({ lines, currency: 'usd' })) + ).toBe(100); + }); + + test('excludes service-fee lines and seat-only discounts from the Kilo Pass base', () => { + const lines = [ + pricedLine(KILO_PASS_PRICE_ID, 4_900, { + id: 'il_pass', + pretax_credit_amounts: [{ amount: 980, type: 'discount', discount: 'di_pass' }], + }), + pricedLine(SEAT_PRICE_ID, 72_000, { + id: 'il_seat', + pricing: { + type: 'price_details', + unit_amount_decimal: '72000', + price_details: { price: SEAT_PRICE_ID, product: SEAT_PRODUCT_ID }, + }, + pretax_credit_amounts: [{ amount: 72_000, type: 'discount', discount: 'di_seat' }], + }), + invoiceLine({ + id: 'il_fee', + amount: 196, + description: SERVICE_FEE_DESCRIPTION, + metadata: buildServiceFeeLineMetadata('invoice:in_test'), + }), + pricedLine(KILOCLAW_PRICE_ID, 20_000, { id: 'il_claw' }), + ]; + + expect(sumEligibleKiloPassSubtotalMinor({ lines, currency: 'usd' })).toBe(3_920); + expect(calculateServiceFeeMinor(3_920)).toBe(196); + }); + + test('does not classify an unrelated item solely from subscription metadata', () => { + const unrelatedLine = pricedLine('price_unrelated', 1_000, { + id: 'il_unrelated', + subscription: 'sub_pass', + parent: { + type: 'subscription_item_details', + invoice_item_details: null, + subscription_item_details: { + invoice_item: null, + subscription: 'sub_pass', + subscription_item: 'si_unrelated', + proration: false, + proration_details: { credited_items: null }, + }, + }, + }); + const subscription = { + id: 'sub_pass', + metadata: { + type: 'kilo-pass', + kiloUserId: 'user_1', + tier: 'tier_49', + cadence: 'monthly', + }, + items: { + data: [ + { + id: 'si_unrelated', + metadata: {}, + price: { id: 'price_unrelated' }, + }, + ], + }, + } as unknown as Stripe.Subscription; + + expect(isEligibleKiloPassInvoiceLine(unrelatedLine, subscription)).toBe(false); + expect( + sumEligibleKiloPassSubtotalMinor({ + lines: [unrelatedLine], + currency: 'usd', + subscription, + }) + ).toBe(0); + }); + + test('uses aggregate rounding instead of summing per-line fees', () => { + const lines = [ + pricedLine(KILO_PASS_PRICE_ID, 10, { id: 'il_a' }), + pricedLine(KILO_PASS_PRICE_ID, 10, { id: 'il_b' }), + ]; + const subtotal = sumEligibleKiloPassSubtotalMinor({ lines, currency: 'usd' }); + + expect(subtotal).toBe(20); + expect(calculateServiceFeeMinor(subtotal)).toBe(1); + expect(calculateServiceFeeMinor(10) + calculateServiceFeeMinor(10)).toBe(2); + }); + + test('clamps a net-negative eligible subtotal to zero', () => { + expect( + sumEligibleKiloPassSubtotalMinor({ + lines: [pricedLine(KILO_PASS_PRICE_ID, -500, { id: 'il_credit_only' })], + currency: 'usd', + }) + ).toBe(0); + }); +}); + +describe('listAllInvoiceLineItems', () => { + test('uses embedded lines when the invoice is not paginated', async () => { + const listLineItems = jest.fn(); + const embedded = [pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_embedded' })]; + + await expect( + listAllInvoiceLineItems({ + invoice: invoiceWithLines(embedded, false), + stripe: { invoices: { listLineItems } }, + }) + ).resolves.toEqual(embedded); + expect(listLineItems).not.toHaveBeenCalled(); + }); + + test('retrieves every page through the injected Stripe client when has_more is true', async () => { + const firstPage = [pricedLine(KILO_PASS_PRICE_ID, 10, { id: 'il_1' })]; + const secondPage = [pricedLine(KILO_PASS_PRICE_ID, 10, { id: 'il_2' })]; + const thirdPage = [pricedLine(KILO_PASS_PRICE_ID, 4_900, { id: 'il_3' })]; + const listLineItems = jest + .fn() + .mockResolvedValueOnce({ data: firstPage, has_more: true }) + .mockResolvedValueOnce({ data: secondPage, has_more: true }) + .mockResolvedValueOnce({ data: thirdPage, has_more: false }); + + const invoice = invoiceWithLines( + [pricedLine(KILO_PASS_PRICE_ID, 10, { id: 'il_stale_embedded' })], + true + ); + const lines = await listAllInvoiceLineItems({ + invoice, + stripe: { invoices: { listLineItems } }, + }); + + expect(lines.map(line => line.id)).toEqual(['il_1', 'il_2', 'il_3']); + expect(listLineItems).toHaveBeenNthCalledWith(1, 'in_test', { limit: 100 }); + expect(listLineItems).toHaveBeenNthCalledWith(2, 'in_test', { + limit: 100, + starting_after: 'il_1', + }); + expect(listLineItems).toHaveBeenNthCalledWith(3, 'in_test', { + limit: 100, + starting_after: 'il_2', + }); + + await expect( + getEligibleKiloPassSubtotalMinor({ + invoice, + stripe: { + invoices: { + listLineItems: jest + .fn() + .mockResolvedValueOnce({ + data: [...firstPage, ...secondPage, ...thirdPage], + has_more: false, + }), + }, + }, + }) + ).resolves.toBe(4_920); + }); +}); diff --git a/apps/web/src/lib/service-fees/stripe-lines.ts b/apps/web/src/lib/service-fees/stripe-lines.ts new file mode 100644 index 0000000000..42fc79cc74 --- /dev/null +++ b/apps/web/src/lib/service-fees/stripe-lines.ts @@ -0,0 +1,275 @@ +import 'server-only'; + +import type Stripe from 'stripe'; + +import { + STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, + STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, + STRIPE_TEAMS_ANNUAL_PRICE_ID, + STRIPE_TEAMS_MONTHLY_PRICE_ID, +} from '@/lib/config.server'; +import { getOrganizationKiloPassMetadata } from '@/lib/kilo-pass-org/stripe-metadata'; +import { getKiloPassMetadataFromStripeMetadata } from '@/lib/kilo-pass/stripe-handlers-metadata'; +import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; +import { getKnownStripePriceIdsForKiloClaw } from '@/lib/kiloclaw/stripe-price-ids.server'; +import { SEAT_PRODUCT_IDS, isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; +import { getNetPretaxLineAmountMinor } from '@/lib/service-fees/calculation'; +import { + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import type { + ServiceFeeCommercialMetadata, + ServiceFeeFlow, + ServiceFeeLineMetadata, +} from '@/lib/service-fees/types'; + +const INVOICE_LINE_PAGE_SIZE = 100; + +const KNOWN_SEAT_PRICE_IDS = new Set( + [ + STRIPE_TEAMS_MONTHLY_PRICE_ID, + STRIPE_TEAMS_ANNUAL_PRICE_ID, + STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, + STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, + ].filter((priceId): priceId is string => Boolean(priceId && priceId.trim())) +); + +export type InvoiceLineItemListClient = { + invoices: { + listLineItems: ( + invoiceId: string, + params?: Stripe.InvoiceListLineItemsParams + ) => PromiseLike, 'data' | 'has_more'>>; + }; +}; + +export type CheckoutLineLike = Stripe.LineItem | Stripe.Checkout.SessionCreateParams.LineItem; + +export function buildServiceFeeLineMetadata(assessmentKey: string): ServiceFeeLineMetadata { + return { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: assessmentKey, + serviceFeeRateBasisPoints: String( + SERVICE_FEE_RATE_BASIS_POINTS + ) as ServiceFeeLineMetadata['serviceFeeRateBasisPoints'], + }; +} + +export function buildServiceFeeCommercialMetadata(input: { + assessmentKey: string; + flow: ServiceFeeFlow; + principalMinor?: number; + organizationId?: string; +}): ServiceFeeCommercialMetadata { + return { + serviceFeeAssessmentKey: input.assessmentKey, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeFlow: input.flow, + ...(input.principalMinor !== undefined + ? { serviceFeePrincipalMinor: String(input.principalMinor) } + : {}), + ...(input.organizationId ? { serviceFeeOrganizationId: input.organizationId } : {}), + }; +} + +export function isServiceFeeMetadata( + metadata: Stripe.Metadata | Stripe.MetadataParam | null | undefined +): boolean { + return ( + metadata?.type === SERVICE_FEE_METADATA_TYPE && + metadata.serviceFeeVersion === SERVICE_FEE_VERSION + ); +} + +export function isServiceFeeInvoiceLine(line: Stripe.InvoiceLineItem): boolean { + return isServiceFeeMetadata(line.metadata); +} + +export function isServiceFeeCheckoutLine(line: CheckoutLineLike): boolean { + if ('price_data' in line && isServiceFeeMetadata(line.price_data?.product_data?.metadata)) { + return true; + } + + if (!('price' in line) || !line.price || typeof line.price === 'string') { + return false; + } + + if (isServiceFeeMetadata(line.price.metadata)) return true; + + const product = line.price.product; + if (!product || typeof product === 'string' || product.deleted) return false; + return isServiceFeeMetadata(product.metadata); +} + +export function isKnownKiloPassInvoiceLine( + line: Stripe.InvoiceLineItem, + subscription?: Stripe.Subscription | null +): boolean { + if ( + isServiceFeeInvoiceLine(line) || + isSeatInvoiceLine(line, subscription) || + isKiloClawInvoiceLine(line) + ) { + return false; + } + + const priceId = getInvoiceLinePriceId(line); + if (priceId && getKnownKiloPassPriceIdSet().has(priceId)) return true; + + if ( + getKiloPassMetadataFromStripeMetadata(line.metadata) || + getOrganizationKiloPassMetadata(line.metadata) + ) { + return true; + } + + if (!subscription || !lineBelongsToSubscription(line, subscription)) return false; + + const subscriptionItemId = getInvoiceLineSubscriptionItemId(line); + if (!subscriptionItemId) return false; + const item = subscription.items?.data.find(candidate => candidate.id === subscriptionItemId); + if (!item) return false; + + return ( + getKnownKiloPassPriceIdSet().has(item.price.id) || + getKiloPassMetadataFromStripeMetadata(item.metadata) !== null || + getOrganizationKiloPassMetadata(item.metadata) !== null + ); +} + +export function isSeatInvoiceLine( + line: Stripe.InvoiceLineItem, + subscription?: Stripe.Subscription | null +): boolean { + if (isServiceFeeInvoiceLine(line)) return false; + + const productId = getInvoiceLineProductId(line); + if (productId && SEAT_PRODUCT_IDS.has(productId)) return true; + + const priceId = getInvoiceLinePriceId(line); + if (priceId && KNOWN_SEAT_PRICE_IDS.has(priceId)) return true; + + if (!subscription) return false; + const subscriptionItemId = getInvoiceLineSubscriptionItemId(line); + if (!subscriptionItemId) return false; + const item = subscription.items?.data.find(candidate => candidate.id === subscriptionItemId); + return item ? isSeatLineItem(item) : false; +} + +export function isKiloClawInvoiceLine(line: Stripe.InvoiceLineItem): boolean { + if (isServiceFeeInvoiceLine(line)) return false; + + const priceId = getInvoiceLinePriceId(line); + if (priceId && getKnownKiloClawPriceIdSet().has(priceId)) return true; + + return line.metadata?.type === 'kiloclaw'; +} + +export function isEligibleKiloPassInvoiceLine( + line: Stripe.InvoiceLineItem, + subscription?: Stripe.Subscription | null +): boolean { + return isKnownKiloPassInvoiceLine(line, subscription); +} + +export function sumEligibleKiloPassSubtotalMinor(input: { + lines: readonly Stripe.InvoiceLineItem[]; + currency: string; + subscription?: Stripe.Subscription | null; +}): number { + let total = BigInt(0); + for (const line of input.lines) { + if (!isEligibleKiloPassInvoiceLine(line, input.subscription)) continue; + total += BigInt(getNetPretaxLineAmountMinor(line, input.currency)); + } + if (total <= BigInt(0)) return 0; + if (total > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('eligible subtotal exceeds safe integer range'); + } + return Number(total); +} + +export async function listAllInvoiceLineItems(input: { + invoice: Pick; + stripe: InvoiceLineItemListClient; +}): Promise { + if (!input.invoice.lines?.has_more) { + return input.invoice.lines?.data ?? []; + } + if (!input.invoice.id) { + throw new Error('invoice id is required to list all invoice lines'); + } + + const lines: Stripe.InvoiceLineItem[] = []; + let startingAfter: string | undefined; + + for (;;) { + const page = await input.stripe.invoices.listLineItems(input.invoice.id, { + limit: INVOICE_LINE_PAGE_SIZE, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + lines.push(...page.data); + if (!page.has_more) break; + const cursor = page.data.at(-1)?.id; + if (!cursor) { + throw new Error(`invoice ${input.invoice.id} line page is marked has_more without a cursor`); + } + startingAfter = cursor; + } + + return lines; +} + +export async function getEligibleKiloPassSubtotalMinor(input: { + invoice: Stripe.Invoice; + stripe: InvoiceLineItemListClient; + subscription?: Stripe.Subscription | null; +}): Promise { + const lines = await listAllInvoiceLineItems({ + invoice: input.invoice, + stripe: input.stripe, + }); + return sumEligibleKiloPassSubtotalMinor({ + lines, + currency: input.invoice.currency, + subscription: input.subscription, + }); +} + +function getInvoiceLinePriceId(line: Stripe.InvoiceLineItem): string | null { + return line.pricing?.price_details?.price ?? null; +} + +function getInvoiceLineProductId(line: Stripe.InvoiceLineItem): string | null { + return line.pricing?.price_details?.product ?? null; +} + +function getInvoiceLineSubscriptionItemId(line: Stripe.InvoiceLineItem): string | null { + const itemId = line.parent?.subscription_item_details?.subscription_item; + return typeof itemId === 'string' ? itemId : null; +} + +function lineBelongsToSubscription( + line: Stripe.InvoiceLineItem, + subscription: Stripe.Subscription +): boolean { + const lineSubscriptionId = + typeof line.subscription === 'string' ? line.subscription : (line.subscription?.id ?? null); + const parentSubscriptionId = line.parent?.subscription_item_details?.subscription ?? null; + return lineSubscriptionId === subscription.id || parentSubscriptionId === subscription.id; +} + +function getKnownKiloPassPriceIdSet(): Set { + return new Set(getKnownStripePriceIdsForKiloPass()); +} + +function getKnownKiloClawPriceIdSet(): Set { + try { + return new Set(getKnownStripePriceIdsForKiloClaw()); + } catch { + return new Set(); + } +} diff --git a/apps/web/src/lib/service-fees/tax.test.ts b/apps/web/src/lib/service-fees/tax.test.ts new file mode 100644 index 0000000000..7f032765dd --- /dev/null +++ b/apps/web/src/lib/service-fees/tax.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test, jest } from '@jest/globals'; + +import { + buildInheritedInlineServiceFeeTaxInput, + readServiceFeeTaxBehaviorFromPrice, + resolveServiceFeeTaxInput, + type StripePriceTaxReader, +} from '@/lib/service-fees/tax'; + +function priceReader(retrieve: StripePriceTaxReader['prices']['retrieve']): StripePriceTaxReader { + return { prices: { retrieve } }; +} + +describe('service fee tax input', () => { + test('public resolver inherits inline treatment and mirrors Price tax_behavior', async () => { + const retrieve = jest.fn(async id => ({ + id, + tax_behavior: 'exclusive', + })); + + await expect( + resolveServiceFeeTaxInput({ + principal: { kind: 'price', priceId: 'price_1' }, + stripe: priceReader(retrieve), + }) + ).resolves.toEqual({ source: 'price', taxBehavior: 'exclusive' }); + await expect(resolveServiceFeeTaxInput({ principal: { kind: 'inline' } })).resolves.toEqual({ + source: 'inline_inherit', + }); + expect(retrieve).toHaveBeenCalledWith('price_1'); + }); + + test('Price-based resolution requires a Stripe reader', async () => { + await expect( + resolveServiceFeeTaxInput({ principal: { kind: 'price', priceId: 'price_1' } }) + ).rejects.toThrow('service_fee_tax_behavior_unresolved'); + }); + + test('inline helper represents inherited treatment without Price retrieval', () => { + expect(buildInheritedInlineServiceFeeTaxInput()).toEqual({ source: 'inline_inherit' }); + }); + + test('price helper mirrors exclusive and inclusive tax_behavior', async () => { + await expect( + readServiceFeeTaxBehaviorFromPrice({ + stripe: priceReader(async () => ({ id: 'price_1', tax_behavior: 'exclusive' })), + priceId: 'price_1', + }) + ).resolves.toEqual({ source: 'price', taxBehavior: 'exclusive' }); + + await expect( + readServiceFeeTaxBehaviorFromPrice({ + stripe: priceReader(async () => ({ id: 'price_2', tax_behavior: 'inclusive' })), + priceId: 'price_2', + }) + ).resolves.toEqual({ source: 'price', taxBehavior: 'inclusive' }); + }); + + test('price helper rejects unspecified behavior and propagates retrieval failure', async () => { + await expect( + readServiceFeeTaxBehaviorFromPrice({ + stripe: priceReader(async () => ({ id: 'price_2', tax_behavior: 'unspecified' })), + priceId: 'price_2', + }) + ).rejects.toThrow('service_fee_tax_behavior_unresolved'); + + await expect( + readServiceFeeTaxBehaviorFromPrice({ + stripe: priceReader(async () => { + throw new Error('stripe retrieval failed'); + }), + priceId: 'price_3', + }) + ).rejects.toThrow('stripe retrieval failed'); + }); +}); diff --git a/apps/web/src/lib/service-fees/tax.ts b/apps/web/src/lib/service-fees/tax.ts new file mode 100644 index 0000000000..902734e177 --- /dev/null +++ b/apps/web/src/lib/service-fees/tax.ts @@ -0,0 +1,64 @@ +import 'server-only'; + +import type Stripe from 'stripe'; + +export type ServiceFeeTaxBehavior = Extract; + +export type ServiceFeeTaxPrincipal = { kind: 'inline' } | { kind: 'price'; priceId: string }; + +export type ServiceFeeTaxInput = { + source: 'inline_inherit' | 'price'; + taxBehavior?: ServiceFeeTaxBehavior; +}; + +export type StripePriceTaxReader = { + prices: { + retrieve( + id: string, + params?: Stripe.PriceRetrieveParams + ): Promise>; + }; +}; + +/** + * Inline principal lines omit `tax_behavior`. A fee line built the same way + * inherits identical treatment without retrieving a Price. + */ +export function buildInheritedInlineServiceFeeTaxInput(): ServiceFeeTaxInput { + return { source: 'inline_inherit' }; +} + +export async function readServiceFeeTaxBehaviorFromPrice(params: { + stripe: StripePriceTaxReader; + priceId: string; +}): Promise { + const price = await params.stripe.prices.retrieve(params.priceId); + if (price.tax_behavior !== 'exclusive' && price.tax_behavior !== 'inclusive') { + throw new Error('service_fee_tax_behavior_unresolved'); + } + return { + source: 'price', + taxBehavior: price.tax_behavior, + }; +} + +/** + * Finance/tax treatment was confirmed on 2026-08-11: the service-fee line + * follows the eligible product's Stripe tax behavior. Inline fee lines inherit + * treatment; Price-based fee lines mirror an explicit inclusive/exclusive value. + */ +export async function resolveServiceFeeTaxInput(params: { + principal: ServiceFeeTaxPrincipal; + stripe?: StripePriceTaxReader; +}): Promise { + if (params.principal.kind === 'inline') { + return buildInheritedInlineServiceFeeTaxInput(); + } + if (!params.stripe) { + throw new Error('service_fee_tax_behavior_unresolved'); + } + return readServiceFeeTaxBehaviorFromPrice({ + stripe: params.stripe, + priceId: params.principal.priceId, + }); +} diff --git a/apps/web/src/lib/service-fees/types.ts b/apps/web/src/lib/service-fees/types.ts new file mode 100644 index 0000000000..1fb6e8610d --- /dev/null +++ b/apps/web/src/lib/service-fees/types.ts @@ -0,0 +1,170 @@ +import type Stripe from 'stripe'; + +import type { + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; + +export const SERVICE_FEE_FLOWS = [ + 'personal_top_up', + 'organization_top_up', + 'personal_auto_top_up_setup', + 'organization_auto_top_up_setup', + 'personal_auto_top_up', + 'organization_auto_top_up', + 'personal_kilo_pass', + 'organization_kilo_pass', +] as const; + +export type ServiceFeeFlow = (typeof SERVICE_FEE_FLOWS)[number]; + +export const PERSONAL_SERVICE_FEE_FLOWS = [ + 'personal_top_up', + 'personal_auto_top_up_setup', + 'personal_auto_top_up', + 'personal_kilo_pass', +] as const satisfies readonly ServiceFeeFlow[]; + +export type PersonalServiceFeeFlow = (typeof PERSONAL_SERVICE_FEE_FLOWS)[number]; + +export const ORGANIZATION_SERVICE_FEE_FLOWS = [ + 'organization_top_up', + 'organization_auto_top_up_setup', + 'organization_auto_top_up', + 'organization_kilo_pass', +] as const satisfies readonly ServiceFeeFlow[]; + +export type OrganizationServiceFeeFlow = (typeof ORGANIZATION_SERVICE_FEE_FLOWS)[number]; + +export const SERVICE_FEE_OUTCOMES = [ + 'pending', + 'charged', + 'exempt', + 'pre_activation', + 'zero_rounded', + 'unsupported_currency', + 'missed', +] as const; + +export type ServiceFeeOutcome = (typeof SERVICE_FEE_OUTCOMES)[number]; + +export const SERVICE_FEE_OWNER_KINDS = ['personal', 'organization'] as const; + +export type ServiceFeeOwnerKind = (typeof SERVICE_FEE_OWNER_KINDS)[number]; + +export type ServiceFeePersonalOwner = { + kind: 'personal'; + kiloUserId: string; +}; + +export type ServiceFeeOrganizationOwner = { + kind: 'organization'; + organizationId: string; + kiloUserId?: string; +}; + +export type ServiceFeeOwner = ServiceFeePersonalOwner | ServiceFeeOrganizationOwner; + +export const SERVICE_FEE_SUPPORTED_CURRENCY = 'usd'; + +export type ServiceFeeSupportedCurrency = typeof SERVICE_FEE_SUPPORTED_CURRENCY; + +export type ServiceFeeLineMetadata = { + type: typeof SERVICE_FEE_METADATA_TYPE; + serviceFeeVersion: typeof SERVICE_FEE_VERSION; + serviceFeeAssessmentKey: string; + serviceFeeRateBasisPoints: `${typeof SERVICE_FEE_RATE_BASIS_POINTS}`; +}; + +export type ServiceFeeCommercialMetadata = { + serviceFeeAssessmentKey: string; + serviceFeeVersion: typeof SERVICE_FEE_VERSION; + serviceFeeFlow: ServiceFeeFlow; + serviceFeePrincipalMinor?: string; + serviceFeeOrganizationId?: string; +}; + +export type PrepareAssessmentInput = { + assessmentKey: string; + flow: ServiceFeeFlow; + currency: string; + eligibilityCreatedAt: Date; + eligibleSubtotalMinor: number; + kiloUserId?: string; + organizationId?: string; + stripeCustomerId?: string; +}; + +export type FeeApplicationResult = { + assessmentId: string | null; + assessmentKey: string; + outcome: ServiceFeeOutcome; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + chargedFeeMinor: number; + checkoutLineItem?: Stripe.Checkout.SessionCreateParams.LineItem; +}; + +export type CalculateCumulativeFeeRefundInput = { + originalProductMinor: number; + originalFeeMinor: number; + cumulativeProductRefundMinor: number; +}; + +const PERSONAL_SERVICE_FEE_FLOW_SET = new Set(PERSONAL_SERVICE_FEE_FLOWS); +const ORGANIZATION_SERVICE_FEE_FLOW_SET = new Set(ORGANIZATION_SERVICE_FEE_FLOWS); + +export function isServiceFeeFlow(value: string): value is ServiceFeeFlow { + return (SERVICE_FEE_FLOWS as readonly string[]).includes(value); +} + +export function isServiceFeeOutcome(value: string): value is ServiceFeeOutcome { + return (SERVICE_FEE_OUTCOMES as readonly string[]).includes(value); +} + +export function isPersonalServiceFeeFlow(flow: ServiceFeeFlow): flow is PersonalServiceFeeFlow { + return PERSONAL_SERVICE_FEE_FLOW_SET.has(flow); +} + +export function isOrganizationServiceFeeFlow( + flow: ServiceFeeFlow +): flow is OrganizationServiceFeeFlow { + return ORGANIZATION_SERVICE_FEE_FLOW_SET.has(flow); +} + +export function isSupportedServiceFeeCurrency( + currency: string +): currency is ServiceFeeSupportedCurrency { + return currency === SERVICE_FEE_SUPPORTED_CURRENCY; +} + +export function getServiceFeeOwnerKind(flow: ServiceFeeFlow): ServiceFeeOwnerKind { + return isOrganizationServiceFeeFlow(flow) ? 'organization' : 'personal'; +} + +export function getServiceFeeOwner( + flow: ServiceFeeFlow, + input: Pick +): ServiceFeeOwner { + if (isOrganizationServiceFeeFlow(flow)) { + if (!input.organizationId) { + throw new Error(`organization service-fee flow ${flow} requires organizationId`); + } + return input.kiloUserId + ? { + kind: 'organization', + organizationId: input.organizationId, + kiloUserId: input.kiloUserId, + } + : { kind: 'organization', organizationId: input.organizationId }; + } + + if (!input.kiloUserId) { + throw new Error(`personal service-fee flow ${flow} requires kiloUserId`); + } + if (input.organizationId) { + throw new Error(`personal service-fee flow ${flow} forbids organizationId`); + } + return { kind: 'personal', kiloUserId: input.kiloUserId }; +} diff --git a/apps/web/src/lib/stripe-client.ts b/apps/web/src/lib/stripe-client.ts index 260fb55c69..fb6b122fcb 100644 --- a/apps/web/src/lib/stripe-client.ts +++ b/apps/web/src/lib/stripe-client.ts @@ -10,7 +10,9 @@ if (!stripeSecretKey) { const skipStripeApi = process.env.NODE_ENV !== 'production' && process.env.SKIP_STRIPE_API === 'true'; -export const client: Stripe = new Stripe(stripeSecretKey); +export const client: Stripe = new Stripe(stripeSecretKey, { + apiVersion: '2025-10-29.clover', +}); type ConstrainedMetadata = UserConstrainedMetadata | OrganizationConstrainedMetdata;