From 0c1690a5cb1f157b59e3b6d17872a0056103776e Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:45:50 +0800 Subject: [PATCH 1/6] feat(billing): add prepaid payment backend --- .../billing-edge-cases.integration.spec.ts | 483 ++++++++++++++++ apps/api/src/billing/billing.module.ts | 24 +- .../entities/payment-provider-event.entity.ts | 22 + .../billing/entities/top-up-record.entity.ts | 64 +++ .../entities/wallet-transaction.entity.ts | 2 +- .../api/src/billing/entities/wallet.entity.ts | 24 + .../billing/payment/fake-payment.provider.ts | 49 ++ .../payment/payment-provider.factory.ts | 22 + .../billing/payment/payment-provider.spec.ts | 268 +++++++++ .../src/billing/payment/payment-provider.ts | 85 +++ .../payment/payment.controller.spec.ts | 66 +++ .../src/billing/payment/payment.controller.ts | 100 ++++ .../billing/payment/payment.service.spec.ts | 402 +++++++++++++ .../src/billing/payment/payment.service.ts | 526 ++++++++++++++++++ .../payment/stripe-payment.provider.ts | 289 ++++++++++ .../src/billing/settlement.service.spec.ts | 27 + apps/api/src/config/configuration.ts | 7 + apps/api/src/main.ts | 1 + .../1782700300000-migration.spec.ts | 42 ++ .../pre-deploy/1782700300000-migration.ts | 83 +++ apps/package.json | 2 + apps/yarn.lock | 13 + 22 files changed, 2598 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/billing/billing-edge-cases.integration.spec.ts create mode 100644 apps/api/src/billing/entities/payment-provider-event.entity.ts create mode 100644 apps/api/src/billing/entities/top-up-record.entity.ts create mode 100644 apps/api/src/billing/payment/fake-payment.provider.ts create mode 100644 apps/api/src/billing/payment/payment-provider.factory.ts create mode 100644 apps/api/src/billing/payment/payment-provider.spec.ts create mode 100644 apps/api/src/billing/payment/payment-provider.ts create mode 100644 apps/api/src/billing/payment/payment.controller.spec.ts create mode 100644 apps/api/src/billing/payment/payment.controller.ts create mode 100644 apps/api/src/billing/payment/payment.service.spec.ts create mode 100644 apps/api/src/billing/payment/payment.service.ts create mode 100644 apps/api/src/billing/payment/stripe-payment.provider.ts create mode 100644 apps/api/src/migrations/pre-deploy/1782700300000-migration.spec.ts create mode 100644 apps/api/src/migrations/pre-deploy/1782700300000-migration.ts diff --git a/apps/api/src/billing/billing-edge-cases.integration.spec.ts b/apps/api/src/billing/billing-edge-cases.integration.spec.ts new file mode 100644 index 000000000..4d84c8065 --- /dev/null +++ b/apps/api/src/billing/billing-edge-cases.integration.spec.ts @@ -0,0 +1,483 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import 'reflect-metadata' +import Decimal from 'decimal.js' +import { randomUUID } from 'node:crypto' +import { DataSource } from 'typeorm' +import { BoxUsagePeriodArchive } from '../usage/entities/box-usage-period-archive.entity' +import { PaymentProviderEvent } from './entities/payment-provider-event.entity' +import { PricingPlan } from './entities/pricing-plan.entity' +import { RatedPeriod } from './entities/rated-period.entity' +import { TopUpRecord } from './entities/top-up-record.entity' +import { WalletTransaction } from './entities/wallet-transaction.entity' +import { Wallet } from './entities/wallet.entity' +import { FakePaymentProvider } from './payment/fake-payment.provider' +import type { + PaymentProvider, + ProviderWebhookEvent, + TopUpPaymentInput, + TopUpPaymentResult, +} from './payment/payment-provider' +import { PaymentService } from './payment/payment.service' +import { RatingService } from './rating/rating.service' +import { SettlementService } from './settlement.service' +import { WalletService } from './wallet.service' + +const RUN_DATABASE_TESTS = process.env.BILLING_EDGE_DB_TESTS === '1' +const describeWithDatabase = RUN_DATABASE_TESTS ? describe : describe.skip +const schemaName = `billing_edge_${randomUUID().replaceAll('-', '')}` +const migratedTables = [ + 'wallet', + 'wallet_transaction', + 'rated_period', + 'pricing_plan', + 'box_usage_period_archive', + 'top_up_record', + 'payment_provider_event', +] as const + +class AmbiguousThenSuccessfulProvider extends FakePaymentProvider { + readonly calls: TopUpPaymentInput[] = [] + + override async createManualTopUp(input: TopUpPaymentInput): Promise { + this.calls.push(input) + if (this.calls.length === 1) throw new Error('provider response was lost') + return super.createManualTopUp(input) + } +} + +class TestWebhookPaymentProvider extends FakePaymentProvider { + override async parseWebhook(payload: Buffer): Promise { + return JSON.parse(payload.toString('utf8')) as ProviderWebhookEvent + } +} + +function connectionOptions() { + return { + type: 'postgres' as const, + host: process.env.BILLING_EDGE_DB_HOST ?? process.env.DB_HOST ?? '127.0.0.1', + port: Number(process.env.BILLING_EDGE_DB_PORT ?? process.env.DB_PORT ?? '25432'), + username: process.env.BILLING_EDGE_DB_USERNAME ?? process.env.DB_USERNAME ?? 'boxlite', + password: process.env.BILLING_EDGE_DB_PASSWORD ?? process.env.DB_PASSWORD ?? 'boxlite', + database: process.env.BILLING_EDGE_DB_DATABASE ?? process.env.DB_DATABASE ?? 'boxlite', + } +} + +describeWithDatabase('Billing common edge cases with PostgreSQL', () => { + let controlDataSource: DataSource + let billingDataSource: DataSource + + beforeAll(async () => { + if (!/^billing_edge_[a-f0-9]+$/.test(schemaName)) { + throw new Error(`unsafe billing edge-case schema name: ${schemaName}`) + } + + controlDataSource = await new DataSource(connectionOptions()).initialize() + await controlDataSource.query(`CREATE SCHEMA "${schemaName}"`) + for (const tableName of migratedTables) { + await controlDataSource.query( + `CREATE TABLE "${schemaName}"."${tableName}" (LIKE public."${tableName}" INCLUDING ALL)`, + ) + } + billingDataSource = await new DataSource({ + ...connectionOptions(), + schema: schemaName, + entities: [ + Wallet, + WalletTransaction, + RatedPeriod, + PricingPlan, + BoxUsagePeriodArchive, + TopUpRecord, + PaymentProviderEvent, + ], + synchronize: false, + }).initialize() + }, 30_000) + + afterEach(async () => { + await billingDataSource.getRepository(PaymentProviderEvent).clear() + await billingDataSource.getRepository(WalletTransaction).clear() + await billingDataSource.getRepository(TopUpRecord).clear() + await billingDataSource.getRepository(RatedPeriod).clear() + await billingDataSource.getRepository(BoxUsagePeriodArchive).clear() + await billingDataSource.getRepository(PricingPlan).clear() + await billingDataSource.getRepository(Wallet).clear() + }) + + afterAll(async () => { + if (billingDataSource?.isInitialized) await billingDataSource.destroy() + if (controlDataSource?.isInitialized) { + await controlDataSource.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`) + await controlDataSource.destroy() + } + }) + + function walletService(): WalletService { + return new WalletService(billingDataSource.getRepository(Wallet), billingDataSource.getRepository(RatedPeriod), { + get: (key: string) => { + if (key === 'billing.trialGrantCents') return 10_000 + if (key === 'billing.trialDurationDays') return 30 + throw new Error(`unexpected billing config key ${key}`) + }, + } as never) + } + + function paymentService( + wallets: WalletService, + provider: PaymentProvider = new FakePaymentProvider(), + ): PaymentService { + return new PaymentService( + billingDataSource.getRepository(Wallet), + billingDataSource.getRepository(TopUpRecord), + wallets, + provider, + { getOrThrow: () => 'http://localhost:3000' } as never, + ) + } + + async function createWallet(organizationId: string, overrides: Partial = {}): Promise { + const repository = billingDataSource.getRepository(Wallet) + return repository.save( + repository.create({ + organizationId, + freeBalanceCents: '0', + paidBalanceCents: '1000', + settlementRemainderCents: '0', + freeExpiresAt: null, + billingStatus: 'active', + paymentProviderCustomerId: null, + paymentProviderMethodId: null, + paymentMethodBrand: null, + paymentMethodLast4: null, + autoReloadEnabled: false, + autoReloadThresholdCents: null, + autoReloadTargetCents: null, + autoReloadNextAttemptAt: null, + ...overrides, + }), + ) + } + + async function createRatedPeriod(organizationId: string, preciseCents = '300'): Promise { + const repository = billingDataSource.getRepository(RatedPeriod) + return repository.save( + repository.create({ + usagePeriodArchiveId: randomUUID(), + organizationId, + boxId: `edge-box-${randomUUID()}`, + pricingSegments: [], + usageTotals: { cpuSeconds: '60', memGibSeconds: '0', diskGibSeconds: '0', gpuSeconds: '0' }, + billedSeconds: '60', + preciseCents, + ratedCents: preciseCents, + ratedAt: new Date(), + }), + ) + } + + it('creates exactly one immutable rating when two workers rate the same archived period', async () => { + const archiveRepository = billingDataSource.getRepository(BoxUsagePeriodArchive) + const planRepository = billingDataSource.getRepository(PricingPlan) + const ratedRepository = billingDataSource.getRepository(RatedPeriod) + const startAt = new Date('1900-01-01T00:00:00.000Z') + const endAt = new Date('1900-01-01T00:01:00.000Z') + const archive = await archiveRepository.save( + archiveRepository.create({ + boxId: `edge-box-${randomUUID()}`, + organizationId: randomUUID(), + startAt, + endAt, + cpu: 1, + mem: 0, + disk: 0, + gpu: 0, + region: 'us', + }), + ) + const plan = await planRepository.save( + planRepository.create({ + version: 1, + cpuRateCentsPerSec: '0.01', + memRateCentsPerSec: '0', + diskRateCentsPerSec: '0', + gpuRateCentsPerSec: '0', + effectiveFrom: new Date('1899-01-01T00:00:00.000Z'), + effectiveTo: new Date('1901-01-01T00:00:00.000Z'), + }), + ) + const service = new RatingService(archiveRepository, ratedRepository, planRepository) + + const results = await Promise.all([service.ratePeriod(archive), service.ratePeriod(archive)]) + + expect(results.filter(Boolean)).toHaveLength(1) + plan.cpuRateCentsPerSec = '0.02' + await planRepository.save(plan) + const rows = await ratedRepository.findBy({ usagePeriodArchiveId: archive.id }) + expect(rows).toHaveLength(1) + expect(rows[0].pricingSegments[0].pricingVersion).toBe(plan.version) + expect(new Decimal(rows[0].pricingSegments[0].unitRates.cpuRateCentsPerSec).equals('0.01')).toBe(true) + }) + + it('serializes concurrent debits so one rated period changes the wallet once', async () => { + const organizationId = randomUUID() + await createWallet(organizationId) + const period = await createRatedPeriod(organizationId) + const service = walletService() + + const results = await Promise.all([service.debitRatedPeriod(period), service.debitRatedPeriod(period)]) + + expect(results.filter(Boolean)).toHaveLength(1) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '700', + }) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ ratedPeriodId: period.id }), + ).resolves.toBe(1) + }) + + it('settles one archive exactly once when two complete settlement sweeps overlap', async () => { + const organizationId = randomUUID() + await createWallet(organizationId) + const archiveRepository = billingDataSource.getRepository(BoxUsagePeriodArchive) + const planRepository = billingDataSource.getRepository(PricingPlan) + const ratedRepository = billingDataSource.getRepository(RatedPeriod) + const startAt = new Date('1900-02-01T00:00:00.000Z') + const archive = await archiveRepository.save( + archiveRepository.create({ + boxId: `edge-box-${randomUUID()}`, + organizationId, + startAt, + endAt: new Date('1900-02-01T00:01:00.000Z'), + cpu: 1, + mem: 0, + disk: 0, + gpu: 0, + region: 'us', + }), + ) + await planRepository.save( + planRepository.create({ + version: 2, + cpuRateCentsPerSec: '1', + memRateCentsPerSec: '0', + diskRateCentsPerSec: '0', + gpuRateCentsPerSec: '0', + effectiveFrom: new Date('1900-01-01T00:00:00.000Z'), + effectiveTo: new Date('1901-01-01T00:00:00.000Z'), + }), + ) + const wallets = walletService() + const settlement = new SettlementService( + new RatingService(archiveRepository, ratedRepository, planRepository), + wallets, + ) + + await Promise.all([settlement.settleClosedPeriods(), settlement.settleClosedPeriods()]) + + await expect(ratedRepository.countBy({ usagePeriodArchiveId: archive.id })).resolves.toBe(1) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ organizationId, kind: 'usage_debit' }), + ).resolves.toBe(1) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '940', + }) + }) + + it('keeps the final balance correct when usage debit and duplicate paid webhooks race', async () => { + const organizationId = randomUUID() + const wallet = await createWallet(organizationId) + const period = await createRatedPeriod(organizationId) + const topUpRepository = billingDataSource.getRepository(TopUpRecord) + const topUp = await topUpRepository.save( + topUpRepository.create({ + walletId: wallet.id, + organizationId, + amountCents: '500', + source: 'manual', + status: 'pending', + idempotencyKey: `edge-${randomUUID()}`, + providerReference: null, + checkoutUrl: null, + receiptUrl: null, + failureCode: null, + failureMessage: null, + completedAt: null, + }), + ) + const wallets = walletService() + const payments = paymentService(wallets, new TestWebhookPaymentProvider()) + const paidEvent = (providerEventId: string) => + Buffer.from( + JSON.stringify({ + kind: 'top_up_paid', + providerEventId, + providerReference: `edge-payment-${topUp.id}`, + topUpId: topUp.id, + organizationId, + amountCents: topUp.amountCents, + currency: 'usd', + receiptUrl: 'https://receipt.test/edge', + }), + ) + const failedEvent = Buffer.from( + JSON.stringify({ + kind: 'top_up_failed', + providerEventId: `edge:${randomUUID()}`, + providerReference: `edge-payment-${topUp.id}`, + topUpId: topUp.id, + organizationId, + failureCode: 'network_timeout', + failureMessage: 'late failure', + }), + ) + const firstEventId = `edge:${randomUUID()}` + + await Promise.all([ + wallets.debitRatedPeriod(period), + payments.handleWebhook(paidEvent(firstEventId), 'test'), + payments.handleWebhook(paidEvent(firstEventId), 'test'), + payments.handleWebhook(paidEvent(`edge:${randomUUID()}`), 'test'), + payments.handleWebhook(failedEvent, 'test'), + ]) + + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '1200', + }) + await expect(billingDataSource.getRepository(WalletTransaction).findBy({ organizationId })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'usage_debit', amountCents: '-300' }), + expect.objectContaining({ kind: 'top_up', amountCents: '500' }), + ]), + ) + await expect(billingDataSource.getRepository(WalletTransaction).countBy({ organizationId })).resolves.toBe(2) + await expect(billingDataSource.getRepository(PaymentProviderEvent).count()).resolves.toBe(3) + await expect(topUpRepository.findOneByOrFail({ id: topUp.id })).resolves.toMatchObject({ status: 'paid' }) + }) + + it('claims only one auto-reload when two schedulers observe the same low balance', async () => { + const organizationId = randomUUID() + await createWallet(organizationId, { + paidBalanceCents: '900', + paymentProviderCustomerId: 'edge-customer', + paymentProviderMethodId: 'edge-method', + paymentMethodBrand: 'visa', + paymentMethodLast4: '4242', + autoReloadEnabled: true, + autoReloadThresholdCents: '1000', + autoReloadTargetCents: '5000', + }) + const payments = paymentService(walletService()) + const now = new Date('2026-07-10T00:00:00.000Z') + + const results = await Promise.all([ + payments.runAutoReloadForOrganization(organizationId, now), + payments.runAutoReloadForOrganization(organizationId, now), + ]) + + expect(results.filter(Boolean)).toHaveLength(1) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '5000', + }) + await expect( + billingDataSource.getRepository(TopUpRecord).countBy({ organizationId, source: 'auto_reload' }), + ).resolves.toBe(1) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ organizationId, kind: 'top_up' }), + ).resolves.toBe(1) + }) + + it('retries one top-up after the provider succeeds but the first local update fails', async () => { + const organizationId = randomUUID() + await createWallet(organizationId, { + paidBalanceCents: '0', + paymentProviderCustomerId: 'edge-customer', + paymentProviderMethodId: 'edge-method', + paymentMethodBrand: 'visa', + paymentMethodLast4: '4242', + }) + const payments = paymentService(walletService()) + const idempotencyKey = `edge-${randomUUID()}` + await controlDataSource.query(` + CREATE FUNCTION "${schemaName}".fail_paid_top_up_update() RETURNS trigger AS $$ + BEGIN + IF NEW."providerReference" LIKE 'fake-payment-%' THEN + RAISE EXCEPTION 'provider success persistence failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_paid_top_up_update + BEFORE UPDATE ON "${schemaName}"."top_up_record" + FOR EACH ROW EXECUTE FUNCTION "${schemaName}".fail_paid_top_up_update(); + `) + + try { + await expect(payments.createManualTopUp(organizationId, '2500', idempotencyKey)).rejects.toThrow( + 'provider success persistence failure', + ) + } finally { + await controlDataSource.query(` + DROP TRIGGER IF EXISTS fail_paid_top_up_update ON "${schemaName}"."top_up_record"; + DROP FUNCTION IF EXISTS "${schemaName}".fail_paid_top_up_update(); + `) + } + + await expect( + billingDataSource.getRepository(TopUpRecord).findOneByOrFail({ organizationId, idempotencyKey }), + ).resolves.toMatchObject({ status: 'pending' }) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '0', + }) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ organizationId, kind: 'top_up' }), + ).resolves.toBe(0) + + await expect(payments.createManualTopUp(organizationId, '2500', idempotencyKey)).resolves.toMatchObject({ + status: 'paid', + }) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '2500', + }) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ organizationId, kind: 'top_up' }), + ).resolves.toBe(1) + }) + + it('keeps an ambiguous provider response pending and retries the same top-up id', async () => { + const organizationId = randomUUID() + await createWallet(organizationId, { + paidBalanceCents: '0', + paymentProviderCustomerId: 'edge-customer', + paymentProviderMethodId: 'edge-method', + paymentMethodBrand: 'visa', + paymentMethodLast4: '4242', + }) + const provider = new AmbiguousThenSuccessfulProvider() + const payments = paymentService(walletService(), provider) + const idempotencyKey = `edge-${randomUUID()}` + + await expect(payments.createManualTopUp(organizationId, '2500', idempotencyKey)).rejects.toThrow( + 'payment provider request failed', + ) + await expect( + billingDataSource.getRepository(TopUpRecord).findOneByOrFail({ organizationId, idempotencyKey }), + ).resolves.toMatchObject({ status: 'pending', failureCode: null, completedAt: null }) + await expect(billingDataSource.getRepository(Wallet).findOneByOrFail({ organizationId })).resolves.toMatchObject({ + paidBalanceCents: '0', + }) + + await expect(payments.createManualTopUp(organizationId, '2500', idempotencyKey)).resolves.toMatchObject({ + status: 'paid', + }) + expect(provider.calls).toHaveLength(2) + expect(provider.calls[0].topUpId).toBe(provider.calls[1].topUpId) + await expect(billingDataSource.getRepository(TopUpRecord).countBy({ organizationId })).resolves.toBe(1) + await expect( + billingDataSource.getRepository(WalletTransaction).countBy({ organizationId, kind: 'top_up' }), + ).resolves.toBe(1) + }) +}) diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts index 15d9496b9..8ed644156 100644 --- a/apps/api/src/billing/billing.module.ts +++ b/apps/api/src/billing/billing.module.ts @@ -5,15 +5,22 @@ import { Module } from '@nestjs/common' import { TypeOrmModule } from '@nestjs/typeorm' +import { TypedConfigService } from '../config/typed-config.service' import { Organization } from '../organization/entities/organization.entity' import { OrganizationModule } from '../organization/organization.module' import { BoxUsagePeriodArchive } from '../usage/entities/box-usage-period-archive.entity' import { BillingController } from './billing.controller' import { BillingReadService } from './billing-read.service' +import { PaymentProviderEvent } from './entities/payment-provider-event.entity' import { PricingPlan } from './entities/pricing-plan.entity' import { RatedPeriod } from './entities/rated-period.entity' +import { TopUpRecord } from './entities/top-up-record.entity' import { WalletTransaction } from './entities/wallet-transaction.entity' import { Wallet } from './entities/wallet.entity' +import { BillingPaymentController, PaymentWebhookController } from './payment/payment.controller' +import { PAYMENT_PROVIDER } from './payment/payment-provider' +import { createPaymentProvider } from './payment/payment-provider.factory' +import { PaymentService } from './payment/payment.service' import { RatingService } from './rating/rating.service' import { SettlementService } from './settlement.service' import { WalletService } from './wallet.service' @@ -23,15 +30,28 @@ import { WalletService } from './wallet.service' OrganizationModule, TypeOrmModule.forFeature([ BoxUsagePeriodArchive, + PaymentProviderEvent, PricingPlan, RatedPeriod, + TopUpRecord, Wallet, WalletTransaction, Organization, ]), ], - controllers: [BillingController], - providers: [RatingService, WalletService, SettlementService, BillingReadService], + controllers: [BillingController, BillingPaymentController, PaymentWebhookController], + providers: [ + RatingService, + WalletService, + SettlementService, + BillingReadService, + PaymentService, + { + provide: PAYMENT_PROVIDER, + inject: [TypedConfigService], + useFactory: createPaymentProvider, + }, + ], exports: [RatingService, WalletService, SettlementService, BillingReadService], }) export class BillingModule {} diff --git a/apps/api/src/billing/entities/payment-provider-event.entity.ts b/apps/api/src/billing/entities/payment-provider-event.entity.ts new file mode 100644 index 000000000..c258ba379 --- /dev/null +++ b/apps/api/src/billing/entities/payment-provider-event.entity.ts @@ -0,0 +1,22 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' + +@Entity('payment_provider_event') +@Index('payment_provider_event_provider_id_idx', ['providerEventId'], { unique: true }) +export class PaymentProviderEvent { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column({ type: 'character varying' }) + providerEventId: string + + @Column({ type: 'character varying' }) + eventType: string + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date +} diff --git a/apps/api/src/billing/entities/top-up-record.entity.ts b/apps/api/src/billing/entities/top-up-record.entity.ts new file mode 100644 index 000000000..0141af784 --- /dev/null +++ b/apps/api/src/billing/entities/top-up-record.entity.ts @@ -0,0 +1,64 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Check, Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' + +export type TopUpSource = 'manual' | 'auto_reload' +export type TopUpStatus = 'pending' | 'paid' | 'failed' + +@Entity('top_up_record') +@Index('top_up_record_org_created_idx', ['organizationId', 'createdAt']) +@Index('top_up_record_org_idempotency_idx', ['organizationId', 'idempotencyKey'], { unique: true }) +@Index('top_up_record_provider_reference_idx', ['providerReference'], { + unique: true, + where: '"providerReference" IS NOT NULL', +}) +@Check('top_up_record_positive_amount', '"amountCents" > 0') +export class TopUpRecord { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column({ type: 'uuid' }) + walletId: string + + @Column({ type: 'uuid' }) + organizationId: string + + @Column({ type: 'bigint' }) + amountCents: string + + @Column({ type: 'character varying' }) + source: TopUpSource + + @Column({ type: 'character varying', default: 'pending' }) + status: TopUpStatus + + @Column({ type: 'character varying' }) + idempotencyKey: string + + @Column({ type: 'character varying', nullable: true }) + providerReference: string | null + + @Column({ type: 'text', nullable: true }) + checkoutUrl: string | null + + @Column({ type: 'text', nullable: true }) + receiptUrl: string | null + + @Column({ type: 'character varying', nullable: true }) + failureCode: string | null + + @Column({ type: 'text', nullable: true }) + failureMessage: string | null + + @Column({ type: 'timestamp with time zone', nullable: true }) + completedAt: Date | null + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updatedAt: Date +} diff --git a/apps/api/src/billing/entities/wallet-transaction.entity.ts b/apps/api/src/billing/entities/wallet-transaction.entity.ts index de78f670d..46ee5e9d7 100644 --- a/apps/api/src/billing/entities/wallet-transaction.entity.ts +++ b/apps/api/src/billing/entities/wallet-transaction.entity.ts @@ -5,7 +5,7 @@ import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' -export type WalletTransactionKind = 'free_grant' | 'usage_debit' | 'adjustment' +export type WalletTransactionKind = 'free_grant' | 'usage_debit' | 'adjustment' | 'top_up' @Entity('wallet_transaction') @Index('wallet_transaction_wallet_created_idx', ['walletId', 'createdAt']) diff --git a/apps/api/src/billing/entities/wallet.entity.ts b/apps/api/src/billing/entities/wallet.entity.ts index d5a886e57..c2347480c 100644 --- a/apps/api/src/billing/entities/wallet.entity.ts +++ b/apps/api/src/billing/entities/wallet.entity.ts @@ -33,6 +33,30 @@ export class Wallet { @Column({ type: 'character varying', default: 'trial' }) billingStatus: BillingStatus + @Column({ type: 'character varying', nullable: true }) + paymentProviderCustomerId: string | null + + @Column({ type: 'character varying', nullable: true }) + paymentProviderMethodId: string | null + + @Column({ type: 'character varying', nullable: true }) + paymentMethodBrand: string | null + + @Column({ type: 'character varying', nullable: true }) + paymentMethodLast4: string | null + + @Column({ type: 'boolean', default: false }) + autoReloadEnabled: boolean + + @Column({ type: 'bigint', nullable: true }) + autoReloadThresholdCents: string | null + + @Column({ type: 'bigint', nullable: true }) + autoReloadTargetCents: string | null + + @Column({ type: 'timestamp with time zone', nullable: true }) + autoReloadNextAttemptAt: Date | null + @CreateDateColumn({ type: 'timestamp with time zone' }) createdAt: Date diff --git a/apps/api/src/billing/payment/fake-payment.provider.ts b/apps/api/src/billing/payment/fake-payment.provider.ts new file mode 100644 index 000000000..3aa6fb751 --- /dev/null +++ b/apps/api/src/billing/payment/fake-payment.provider.ts @@ -0,0 +1,49 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import { + PaymentProvider, + PaymentSetupInput, + PaymentSetupResult, + ProviderWebhookEvent, + TopUpPaymentInput, + TopUpPaymentResult, +} from './payment-provider' + +export class FakePaymentProvider implements PaymentProvider { + readonly mode = 'fake' as const + + async createSetup(input: PaymentSetupInput): Promise { + return { + status: 'ready', + checkoutUrl: null, + providerReference: `fake-setup-${input.walletId}`, + providerCustomerId: input.providerCustomerId ?? `fake-customer-${input.walletId}`, + paymentMethod: { id: `fake-card-${input.walletId}`, brand: 'visa', last4: '4242' }, + } + } + + async createManualTopUp(input: TopUpPaymentInput): Promise { + return this.paidResult(input) + } + + async chargeSavedMethod(input: TopUpPaymentInput): Promise { + return this.paidResult(input) + } + + async parseWebhook(): Promise { + throw new BadRequestException('fake payment provider does not accept webhooks') + } + + private paidResult(input: TopUpPaymentInput): TopUpPaymentResult { + return { + status: 'paid', + checkoutUrl: null, + providerReference: `fake-payment-${input.topUpId}`, + receiptUrl: null, + } + } +} diff --git a/apps/api/src/billing/payment/payment-provider.factory.ts b/apps/api/src/billing/payment/payment-provider.factory.ts new file mode 100644 index 000000000..bf1dc668a --- /dev/null +++ b/apps/api/src/billing/payment/payment-provider.factory.ts @@ -0,0 +1,22 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { TypedConfigService } from '../../config/typed-config.service' +import { FakePaymentProvider } from './fake-payment.provider' +import { PaymentProvider } from './payment-provider' +import { StripePaymentProvider } from './stripe-payment.provider' + +export function createPaymentProvider(configService: TypedConfigService): PaymentProvider { + const mode = configService.get('billing.paymentProvider') + if (mode === 'fake') return new FakePaymentProvider() + if (mode !== 'stripe') throw new Error(`unsupported billing payment provider: ${String(mode)}`) + + const secretKey = configService.get('billing.stripe.secretKey') + const webhookSecret = configService.get('billing.stripe.webhookSecret') + if (!secretKey || !webhookSecret) { + throw new Error('Stripe payment provider requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET') + } + return new StripePaymentProvider(secretKey, webhookSecret) +} diff --git a/apps/api/src/billing/payment/payment-provider.spec.ts b/apps/api/src/billing/payment/payment-provider.spec.ts new file mode 100644 index 000000000..8a52dd2ad --- /dev/null +++ b/apps/api/src/billing/payment/payment-provider.spec.ts @@ -0,0 +1,268 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import type Stripe from 'stripe' +import { FakePaymentProvider } from './fake-payment.provider' +import { createPaymentProvider } from './payment-provider.factory' +import { StripePaymentProvider } from './stripe-payment.provider' + +describe('PaymentProvider implementations', () => { + it('lets local E2E attach a deterministic fake card and complete payments immediately', async () => { + const provider = new FakePaymentProvider() + + await expect( + provider.createSetup({ + organizationId: 'org-1', + walletId: 'wallet-1', + setupAttemptId: 'attempt-1', + providerCustomerId: null, + successUrl: 'http://dashboard.test/success', + cancelUrl: 'http://dashboard.test/cancel', + }), + ).resolves.toEqual({ + status: 'ready', + checkoutUrl: null, + providerReference: 'fake-setup-wallet-1', + providerCustomerId: 'fake-customer-wallet-1', + paymentMethod: { id: 'fake-card-wallet-1', brand: 'visa', last4: '4242' }, + }) + + await expect( + provider.createManualTopUp({ + organizationId: 'org-1', + topUpId: 'top-up-1', + amountCents: '2500', + providerCustomerId: 'fake-customer-wallet-1', + providerMethodId: 'fake-card-wallet-1', + successUrl: 'http://dashboard.test/success', + cancelUrl: 'http://dashboard.test/cancel', + }), + ).resolves.toMatchObject({ status: 'paid', providerReference: 'fake-payment-top-up-1' }) + }) + + it('does not accept public webhook events in fake mode', async () => { + const provider = new FakePaymentProvider() + + await expect( + provider.parseWebhook( + Buffer.from( + JSON.stringify({ + kind: 'top_up_paid', + providerEventId: 'forged-event', + topUpId: 'top-up-1', + organizationId: 'org-1', + amountCents: '2500', + currency: 'usd', + }), + ), + 'fake', + ), + ).rejects.toBeInstanceOf(BadRequestException) + }) + + it('creates hosted Stripe Checkout sessions with server-owned amount and idempotency metadata', async () => { + const customersCreate = jest.fn().mockResolvedValue({ id: 'cus_1' }) + const sessionsCreate = jest.fn().mockResolvedValue({ id: 'cs_1', url: 'https://checkout.stripe.test/cs_1' }) + const stripe = { + customers: { create: customersCreate }, + checkout: { sessions: { create: sessionsCreate } }, + } as unknown as Stripe + const provider = new StripePaymentProvider('sk_test_secret', 'whsec_test', stripe) + + const setup = await provider.createSetup({ + organizationId: 'org-1', + walletId: 'wallet-1', + setupAttemptId: 'attempt-1', + providerCustomerId: null, + successUrl: 'https://dashboard.test/success', + cancelUrl: 'https://dashboard.test/cancel', + }) + + expect(customersCreate).toHaveBeenCalledWith( + { metadata: { organizationId: 'org-1', walletId: 'wallet-1' } }, + { idempotencyKey: 'wallet-customer:wallet-1' }, + ) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'setup', + customer: 'cus_1', + metadata: { organizationId: 'org-1', walletId: 'wallet-1', operation: 'setup' }, + }), + { idempotencyKey: 'wallet-setup:wallet-1:attempt-1' }, + ) + expect(setup).toMatchObject({ status: 'pending', providerCustomerId: 'cus_1', providerReference: 'cs_1' }) + }) + + it('passes the trusted top-up amount to Stripe and idempotently confirms saved-card auto-reload', async () => { + const sessionsCreate = jest.fn().mockResolvedValue({ id: 'cs_topup', url: 'https://checkout.test/topup' }) + const paymentIntentsCreate = jest.fn().mockResolvedValue({ + id: 'pi_auto', + status: 'succeeded', + latest_charge: { receipt_url: 'https://receipt.test/auto' }, + }) + const stripe = { + checkout: { sessions: { create: sessionsCreate } }, + paymentIntents: { create: paymentIntentsCreate }, + } as unknown as Stripe + const provider = new StripePaymentProvider('sk_test_secret', 'whsec_test', stripe) + const input = { + organizationId: 'org-1', + topUpId: 'top-up-1', + amountCents: '2500', + providerCustomerId: 'cus-1', + providerMethodId: 'pm-1', + successUrl: 'https://dashboard.test/success', + cancelUrl: 'https://dashboard.test/cancel', + } + + await expect(provider.createManualTopUp(input)).resolves.toMatchObject({ status: 'pending' }) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus-1', + line_items: [expect.objectContaining({ price_data: expect.objectContaining({ unit_amount: 2500 }) })], + metadata: expect.objectContaining({ topUpId: 'top-up-1', organizationId: 'org-1' }), + }), + { idempotencyKey: 'manual-top-up:top-up-1' }, + ) + + await expect(provider.chargeSavedMethod(input)).resolves.toEqual({ + status: 'paid', + checkoutUrl: null, + providerReference: 'pi_auto', + receiptUrl: 'https://receipt.test/auto', + }) + expect(paymentIntentsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 2500, + currency: 'usd', + customer: 'cus-1', + payment_method: 'pm-1', + confirm: true, + off_session: true, + }), + { idempotencyKey: 'auto-reload:top-up-1' }, + ) + }) + + it('retries ambiguous Stripe errors but records a definitive card failure', async () => { + const ambiguousError = Object.assign(new Error('connection reset after request'), { + type: 'StripeConnectionError', + }) + const cardError = Object.assign(new Error('card declined'), { + type: 'StripeCardError', + code: 'card_declined', + payment_intent: { id: 'pi_declined' }, + }) + const paymentIntentsCreate = jest.fn().mockRejectedValueOnce(ambiguousError).mockRejectedValueOnce(cardError) + const stripe = { paymentIntents: { create: paymentIntentsCreate } } as unknown as Stripe + const provider = new StripePaymentProvider('sk_test_secret', 'whsec_test', stripe) + const input = { + organizationId: 'org-1', + topUpId: 'top-up-ambiguous', + amountCents: '2500', + providerCustomerId: 'cus-1', + providerMethodId: 'pm-1', + successUrl: 'https://dashboard.test/success', + cancelUrl: 'https://dashboard.test/cancel', + } + + await expect(provider.chargeSavedMethod(input)).rejects.toBe(ambiguousError) + await expect(provider.chargeSavedMethod(input)).resolves.toMatchObject({ + status: 'failed', + providerReference: 'pi_declined', + failureCode: 'card_declined', + }) + expect(paymentIntentsCreate).toHaveBeenNthCalledWith(2, expect.any(Object), { + idempotencyKey: 'auto-reload:top-up-ambiguous', + }) + }) + + it('verifies the original Stripe webhook bytes and maps a paid checkout to a domain event', async () => { + const payload = Buffer.from('{"id":"evt_1"}') + const constructEvent = jest.fn().mockReturnValue({ + id: 'evt_1', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_1', + mode: 'payment', + payment_status: 'paid', + amount_total: 2500, + currency: 'usd', + metadata: { topUpId: 'top-up-1', organizationId: 'org-1' }, + payment_intent: null, + }, + }, + }) + const stripe = { webhooks: { constructEvent } } as unknown as Stripe + const provider = new StripePaymentProvider('sk_test_secret', 'whsec_test', stripe) + + await expect(provider.parseWebhook(payload, 'stripe-signature')).resolves.toEqual({ + kind: 'top_up_paid', + providerEventId: 'evt_1', + providerReference: 'cs_1', + topUpId: 'top-up-1', + organizationId: 'org-1', + amountCents: '2500', + currency: 'usd', + receiptUrl: null, + }) + expect(constructEvent).toHaveBeenCalledWith(payload, 'stripe-signature', 'whsec_test') + }) + + it('reserves PaymentIntent webhooks for saved-card auto-reloads', async () => { + const payload = Buffer.from('{"id":"evt_payment_intent"}') + const constructEvent = jest + .fn() + .mockReturnValueOnce({ + id: 'evt_manual', + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_manual', + amount_received: 2500, + currency: 'usd', + latest_charge: null, + metadata: { operation: 'top_up', topUpId: 'top-up-1', organizationId: 'org-1' }, + }, + }, + }) + .mockReturnValueOnce({ + id: 'evt_auto', + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_auto', + amount_received: 2500, + currency: 'usd', + latest_charge: { receipt_url: 'https://receipt.test/auto' }, + metadata: { operation: 'auto_reload', topUpId: 'top-up-2', organizationId: 'org-1' }, + }, + }, + }) + const stripe = { webhooks: { constructEvent } } as unknown as Stripe + const provider = new StripePaymentProvider('sk_test_secret', 'whsec_test', stripe) + + await expect(provider.parseWebhook(payload, 'stripe-signature')).resolves.toBeNull() + await expect(provider.parseWebhook(payload, 'stripe-signature')).resolves.toMatchObject({ + kind: 'top_up_paid', + providerEventId: 'evt_auto', + providerReference: 'pi_auto', + topUpId: 'top-up-2', + receiptUrl: 'https://receipt.test/auto', + }) + }) + + it('fails closed when production has no explicit provider or Stripe secrets', () => { + const config = (values: Record) => ({ get: (key: string) => values[key] }) as never + + expect(() => createPaymentProvider(config({}))).toThrow('unsupported billing payment provider') + expect(() => createPaymentProvider(config({ 'billing.paymentProvider': 'stripe' }))).toThrow( + 'requires STRIPE_SECRET_KEY', + ) + expect(createPaymentProvider(config({ 'billing.paymentProvider': 'fake' }))).toBeInstanceOf(FakePaymentProvider) + }) +}) diff --git a/apps/api/src/billing/payment/payment-provider.ts b/apps/api/src/billing/payment/payment-provider.ts new file mode 100644 index 000000000..05de3465e --- /dev/null +++ b/apps/api/src/billing/payment/payment-provider.ts @@ -0,0 +1,85 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +export interface PaymentMethodView { + id: string + brand: string + last4: string +} + +export interface PaymentSetupInput { + organizationId: string + walletId: string + setupAttemptId: string + providerCustomerId: string | null + successUrl: string + cancelUrl: string +} + +export interface PaymentSetupResult { + status: 'ready' | 'pending' + checkoutUrl: string | null + providerReference: string + providerCustomerId: string + paymentMethod: PaymentMethodView | null +} + +export interface TopUpPaymentInput { + organizationId: string + topUpId: string + amountCents: string + providerCustomerId: string + providerMethodId: string + successUrl: string + cancelUrl: string +} + +export interface TopUpPaymentResult { + status: 'pending' | 'paid' | 'failed' + checkoutUrl: string | null + providerReference: string + receiptUrl: string | null + failureCode?: string + failureMessage?: string +} + +export type ProviderWebhookEvent = + | { + kind: 'setup_succeeded' + providerEventId: string + providerReference: string + organizationId: string + providerCustomerId: string + paymentMethod: PaymentMethodView + } + | { + kind: 'top_up_paid' + providerEventId: string + providerReference: string + topUpId: string + organizationId: string + amountCents: string + currency: string + receiptUrl: string | null + } + | { + kind: 'top_up_failed' + providerEventId: string + providerReference: string + topUpId: string + organizationId: string + failureCode: string | null + failureMessage: string | null + } + +export interface PaymentProvider { + readonly mode: 'fake' | 'stripe' + createSetup(input: PaymentSetupInput): Promise + createManualTopUp(input: TopUpPaymentInput): Promise + chargeSavedMethod(input: TopUpPaymentInput): Promise + parseWebhook(payload: Buffer, signature: string): Promise +} + +export const PAYMENT_PROVIDER = Symbol('PAYMENT_PROVIDER') diff --git a/apps/api/src/billing/payment/payment.controller.spec.ts b/apps/api/src/billing/payment/payment.controller.spec.ts new file mode 100644 index 000000000..a01bb7039 --- /dev/null +++ b/apps/api/src/billing/payment/payment.controller.spec.ts @@ -0,0 +1,66 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { RequiredOrganizationMemberRole } from '../../organization/decorators/required-organization-member-role.decorator' +import { OrganizationMemberRole } from '../../organization/enums/organization-member-role.enum' +import { BillingPaymentController, PaymentWebhookController } from './payment.controller' + +describe('BillingPaymentController', () => { + const paymentService = { + getPaymentState: jest.fn(), + setupPaymentMethod: jest.fn(), + setAutoReload: jest.fn(), + createManualTopUp: jest.fn(), + listReceipts: jest.fn(), + handleWebhook: jest.fn(), + } + + beforeEach(() => jest.resetAllMocks()) + + it('keeps every customer payment operation owner-only and delegates validated contracts', async () => { + const controller = new BillingPaymentController(paymentService as never) + paymentService.getPaymentState.mockResolvedValue({ providerMode: 'fake' }) + paymentService.setupPaymentMethod.mockResolvedValue({ status: 'ready', checkoutUrl: null }) + paymentService.setAutoReload.mockResolvedValue(undefined) + paymentService.createManualTopUp.mockResolvedValue({ id: 'top-up-1', status: 'paid', checkoutUrl: null }) + paymentService.listReceipts.mockResolvedValue({ items: [], page: 1, pageSize: 8, total: 0 }) + + await expect(controller.getPaymentState('org-1')).resolves.toEqual({ providerMode: 'fake' }) + await expect(controller.setupPaymentMethod('org-1')).resolves.toMatchObject({ status: 'ready' }) + await expect( + controller.setAutoReload('org-1', { enabled: true, thresholdCents: '2000', targetCents: '5000' }), + ).resolves.toBeUndefined() + await expect(controller.createTopUp('org-1', { amountCents: '2500' }, 'request-1')).resolves.toMatchObject({ + status: 'paid', + }) + await expect(controller.listReceipts('org-1', '1', '8', '')).resolves.toMatchObject({ total: 0 }) + + expect(paymentService.createManualTopUp).toHaveBeenCalledWith('org-1', '2500', 'request-1') + const reflector = new Reflector() + for (const handler of [ + BillingPaymentController.prototype.getPaymentState, + BillingPaymentController.prototype.setupPaymentMethod, + BillingPaymentController.prototype.setAutoReload, + BillingPaymentController.prototype.createTopUp, + BillingPaymentController.prototype.listReceipts, + ]) { + expect(reflector.get(RequiredOrganizationMemberRole, handler)).toBe(OrganizationMemberRole.OWNER) + } + }) + + it('requires untouched raw bytes and a Stripe signature on the public webhook', async () => { + const controller = new PaymentWebhookController(paymentService as never) + const rawBody = Buffer.from('{"id":"evt-1"}') + + await expect(controller.handle({ rawBody } as never, 'signature')).resolves.toEqual({ received: true }) + expect(paymentService.handleWebhook).toHaveBeenCalledWith(rawBody, 'signature') + await expect(controller.handle({ rawBody: undefined } as never, 'signature')).rejects.toBeInstanceOf( + BadRequestException, + ) + await expect(controller.handle({ rawBody } as never, undefined)).rejects.toBeInstanceOf(BadRequestException) + }) +}) diff --git a/apps/api/src/billing/payment/payment.controller.ts b/apps/api/src/billing/payment/payment.controller.ts new file mode 100644 index 000000000..d3f583c33 --- /dev/null +++ b/apps/api/src/billing/payment/payment.controller.ts @@ -0,0 +1,100 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { + BadRequestException, + Body, + Controller, + Get, + Headers, + HttpCode, + Param, + Post, + Put, + Query, + RawBodyRequest, + Req, + UseGuards, +} from '@nestjs/common' +import { ApiBearerAuth, ApiOAuth2, ApiTags } from '@nestjs/swagger' +import type { Request } from 'express' +import { randomUUID } from 'node:crypto' +import { CombinedAuthGuard } from '../../auth/combined-auth.guard' +import { AuthenticatedRateLimitGuard } from '../../common/guards/authenticated-rate-limit.guard' +import { RequiredOrganizationMemberRole } from '../../organization/decorators/required-organization-member-role.decorator' +import { OrganizationMemberRole } from '../../organization/enums/organization-member-role.enum' +import { OrganizationActionGuard } from '../../organization/guards/organization-action.guard' +import { AutoReloadInput, PaymentService } from './payment.service' + +interface TopUpRequest { + amountCents: string +} + +@ApiTags('billing') +@ApiOAuth2(['openid', 'profile', 'email']) +@ApiBearerAuth() +@Controller('organization/:organizationId/billing') +@UseGuards(CombinedAuthGuard, AuthenticatedRateLimitGuard, OrganizationActionGuard) +export class BillingPaymentController { + constructor(private readonly paymentService: PaymentService) {} + + @Get('payment') + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + getPaymentState(@Param('organizationId') organizationId: string) { + return this.paymentService.getPaymentState(organizationId) + } + + @Post('payment/setup') + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + setupPaymentMethod(@Param('organizationId') organizationId: string) { + return this.paymentService.setupPaymentMethod(organizationId) + } + + @Put('auto-reload') + @HttpCode(200) + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + setAutoReload(@Param('organizationId') organizationId: string, @Body() input: AutoReloadInput): Promise { + return this.paymentService.setAutoReload(organizationId, input) + } + + @Post('top-ups') + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + createTopUp( + @Param('organizationId') organizationId: string, + @Body() input: TopUpRequest, + @Headers('idempotency-key') idempotencyKey?: string, + ) { + return this.paymentService.createManualTopUp(organizationId, input.amountCents, idempotencyKey ?? randomUUID()) + } + + @Get('receipts') + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + listReceipts( + @Param('organizationId') organizationId: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('query') query?: string, + ) { + return this.paymentService.listReceipts(organizationId, Number(page ?? 1), Number(pageSize ?? 8), query ?? '') + } +} + +@ApiTags('billing') +@Controller('billing/webhooks') +export class PaymentWebhookController { + constructor(private readonly paymentService: PaymentService) {} + + @Post('payment') + @HttpCode(200) + async handle( + @Req() request: RawBodyRequest, + @Headers('stripe-signature') signature?: string, + ): Promise<{ received: true }> { + if (!request.rawBody) throw new BadRequestException('payment webhook raw body is required') + if (!signature) throw new BadRequestException('payment webhook signature is required') + await this.paymentService.handleWebhook(request.rawBody, signature) + return { received: true } + } +} diff --git a/apps/api/src/billing/payment/payment.service.spec.ts b/apps/api/src/billing/payment/payment.service.spec.ts new file mode 100644 index 000000000..fd515d316 --- /dev/null +++ b/apps/api/src/billing/payment/payment.service.spec.ts @@ -0,0 +1,402 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import { PaymentProviderEvent } from '../entities/payment-provider-event.entity' +import { TopUpRecord } from '../entities/top-up-record.entity' +import { WalletTransaction } from '../entities/wallet-transaction.entity' +import { Wallet } from '../entities/wallet.entity' +import { FakePaymentProvider } from './fake-payment.provider' +import { PaymentProvider, ProviderWebhookEvent, TopUpPaymentInput, TopUpPaymentResult } from './payment-provider' +import { PaymentService } from './payment.service' + +const ORG_ID = 'f5de33a9-4eb2-4279-a8de-9f02d63cc4f0' + +class FakeRepository { + readonly rows: T[] = [] + readonly saveErrors: Error[] = [] + manager: FakeEntityManager + + constructor(private readonly prefix: string) {} + + create(input: Partial): T { + return input as T + } + + async save(row: T): Promise { + const saveError = this.saveErrors.shift() + if (saveError) throw saveError + row.id ??= `${this.prefix}-${this.rows.length + 1}` + const index = this.rows.findIndex((candidate) => candidate.id === row.id) + if (index === -1) this.rows.push(row) + else this.rows[index] = row + return row + } + + async findOne(options: { where: Partial }): Promise { + return ( + this.rows.find((row) => + Object.entries(options.where).every(([key, value]) => (row as Record)[key] === value), + ) ?? null + ) + } +} + +class FakeEntityManager { + constructor(private readonly repositories: Map>) {} + + transaction(callback: (manager: FakeEntityManager) => Promise): Promise { + return callback(this) + } + + getRepository(entity: unknown): FakeRepository { + const repository = this.repositories.get(entity) + if (!repository) throw new Error(`missing fake repository for ${String(entity)}`) + return repository as unknown as FakeRepository + } +} + +class PendingPaymentProvider extends FakePaymentProvider { + override async createManualTopUp(): Promise { + return { + status: 'pending', + checkoutUrl: 'https://checkout.test/pending', + providerReference: 'checkout-pending', + receiptUrl: null, + } + } +} + +class TestWebhookPaymentProvider extends PendingPaymentProvider { + override async parseWebhook(payload: Buffer): Promise { + return JSON.parse(payload.toString('utf8')) as ProviderWebhookEvent + } +} + +class HookedSuccessfulPaymentProvider extends FakePaymentProvider { + manualTopUpCalls = 0 + beforeManualTopUpResult?: () => void + + override async createManualTopUp(input: TopUpPaymentInput): Promise { + this.manualTopUpCalls++ + this.beforeManualTopUpResult?.() + return super.createManualTopUp(input) + } +} + +class AmbiguousThenSuccessfulPaymentProvider extends FakePaymentProvider { + manualTopUpCalls: TopUpPaymentInput[] = [] + + override async createManualTopUp(input: TopUpPaymentInput): Promise { + this.manualTopUpCalls.push(input) + if (this.manualTopUpCalls.length === 1) throw new Error('provider response was lost') + return super.createManualTopUp(input) + } +} + +function wallet(overrides: Partial = {}): Wallet { + return { + id: 'wallet-1', + organizationId: ORG_ID, + freeBalanceCents: '0', + paidBalanceCents: '0', + settlementRemainderCents: '0', + freeExpiresAt: null, + billingStatus: 'zero_balance', + paymentProviderCustomerId: null, + paymentProviderMethodId: null, + paymentMethodBrand: null, + paymentMethodLast4: null, + autoReloadEnabled: false, + autoReloadThresholdCents: null, + autoReloadTargetCents: null, + autoReloadNextAttemptAt: null, + createdAt: new Date('2026-07-10T00:00:00Z'), + updatedAt: new Date('2026-07-10T00:00:00Z'), + ...overrides, + } +} + +function createService(provider: PaymentProvider = new FakePaymentProvider(), walletOverrides: Partial = {}) { + const wallets = new FakeRepository('wallet') + const topUps = new FakeRepository('top-up') + const events = new FakeRepository('event') + const transactions = new FakeRepository('transaction') + const currentWallet = wallet(walletOverrides) + wallets.rows.push(currentWallet) + + const repositories = new Map>([ + [Wallet, wallets as unknown as FakeRepository], + [TopUpRecord, topUps as unknown as FakeRepository], + [PaymentProviderEvent, events as unknown as FakeRepository], + [WalletTransaction, transactions as unknown as FakeRepository], + ]) + const manager = new FakeEntityManager(repositories) + wallets.manager = manager + topUps.manager = manager + events.manager = manager + transactions.manager = manager + + const service = new PaymentService( + wallets as never, + topUps as never, + { getOrCreateWallet: jest.fn().mockResolvedValue(currentWallet) } as never, + provider, + { getOrThrow: jest.fn().mockReturnValue('http://localhost:3000') } as never, + ) + return { service, currentWallet, wallets, topUps, events, transactions } +} + +describe('PaymentService', () => { + it('attaches the fake provider card for local E2E', async () => { + const { service, currentWallet } = createService() + + await expect(service.setupPaymentMethod(ORG_ID)).resolves.toEqual({ status: 'ready', checkoutUrl: null }) + expect(currentWallet).toMatchObject({ + paymentProviderCustomerId: 'fake-customer-wallet-1', + paymentProviderMethodId: 'fake-card-wallet-1', + paymentMethodBrand: 'visa', + paymentMethodLast4: '4242', + }) + }) + + it('credits paid balance and writes one immutable ledger row for an idempotent top-up', async () => { + const { service, currentWallet, topUps, transactions } = createService(new FakePaymentProvider(), { + paidBalanceCents: '-500', + paymentProviderCustomerId: 'fake-customer-wallet-1', + paymentProviderMethodId: 'fake-card-wallet-1', + }) + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-1')).resolves.toMatchObject({ status: 'paid' }) + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-1')).resolves.toMatchObject({ status: 'paid' }) + await expect(service.createManualTopUp(ORG_ID, '5000', 'request-1')).rejects.toThrow( + 'idempotency key was already used with a different amount', + ) + + expect(currentWallet).toMatchObject({ paidBalanceCents: '2000', billingStatus: 'active' }) + expect(topUps.rows).toHaveLength(1) + expect(transactions.rows).toEqual([ + expect.objectContaining({ kind: 'top_up', amountCents: '2500', source: 'manual_top_up' }), + ]) + }) + + it('resumes an idempotent top-up left pending before its provider result was committed', async () => { + const { service, currentWallet, topUps } = createService(new FakePaymentProvider(), { + paymentProviderCustomerId: 'fake-customer-wallet-1', + paymentProviderMethodId: 'fake-card-wallet-1', + }) + topUps.rows.push({ + id: 'top-up-crash', + walletId: currentWallet.id, + organizationId: ORG_ID, + amountCents: '2500', + source: 'manual', + status: 'pending', + idempotencyKey: 'request-crash', + providerReference: 'fake-payment-top-up-crash', + checkoutUrl: null, + receiptUrl: null, + failureCode: null, + failureMessage: null, + completedAt: null, + createdAt: new Date('2026-07-10T00:00:00Z'), + updatedAt: new Date('2026-07-10T00:00:00Z'), + }) + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-crash')).resolves.toMatchObject({ + id: 'top-up-crash', + status: 'paid', + }) + expect(currentWallet.paidBalanceCents).toBe('2500') + }) + + it('keeps a successful provider result retryable when local persistence fails', async () => { + const provider = new HookedSuccessfulPaymentProvider() + const { service, currentWallet, topUps, transactions } = createService(provider, { + paymentProviderCustomerId: 'fake-customer-wallet-1', + paymentProviderMethodId: 'fake-card-wallet-1', + }) + provider.beforeManualTopUpResult = () => { + provider.beforeManualTopUpResult = undefined + topUps.saveErrors.push(new Error('database unavailable after provider success')) + } + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-persistence')).rejects.toThrow( + 'database unavailable after provider success', + ) + expect(topUps.rows[0].status).toBe('pending') + expect(currentWallet.paidBalanceCents).toBe('0') + expect(transactions.rows).toHaveLength(0) + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-persistence')).resolves.toMatchObject({ + status: 'paid', + }) + expect(provider.manualTopUpCalls).toBe(2) + expect(currentWallet.paidBalanceCents).toBe('2500') + expect(transactions.rows).toHaveLength(1) + }) + + it('keeps an ambiguous provider error pending and retries the same top-up', async () => { + const provider = new AmbiguousThenSuccessfulPaymentProvider() + const { service, currentWallet, topUps, transactions } = createService(provider, { + paymentProviderCustomerId: 'fake-customer-wallet-1', + paymentProviderMethodId: 'fake-card-wallet-1', + }) + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-ambiguous')).rejects.toThrow( + 'payment provider request failed', + ) + expect(topUps.rows[0]).toMatchObject({ status: 'pending', failureCode: null, completedAt: null }) + expect(currentWallet.paidBalanceCents).toBe('0') + expect(transactions.rows).toHaveLength(0) + + await expect(service.createManualTopUp(ORG_ID, '2500', 'request-ambiguous')).resolves.toMatchObject({ + status: 'paid', + }) + expect(provider.manualTopUpCalls).toHaveLength(2) + expect(provider.manualTopUpCalls[0].topUpId).toBe(provider.manualTopUpCalls[1].topUpId) + expect(currentWallet.paidBalanceCents).toBe('2500') + expect(transactions.rows).toHaveLength(1) + }) + + it('converges failed then paid webhooks, ignores later failure, and never double-credits', async () => { + const { service, currentWallet, topUps, transactions } = createService(new TestWebhookPaymentProvider(), { + paymentProviderCustomerId: 'cus-1', + paymentProviderMethodId: 'pm-1', + }) + const topUp = await service.createManualTopUp(ORG_ID, '2500', 'request-1') + + await service.handleWebhook( + Buffer.from( + JSON.stringify({ + kind: 'top_up_failed', + providerEventId: 'evt-failed', + providerReference: 'pi-1', + topUpId: topUp.id, + organizationId: ORG_ID, + failureCode: 'card_declined', + failureMessage: 'declined', + }), + ), + 'test', + ) + expect(topUps.rows[0].status).toBe('failed') + + const paidEvent = { + kind: 'top_up_paid', + providerEventId: 'evt-paid', + providerReference: 'pi-1', + topUpId: topUp.id, + organizationId: ORG_ID, + amountCents: '2500', + currency: 'usd', + receiptUrl: 'https://receipt.test/1', + } + await service.handleWebhook(Buffer.from(JSON.stringify(paidEvent)), 'test') + await service.handleWebhook(Buffer.from(JSON.stringify(paidEvent)), 'test') + await service.handleWebhook( + Buffer.from( + JSON.stringify({ + kind: 'top_up_failed', + providerEventId: 'evt-late-failure', + providerReference: 'pi-1', + topUpId: topUp.id, + organizationId: ORG_ID, + failureCode: 'late', + failureMessage: 'late', + }), + ), + 'test', + ) + + expect(topUps.rows[0]).toMatchObject({ status: 'paid', receiptUrl: 'https://receipt.test/1' }) + expect(currentWallet.paidBalanceCents).toBe('2500') + expect(transactions.rows).toHaveLength(1) + }) + + it('rejects a provider amount mismatch without crediting the wallet', async () => { + const { service, currentWallet } = createService(new TestWebhookPaymentProvider(), { + paymentProviderCustomerId: 'cus-1', + paymentProviderMethodId: 'pm-1', + }) + const topUp = await service.createManualTopUp(ORG_ID, '2500', 'request-1') + + await expect( + service.handleWebhook( + Buffer.from( + JSON.stringify({ + kind: 'top_up_paid', + providerEventId: 'evt-wrong-amount', + providerReference: 'pi-1', + topUpId: topUp.id, + organizationId: ORG_ID, + amountCents: '2600', + currency: 'usd', + receiptUrl: null, + }), + ), + 'test', + ), + ).rejects.toBeInstanceOf(BadRequestException) + expect(currentWallet.paidBalanceCents).toBe('0') + }) + + it('tops up to the configured target once when auto-reload crosses its threshold', async () => { + const { service, currentWallet, topUps } = createService(new FakePaymentProvider(), { + paidBalanceCents: '900', + billingStatus: 'active', + paymentProviderCustomerId: 'cus-1', + paymentProviderMethodId: 'pm-1', + autoReloadEnabled: true, + autoReloadThresholdCents: '1000', + autoReloadTargetCents: '5000', + }) + + await expect(service.runAutoReloadForOrganization(ORG_ID)).resolves.toBe(true) + await expect(service.runAutoReloadForOrganization(ORG_ID)).resolves.toBe(false) + + expect(currentWallet.paidBalanceCents).toBe('5000') + expect(topUps.rows).toEqual([ + expect.objectContaining({ source: 'auto_reload', amountCents: '4100', status: 'paid' }), + ]) + }) + + it('waits until the balance is below the auto-reload threshold', async () => { + const { service, topUps } = createService(new FakePaymentProvider(), { + paidBalanceCents: '1000', + billingStatus: 'active', + paymentProviderCustomerId: 'cus-1', + paymentProviderMethodId: 'pm-1', + autoReloadEnabled: true, + autoReloadThresholdCents: '1000', + autoReloadTargetCents: '5000', + }) + + await expect(service.runAutoReloadForOrganization(ORG_ID)).resolves.toBe(false) + expect(topUps.rows).toHaveLength(0) + }) + + it('requires an attached payment method and a $10 auto-reload spread', async () => { + const { service, currentWallet } = createService() + + await expect( + service.setAutoReload(ORG_ID, { enabled: true, thresholdCents: '2000', targetCents: '2500' }), + ).rejects.toBeInstanceOf(BadRequestException) + + currentWallet.paymentProviderCustomerId = 'cus-1' + currentWallet.paymentProviderMethodId = 'pm-1' + await expect( + service.setAutoReload(ORG_ID, { enabled: true, thresholdCents: '2000', targetCents: '2500' }), + ).rejects.toThrow('at least $10') + await expect( + service.setAutoReload(ORG_ID, { enabled: true, thresholdCents: '2000', targetCents: '5000' }), + ).resolves.toBeUndefined() + expect(currentWallet).toMatchObject({ + autoReloadEnabled: true, + autoReloadThresholdCents: '2000', + autoReloadTargetCents: '5000', + }) + }) +}) diff --git a/apps/api/src/billing/payment/payment.service.ts b/apps/api/src/billing/payment/payment.service.ts new file mode 100644 index 000000000..fcecbac0c --- /dev/null +++ b/apps/api/src/billing/payment/payment.service.ts @@ -0,0 +1,526 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException, Inject, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { InjectRepository } from '@nestjs/typeorm' +import { randomUUID } from 'node:crypto' +import { EntityManager, QueryFailedError, Repository } from 'typeorm' +import { TypedConfigService } from '../../config/typed-config.service' +import { PaymentProviderEvent } from '../entities/payment-provider-event.entity' +import { TopUpRecord } from '../entities/top-up-record.entity' +import { WalletTransaction } from '../entities/wallet-transaction.entity' +import { BillingStatus, Wallet } from '../entities/wallet.entity' +import { WalletService } from '../wallet.service' +import { + PAYMENT_PROVIDER, + PaymentProvider, + ProviderWebhookEvent, + TopUpPaymentInput, + TopUpPaymentResult, +} from './payment-provider' + +const MIN_TOP_UP_CENTS = 500n +const MIN_AUTO_RELOAD_SPREAD_CENTS = 1000n +const AUTO_RELOAD_RETRY_MILLISECONDS = 15 * 60 * 1000 +const PG_UNIQUE_VIOLATION = '23505' + +export interface AutoReloadInput { + enabled: boolean + thresholdCents: string | null + targetCents: string | null +} + +export interface BillingReceipt { + id: string + createdAt: string + amountCents: string + type: 'top_up' | 'usage' + status: 'paid' | 'failed' + receiptUrl: string | null +} + +@Injectable() +export class PaymentService { + private readonly logger = new Logger(PaymentService.name) + + constructor( + @InjectRepository(Wallet) + private readonly wallets: Repository, + @InjectRepository(TopUpRecord) + private readonly topUps: Repository, + private readonly walletService: WalletService, + @Inject(PAYMENT_PROVIDER) + private readonly provider: PaymentProvider, + private readonly configService: TypedConfigService, + ) {} + + async getPaymentState(organizationId: string) { + const wallet = await this.walletService.getOrCreateWallet(organizationId) + return { + providerMode: this.provider.mode, + paymentMethod: + wallet.paymentProviderMethodId && wallet.paymentMethodBrand && wallet.paymentMethodLast4 + ? { brand: wallet.paymentMethodBrand, last4: wallet.paymentMethodLast4 } + : null, + autoReload: { + enabled: wallet.autoReloadEnabled, + thresholdCents: wallet.autoReloadThresholdCents, + targetCents: wallet.autoReloadTargetCents, + }, + } + } + + async setupPaymentMethod( + organizationId: string, + ): Promise<{ status: 'ready' | 'pending'; checkoutUrl: string | null }> { + const wallet = await this.walletService.getOrCreateWallet(organizationId) + const result = await this.provider.createSetup({ + organizationId, + walletId: wallet.id, + setupAttemptId: randomUUID(), + providerCustomerId: wallet.paymentProviderCustomerId, + ...this.returnUrls(), + }) + + await this.wallets.manager.transaction(async (manager) => { + const lockedWallet = await manager.getRepository(Wallet).findOne({ + where: { organizationId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!lockedWallet) throw new BadRequestException('wallet not found') + lockedWallet.paymentProviderCustomerId = result.providerCustomerId + await manager.getRepository(Wallet).save(lockedWallet) + }) + + if (result.status === 'ready' && result.paymentMethod) { + await this.applyProviderEvent({ + kind: 'setup_succeeded', + providerEventId: `direct:${result.providerReference}`, + providerReference: result.providerReference, + organizationId, + providerCustomerId: result.providerCustomerId, + paymentMethod: result.paymentMethod, + }) + } + + return { status: result.status, checkoutUrl: result.checkoutUrl } + } + + async setAutoReload(organizationId: string, input: AutoReloadInput): Promise { + if (!input || typeof input.enabled !== 'boolean') { + throw new BadRequestException('auto-reload enabled must be a boolean') + } + await this.walletService.getOrCreateWallet(organizationId) + await this.wallets.manager.transaction(async (manager) => { + const wallet = await manager.getRepository(Wallet).findOne({ + where: { organizationId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!wallet) throw new BadRequestException('wallet not found') + + if (!input.enabled) { + wallet.autoReloadEnabled = false + wallet.autoReloadThresholdCents = null + wallet.autoReloadTargetCents = null + wallet.autoReloadNextAttemptAt = null + await manager.getRepository(Wallet).save(wallet) + return + } + + if (!wallet.paymentProviderMethodId) { + throw new BadRequestException('attach a payment method before enabling auto-reload') + } + const thresholdCents = this.positiveCents(input.thresholdCents, 'auto-reload threshold', true) + const targetCents = this.positiveCents(input.targetCents, 'auto-reload target') + if (targetCents < thresholdCents + MIN_AUTO_RELOAD_SPREAD_CENTS) { + throw new BadRequestException('auto-reload target must be at least $10 above the threshold') + } + + wallet.autoReloadEnabled = true + wallet.autoReloadThresholdCents = thresholdCents.toString() + wallet.autoReloadTargetCents = targetCents.toString() + wallet.autoReloadNextAttemptAt = null + await manager.getRepository(Wallet).save(wallet) + }) + } + + async createManualTopUp( + organizationId: string, + amountCentsInput: string, + idempotencyKey: string, + ): Promise<{ id: string; status: 'pending' | 'paid' | 'failed'; checkoutUrl: string | null }> { + const amountCents = this.positiveCents(amountCentsInput, 'top-up amount') + if (amountCents < MIN_TOP_UP_CENTS) { + throw new BadRequestException('top-up amount must be at least $5') + } + if (!idempotencyKey || idempotencyKey.length > 128) { + throw new BadRequestException('a valid idempotency key is required') + } + + await this.walletService.getOrCreateWallet(organizationId) + const { topUp, created } = await this.claimManualTopUp(organizationId, amountCents, idempotencyKey) + if (created || (topUp.status === 'pending' && !topUp.checkoutUrl)) { + await this.dispatchTopUp(topUp, false) + } + const current = await this.topUps.findOne({ where: { id: topUp.id } }) + return this.topUpView(current ?? topUp) + } + + async handleWebhook(payload: Buffer, signature: string): Promise { + const event = await this.provider.parseWebhook(payload, signature) + if (event) await this.applyProviderEvent(event) + } + + async runAutoReloadForOrganization(organizationId: string, now = new Date()): Promise { + const topUp = await this.claimAutoReload(organizationId, now) + if (!topUp) return false + await this.dispatchTopUp(topUp, true) + return true + } + + @Cron(CronExpression.EVERY_MINUTE, { name: 'billing-auto-reload' }) + async scheduledAutoReload(): Promise { + const candidates = await this.wallets + .createQueryBuilder('wallet') + .select('wallet.organizationId', 'organizationId') + .where('wallet.autoReloadEnabled = true') + .andWhere('wallet.autoReloadThresholdCents IS NOT NULL') + .andWhere('(wallet.freeBalanceCents + wallet.paidBalanceCents) < wallet.autoReloadThresholdCents') + .andWhere('(wallet.autoReloadNextAttemptAt IS NULL OR wallet.autoReloadNextAttemptAt <= :now)', { + now: new Date(), + }) + .limit(100) + .getRawMany<{ organizationId: string }>() + + for (const candidate of candidates) { + try { + await this.runAutoReloadForOrganization(candidate.organizationId) + } catch (error) { + this.logger.error(`auto-reload failed for organization ${candidate.organizationId}`, error) + } + } + } + + async listReceipts(organizationId: string, pageInput = 1, pageSizeInput = 8, queryInput = '') { + const page = Math.max(1, Math.trunc(Number.isFinite(pageInput) ? pageInput : 1)) + const pageSize = Math.max(1, Math.min(100, Math.trunc(Number.isFinite(pageSizeInput) ? pageSizeInput : 8))) + const query = queryInput.trim().slice(0, 100) + const search = `%${query}%` + const receiptCte = `WITH receipts AS ( + SELECT tur.id, tur."createdAt", tur."amountCents", 'top_up'::text AS type, + tur.status, tur."receiptUrl" + FROM top_up_record tur + WHERE tur."organizationId" = $1 AND tur.status IN ('paid', 'failed') + UNION ALL + SELECT wt.id, wt."createdAt", ABS(wt."amountCents"), 'usage'::text AS type, + 'paid'::text AS status, NULL::text AS "receiptUrl" + FROM wallet_transaction wt + WHERE wt."organizationId" = $1 AND wt.kind = 'usage_debit' + )` + const searchClause = `WHERE $2 = '%%' OR type ILIKE $2 OR status ILIKE $2 OR "amountCents"::text ILIKE $2` + const [rows, countRows] = await Promise.all([ + this.wallets.manager.query( + `${receiptCte} + SELECT id, "createdAt", "amountCents"::text AS "amountCents", type, status, "receiptUrl" + FROM receipts ${searchClause} + ORDER BY "createdAt" DESC, id DESC LIMIT $3 OFFSET $4`, + [organizationId, search, pageSize, (page - 1) * pageSize], + ) as Promise & { createdAt: Date | string }>>, + this.wallets.manager.query(`${receiptCte} SELECT COUNT(*)::int AS total FROM receipts ${searchClause}`, [ + organizationId, + search, + ]) as Promise>, + ]) + return { + items: rows.map((row) => ({ ...row, createdAt: new Date(row.createdAt).toISOString() })), + page, + pageSize, + total: Number(countRows[0]?.total ?? 0), + } + } + + private async claimManualTopUp(organizationId: string, amountCents: bigint, idempotencyKey: string) { + try { + return await this.wallets.manager.transaction(async (manager) => { + const topUpRepository = manager.getRepository(TopUpRecord) + const existing = await topUpRepository.findOne({ where: { organizationId, idempotencyKey } }) + if (existing) { + this.assertIdempotentAmount(existing, amountCents) + return { topUp: existing, created: false } + } + const wallet = await manager.getRepository(Wallet).findOne({ + where: { organizationId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!wallet?.paymentProviderCustomerId || !wallet.paymentProviderMethodId) { + throw new BadRequestException('attach a payment method before topping up') + } + const topUp = await topUpRepository.save( + topUpRepository.create({ + walletId: wallet.id, + organizationId, + amountCents: amountCents.toString(), + source: 'manual', + status: 'pending', + idempotencyKey, + providerReference: null, + checkoutUrl: null, + receiptUrl: null, + failureCode: null, + failureMessage: null, + completedAt: null, + }), + ) + return { topUp, created: true } + }) + } catch (error) { + if (!this.isUniqueViolation(error)) throw error + const existing = await this.topUps.findOne({ where: { organizationId, idempotencyKey } }) + if (!existing) throw error + this.assertIdempotentAmount(existing, amountCents) + return { topUp: existing, created: false } + } + } + + private claimAutoReload(organizationId: string, now: Date): Promise { + return this.wallets.manager.transaction(async (manager) => { + const wallet = await manager.getRepository(Wallet).findOne({ + where: { organizationId }, + lock: { mode: 'pessimistic_write' }, + }) + if ( + !wallet?.autoReloadEnabled || + !wallet.autoReloadThresholdCents || + !wallet.autoReloadTargetCents || + !wallet.paymentProviderCustomerId || + !wallet.paymentProviderMethodId || + (wallet.autoReloadNextAttemptAt && wallet.autoReloadNextAttemptAt > now) + ) { + return null + } + const currentBalance = BigInt(wallet.freeBalanceCents) + BigInt(wallet.paidBalanceCents) + const threshold = BigInt(wallet.autoReloadThresholdCents) + const target = BigInt(wallet.autoReloadTargetCents) + if (currentBalance >= threshold || currentBalance >= target) return null + + const topUpRepository = manager.getRepository(TopUpRecord) + const pending = await topUpRepository.findOne({ + where: { walletId: wallet.id, source: 'auto_reload', status: 'pending' }, + }) + if (pending) { + const createdAt = pending.createdAt?.getTime() + return createdAt && now.getTime() - createdAt >= AUTO_RELOAD_RETRY_MILLISECONDS ? pending : null + } + + return topUpRepository.save( + topUpRepository.create({ + walletId: wallet.id, + organizationId, + amountCents: (target - currentBalance).toString(), + source: 'auto_reload', + status: 'pending', + idempotencyKey: `auto:${wallet.id}:${Math.floor(now.getTime() / AUTO_RELOAD_RETRY_MILLISECONDS)}`, + providerReference: null, + checkoutUrl: null, + receiptUrl: null, + failureCode: null, + failureMessage: null, + completedAt: null, + }), + ) + }) + } + + private async dispatchTopUp(topUp: TopUpRecord, savedMethod: boolean): Promise { + const wallet = await this.wallets.findOne({ where: { id: topUp.walletId } }) + if (!wallet?.paymentProviderCustomerId || !wallet.paymentProviderMethodId) { + throw new BadRequestException('payment method not found') + } + const input: TopUpPaymentInput = { + organizationId: topUp.organizationId, + topUpId: topUp.id, + amountCents: topUp.amountCents, + providerCustomerId: wallet.paymentProviderCustomerId, + providerMethodId: wallet.paymentProviderMethodId, + ...this.returnUrls(), + } + + let result: TopUpPaymentResult + try { + result = savedMethod ? await this.provider.chargeSavedMethod(input) : await this.provider.createManualTopUp(input) + } catch (error) { + throw new ServiceUnavailableException('payment provider request failed', { cause: error }) + } + + await this.applyPaymentResult(topUp, result) + } + + private async applyPaymentResult(topUp: TopUpRecord, result: TopUpPaymentResult): Promise { + topUp.providerReference = result.providerReference + topUp.checkoutUrl = result.checkoutUrl + await this.topUps.save(topUp) + + if (result.status === 'paid') { + await this.applyProviderEvent({ + kind: 'top_up_paid', + providerEventId: `direct:${result.providerReference}`, + providerReference: result.providerReference, + topUpId: topUp.id, + organizationId: topUp.organizationId, + amountCents: topUp.amountCents, + currency: 'usd', + receiptUrl: result.receiptUrl, + }) + } else if (result.status === 'failed') { + await this.applyProviderEvent({ + kind: 'top_up_failed', + providerEventId: `direct:${result.providerReference}`, + providerReference: result.providerReference, + topUpId: topUp.id, + organizationId: topUp.organizationId, + failureCode: result.failureCode ?? null, + failureMessage: result.failureMessage ?? null, + }) + } + } + + private async applyProviderEvent(event: ProviderWebhookEvent): Promise { + try { + await this.wallets.manager.transaction(async (manager) => { + const eventRepository = manager.getRepository(PaymentProviderEvent) + if (await eventRepository.findOne({ where: { providerEventId: event.providerEventId } })) return + + if (event.kind === 'setup_succeeded') { + const wallet = await manager.getRepository(Wallet).findOne({ + where: { organizationId: event.organizationId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!wallet) throw new BadRequestException('wallet not found') + wallet.paymentProviderCustomerId = event.providerCustomerId + wallet.paymentProviderMethodId = event.paymentMethod.id + wallet.paymentMethodBrand = event.paymentMethod.brand + wallet.paymentMethodLast4 = event.paymentMethod.last4 + await manager.getRepository(Wallet).save(wallet) + } else { + await this.applyTopUpEvent(manager, event) + } + + await eventRepository.save( + eventRepository.create({ providerEventId: event.providerEventId, eventType: event.kind }), + ) + }) + } catch (error) { + if (!this.isUniqueViolation(error)) throw error + } + } + + private async applyTopUpEvent( + manager: EntityManager, + event: Exclude, + ): Promise { + const topUpRepository = manager.getRepository(TopUpRecord) + const topUp = await topUpRepository.findOne({ + where: { id: event.topUpId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!topUp || topUp.organizationId !== event.organizationId) { + throw new BadRequestException('top-up does not match provider event') + } + + if (event.kind === 'top_up_failed') { + if (topUp.status === 'paid') return + topUp.status = 'failed' + topUp.failureCode = event.failureCode + topUp.failureMessage = event.failureMessage + topUp.completedAt = new Date() + await topUpRepository.save(topUp) + if (topUp.source === 'auto_reload') { + const wallet = await manager.getRepository(Wallet).findOne({ + where: { id: topUp.walletId }, + lock: { mode: 'pessimistic_write' }, + }) + if (wallet) { + wallet.autoReloadNextAttemptAt = new Date(Date.now() + AUTO_RELOAD_RETRY_MILLISECONDS) + await manager.getRepository(Wallet).save(wallet) + } + } + return + } + + if (event.currency.toLowerCase() !== 'usd' || event.amountCents !== topUp.amountCents) { + throw new BadRequestException('provider payment amount or currency does not match top-up') + } + if (topUp.status === 'paid') return + + const wallet = await manager.getRepository(Wallet).findOne({ + where: { id: topUp.walletId }, + lock: { mode: 'pessimistic_write' }, + }) + if (!wallet) throw new BadRequestException('wallet not found') + wallet.paidBalanceCents = (BigInt(wallet.paidBalanceCents) + BigInt(topUp.amountCents)).toString() + wallet.billingStatus = this.statusForWallet(wallet) + wallet.autoReloadNextAttemptAt = null + await manager.getRepository(Wallet).save(wallet) + + topUp.status = 'paid' + topUp.receiptUrl = event.receiptUrl + topUp.failureCode = null + topUp.failureMessage = null + topUp.completedAt = new Date() + await topUpRepository.save(topUp) + const transactionRepository = manager.getRepository(WalletTransaction) + await transactionRepository.save( + transactionRepository.create({ + walletId: wallet.id, + organizationId: wallet.organizationId, + kind: 'top_up', + amountCents: topUp.amountCents, + source: topUp.source === 'auto_reload' ? 'auto_reload' : 'manual_top_up', + ratedPeriodId: null, + metadata: { topUpId: topUp.id, providerReference: event.providerReference }, + }), + ) + } + + private positiveCents(value: string | null, name: string, allowZero = false): bigint { + if (value === null || !/^\d+$/.test(value)) throw new BadRequestException(`${name} must be a whole cent value`) + const amount = BigInt(value) + if (allowZero ? amount < 0n : amount <= 0n) throw new BadRequestException(`${name} must be positive`) + if (amount > BigInt(Number.MAX_SAFE_INTEGER)) throw new BadRequestException(`${name} is too large`) + return amount + } + + private topUpView(topUp: TopUpRecord) { + return { id: topUp.id, status: topUp.status, checkoutUrl: topUp.checkoutUrl } + } + + private assertIdempotentAmount(topUp: TopUpRecord, amountCents: bigint): void { + if (topUp.amountCents !== amountCents.toString()) { + throw new BadRequestException('idempotency key was already used with a different amount') + } + } + + private returnUrls() { + const dashboardUrl = this.configService.getOrThrow('dashboardUrl').replace(/\/$/, '') + return { + successUrl: `${dashboardUrl}/dashboard/billing?payment=success`, + cancelUrl: `${dashboardUrl}/dashboard/billing?payment=cancelled`, + } + } + + private statusForWallet(wallet: Wallet): BillingStatus { + const total = BigInt(wallet.freeBalanceCents) + BigInt(wallet.paidBalanceCents) + if (total <= 0n) return 'zero_balance' + return BigInt(wallet.freeBalanceCents) > 0n ? 'trial' : 'active' + } + + private isUniqueViolation(error: unknown): boolean { + return ( + error instanceof QueryFailedError && + (error.driverError as { code?: string } | undefined)?.code === PG_UNIQUE_VIOLATION + ) + } +} diff --git a/apps/api/src/billing/payment/stripe-payment.provider.ts b/apps/api/src/billing/payment/stripe-payment.provider.ts new file mode 100644 index 000000000..a4bbd6fd4 --- /dev/null +++ b/apps/api/src/billing/payment/stripe-payment.provider.ts @@ -0,0 +1,289 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import Stripe from 'stripe' +import { + PaymentMethodView, + PaymentProvider, + PaymentSetupInput, + PaymentSetupResult, + ProviderWebhookEvent, + TopUpPaymentInput, + TopUpPaymentResult, +} from './payment-provider' + +export class StripePaymentProvider implements PaymentProvider { + readonly mode = 'stripe' as const + + constructor( + secretKey: string, + private readonly webhookSecret: string, + private readonly stripe = new Stripe(secretKey), + ) {} + + async createSetup(input: PaymentSetupInput): Promise { + const providerCustomerId = input.providerCustomerId ?? (await this.createCustomer(input)).id + const session = await this.stripe.checkout.sessions.create( + { + mode: 'setup', + customer: providerCustomerId, + payment_method_types: ['card'], + success_url: input.successUrl, + cancel_url: input.cancelUrl, + metadata: { + organizationId: input.organizationId, + walletId: input.walletId, + operation: 'setup', + }, + }, + { idempotencyKey: `wallet-setup:${input.walletId}:${input.setupAttemptId}` }, + ) + if (!session.url) { + throw new Error(`Stripe setup session ${session.id} did not return a checkout URL`) + } + return { + status: 'pending', + checkoutUrl: session.url, + providerReference: session.id, + providerCustomerId, + paymentMethod: null, + } + } + + async createManualTopUp(input: TopUpPaymentInput): Promise { + const session = await this.stripe.checkout.sessions.create( + { + mode: 'payment', + customer: input.providerCustomerId, + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'usd', + product_data: { name: 'BoxLite wallet top-up' }, + unit_amount: Number(input.amountCents), + }, + quantity: 1, + }, + ], + client_reference_id: input.topUpId, + success_url: input.successUrl, + cancel_url: input.cancelUrl, + metadata: { organizationId: input.organizationId, topUpId: input.topUpId, operation: 'top_up' }, + payment_intent_data: { + metadata: { organizationId: input.organizationId, topUpId: input.topUpId, operation: 'top_up' }, + }, + }, + { idempotencyKey: `manual-top-up:${input.topUpId}` }, + ) + if (!session.url) { + throw new Error(`Stripe payment session ${session.id} did not return a checkout URL`) + } + return { + status: 'pending', + checkoutUrl: session.url, + providerReference: session.id, + receiptUrl: null, + } + } + + async chargeSavedMethod(input: TopUpPaymentInput): Promise { + try { + const paymentIntent = await this.stripe.paymentIntents.create( + { + amount: Number(input.amountCents), + currency: 'usd', + customer: input.providerCustomerId, + payment_method: input.providerMethodId, + confirm: true, + off_session: true, + metadata: { organizationId: input.organizationId, topUpId: input.topUpId, operation: 'auto_reload' }, + expand: ['latest_charge'], + }, + { idempotencyKey: `auto-reload:${input.topUpId}` }, + ) + if (paymentIntent.status !== 'succeeded') { + return { + status: 'failed', + checkoutUrl: null, + providerReference: paymentIntent.id, + receiptUrl: null, + failureCode: paymentIntent.status, + failureMessage: 'off-session payment did not succeed', + } + } + return { + status: 'paid', + checkoutUrl: null, + providerReference: paymentIntent.id, + receiptUrl: this.receiptUrl(paymentIntent.latest_charge), + } + } catch (error) { + const stripeError = error as { + type?: string + code?: string + message?: string + payment_intent?: { id?: string } + } + if (stripeError.type !== 'StripeCardError' || !stripeError.payment_intent?.id) throw error + return { + status: 'failed', + checkoutUrl: null, + providerReference: stripeError.payment_intent.id, + receiptUrl: null, + failureCode: stripeError.code ?? 'card_declined', + failureMessage: stripeError.message ?? 'off-session payment failed', + } + } + } + + async parseWebhook(payload: Buffer, signature: string): Promise { + const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret) + + if (event.type === 'checkout.session.completed') { + const session = event.data.object + if (session.mode === 'setup') { + return this.setupEvent(event.id, session) + } + if (session.mode === 'payment' && session.payment_status === 'paid') { + return this.checkoutPaidEvent(event.id, session) + } + } + + if (event.type === 'checkout.session.async_payment_failed') { + return this.checkoutFailedEvent(event.id, event.data.object) + } + + if (event.type === 'payment_intent.succeeded' && event.data.object.metadata?.operation === 'auto_reload') { + return this.paymentIntentPaidEvent(event.id, event.data.object) + } + + if (event.type === 'payment_intent.payment_failed' && event.data.object.metadata?.operation === 'auto_reload') { + return this.paymentIntentFailedEvent(event.id, event.data.object) + } + + return null + } + + private createCustomer(input: PaymentSetupInput) { + return this.stripe.customers.create( + { metadata: { organizationId: input.organizationId, walletId: input.walletId } }, + { idempotencyKey: `wallet-customer:${input.walletId}` }, + ) + } + + private async setupEvent(providerEventId: string, session: Stripe.Checkout.Session): Promise { + const organizationId = this.requiredMetadata(session, 'organizationId') + const customerId = this.objectId(session.customer, 'customer') + const setupIntent = + typeof session.setup_intent === 'string' + ? await this.stripe.setupIntents.retrieve(session.setup_intent) + : session.setup_intent + if (!setupIntent) { + throw new Error(`Stripe setup session ${session.id} has no setup intent`) + } + const paymentMethod = + typeof setupIntent.payment_method === 'string' + ? await this.stripe.paymentMethods.retrieve(setupIntent.payment_method) + : setupIntent.payment_method + if (!paymentMethod || paymentMethod.type !== 'card' || !paymentMethod.card) { + throw new Error(`Stripe setup session ${session.id} did not attach a card`) + } + return { + kind: 'setup_succeeded', + providerEventId, + providerReference: session.id, + organizationId, + providerCustomerId: customerId, + paymentMethod: this.paymentMethodView(paymentMethod), + } + } + + private async checkoutPaidEvent( + providerEventId: string, + session: Stripe.Checkout.Session, + ): Promise { + const receiptUrl = await this.receiptUrlForPaymentIntent(session.payment_intent) + return { + kind: 'top_up_paid', + providerEventId, + providerReference: session.id, + topUpId: this.requiredMetadata(session, 'topUpId'), + organizationId: this.requiredMetadata(session, 'organizationId'), + amountCents: String(session.amount_total ?? 0), + currency: session.currency ?? '', + receiptUrl, + } + } + + private checkoutFailedEvent(providerEventId: string, session: Stripe.Checkout.Session): ProviderWebhookEvent { + return { + kind: 'top_up_failed', + providerEventId, + providerReference: session.id, + topUpId: this.requiredMetadata(session, 'topUpId'), + organizationId: this.requiredMetadata(session, 'organizationId'), + failureCode: 'async_payment_failed', + failureMessage: 'Stripe Checkout payment failed', + } + } + + private paymentIntentPaidEvent(providerEventId: string, intent: Stripe.PaymentIntent): ProviderWebhookEvent { + return { + kind: 'top_up_paid', + providerEventId, + providerReference: intent.id, + topUpId: this.requiredMetadata(intent, 'topUpId'), + organizationId: this.requiredMetadata(intent, 'organizationId'), + amountCents: String(intent.amount_received), + currency: intent.currency, + receiptUrl: this.receiptUrl(intent.latest_charge), + } + } + + private paymentIntentFailedEvent(providerEventId: string, intent: Stripe.PaymentIntent): ProviderWebhookEvent { + return { + kind: 'top_up_failed', + providerEventId, + providerReference: intent.id, + topUpId: this.requiredMetadata(intent, 'topUpId'), + organizationId: this.requiredMetadata(intent, 'organizationId'), + failureCode: intent.last_payment_error?.code ?? null, + failureMessage: intent.last_payment_error?.message ?? null, + } + } + + private async receiptUrlForPaymentIntent( + paymentIntent: string | Stripe.PaymentIntent | null, + ): Promise { + if (!paymentIntent) return null + const intent = + typeof paymentIntent === 'string' + ? await this.stripe.paymentIntents.retrieve(paymentIntent, { expand: ['latest_charge'] }) + : paymentIntent + return this.receiptUrl(intent.latest_charge) + } + + private receiptUrl(charge: string | Stripe.Charge | null): string | null { + return typeof charge === 'object' && charge ? charge.receipt_url : null + } + + private paymentMethodView(paymentMethod: Stripe.PaymentMethod): PaymentMethodView { + if (!paymentMethod.card) throw new Error(`Stripe payment method ${paymentMethod.id} is not a card`) + return { id: paymentMethod.id, brand: paymentMethod.card.brand, last4: paymentMethod.card.last4 } + } + + private objectId(value: string | { id: string } | null, name: string): string { + if (typeof value === 'string') return value + if (value?.id) return value.id + throw new Error(`Stripe object is missing ${name}`) + } + + private requiredMetadata(object: { id: string; metadata: Stripe.Metadata }, key: string): string { + const value = object.metadata?.[key] + if (!value) throw new Error(`Stripe object ${object.id} is missing metadata.${key}`) + return value + } +} diff --git a/apps/api/src/billing/settlement.service.spec.ts b/apps/api/src/billing/settlement.service.spec.ts index a7b194944..8e7258275 100644 --- a/apps/api/src/billing/settlement.service.spec.ts +++ b/apps/api/src/billing/settlement.service.spec.ts @@ -41,4 +41,31 @@ describe('SettlementService', () => { await expect(service.settleClosedPeriods()).rejects.toThrow('rating unavailable') expect(walletService.debitRatedPeriods).not.toHaveBeenCalled() }) + + it('finishes the debit on the next sweep when a worker stops after rating', async () => { + const ratingService = { + rateClosedPeriods: jest + .fn() + .mockResolvedValueOnce({ rated: 1, skipped: 0 }) + .mockResolvedValueOnce({ rated: 0, skipped: 1 }), + } + const walletService = { + debitRatedPeriods: jest + .fn() + .mockRejectedValueOnce(new Error('database connection lost')) + .mockResolvedValueOnce({ debited: 1, skipped: 0 }), + } + const service = new SettlementService(ratingService as never, walletService as never) + + await expect(service.settleClosedPeriods()).rejects.toThrow('database connection lost') + await expect(service.settleClosedPeriods()).resolves.toEqual({ + rated: 0, + ratingSkipped: 1, + debited: 1, + debitSkipped: 0, + }) + + expect(ratingService.rateClosedPeriods).toHaveBeenCalledTimes(2) + expect(walletService.debitRatedPeriods).toHaveBeenCalledTimes(2) + }) }) diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 0167d0a53..e4858f28a 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -169,6 +169,13 @@ const configuration = { billing: { trialGrantCents: parseInt(process.env.BILLING_TRIAL_GRANT_CENTS || '10000', 10), trialDurationDays: parseInt(process.env.BILLING_TRIAL_DURATION_DAYS || '30', 10), + paymentProvider: + process.env.BILLING_PAYMENT_PROVIDER || + (process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' ? undefined : 'fake'), + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + }, }, analyticsApiUrl: process.env.ANALYTICS_API_URL, defaultRunner: { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 402786630..f2087e0a2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -46,6 +46,7 @@ async function bootstrap() { } const app = await NestFactory.create(AppModule, { bufferLogs: true, + rawBody: true, httpsOptions: httpsEnabled ? httpsOptions : undefined, }) app.useLogger(app.get(PinoLogger)) diff --git a/apps/api/src/migrations/pre-deploy/1782700300000-migration.spec.ts b/apps/api/src/migrations/pre-deploy/1782700300000-migration.spec.ts new file mode 100644 index 000000000..c5861cf90 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700300000-migration.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { QueryRunner } from 'typeorm' +import { Migration1782700300000 } from './1782700300000-migration' + +class RecordingQueryRunner { + readonly queries: string[] = [] + + async query(sql: string): Promise { + this.queries.push(sql.replace(/\s+/g, ' ').trim()) + } +} + +describe('Migration1782700300000', () => { + it('adds payment state, top-up records, and a durable provider-event inbox', async () => { + const runner = new RecordingQueryRunner() + + await new Migration1782700300000().up(runner as unknown as QueryRunner) + + const sql = runner.queries.join('\n') + expect(sql).toContain('ALTER TABLE "wallet" ADD "paymentProviderCustomerId"') + expect(sql).toContain('ALTER TABLE "wallet" ADD "autoReloadEnabled" boolean NOT NULL DEFAULT false') + expect(sql).toContain('ALTER TABLE "wallet" ADD "autoReloadNextAttemptAt" TIMESTAMP WITH TIME ZONE') + expect(sql).toContain('CREATE TABLE "top_up_record"') + expect(sql).toContain('"status" character varying NOT NULL DEFAULT \'pending\'') + expect(sql).toContain('CREATE UNIQUE INDEX "top_up_record_org_idempotency_idx"') + expect(sql).toContain('CREATE TABLE "payment_provider_event"') + expect(sql).toContain('CREATE UNIQUE INDEX "payment_provider_event_provider_id_idx"') + }) + + it('removes payment tables before wallet columns', async () => { + const runner = new RecordingQueryRunner() + + await new Migration1782700300000().down(runner as unknown as QueryRunner) + + expect(runner.queries.slice(0, 2)).toEqual(['DROP TABLE "payment_provider_event"', 'DROP TABLE "top_up_record"']) + expect(runner.queries.at(-1)).toBe('ALTER TABLE "wallet" DROP COLUMN "paymentProviderCustomerId"') + }) +}) diff --git a/apps/api/src/migrations/pre-deploy/1782700300000-migration.ts b/apps/api/src/migrations/pre-deploy/1782700300000-migration.ts new file mode 100644 index 000000000..baa895287 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700300000-migration.ts @@ -0,0 +1,83 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1782700300000 implements MigrationInterface { + name = 'Migration1782700300000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentProviderCustomerId" character varying`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentProviderMethodId" character varying`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentMethodBrand" character varying`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentMethodLast4" character varying`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "autoReloadEnabled" boolean NOT NULL DEFAULT false`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "autoReloadThresholdCents" bigint`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "autoReloadTargetCents" bigint`) + await queryRunner.query(`ALTER TABLE "wallet" ADD "autoReloadNextAttemptAt" TIMESTAMP WITH TIME ZONE`) + + await queryRunner.query( + `CREATE TABLE "top_up_record" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "walletId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "amountCents" bigint NOT NULL, + "source" character varying NOT NULL, + "status" character varying NOT NULL DEFAULT 'pending', + "idempotencyKey" character varying NOT NULL, + "providerReference" character varying, + "checkoutUrl" text, + "receiptUrl" text, + "failureCode" character varying, + "failureMessage" text, + "completedAt" TIMESTAMP WITH TIME ZONE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "top_up_record_positive_amount" CHECK ("amountCents" > 0), + CONSTRAINT "top_up_record_wallet_fk" FOREIGN KEY ("walletId") + REFERENCES "wallet"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "top_up_record_id_pk" PRIMARY KEY ("id") + )`, + ) + await queryRunner.query( + `CREATE INDEX "top_up_record_org_created_idx" ON "top_up_record" ("organizationId", "createdAt")`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "top_up_record_org_idempotency_idx" + ON "top_up_record" ("organizationId", "idempotencyKey")`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "top_up_record_provider_reference_idx" + ON "top_up_record" ("providerReference") WHERE "providerReference" IS NOT NULL`, + ) + + await queryRunner.query( + `CREATE TABLE "payment_provider_event" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "providerEventId" character varying NOT NULL, + "eventType" character varying NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "payment_provider_event_id_pk" PRIMARY KEY ("id") + )`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "payment_provider_event_provider_id_idx" + ON "payment_provider_event" ("providerEventId")`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "payment_provider_event"`) + await queryRunner.query(`DROP TABLE "top_up_record"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "autoReloadNextAttemptAt"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "autoReloadTargetCents"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "autoReloadThresholdCents"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "autoReloadEnabled"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentMethodLast4"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentMethodBrand"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentProviderMethodId"`) + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentProviderCustomerId"`) + } +} diff --git a/apps/package.json b/apps/package.json index fbb640c47..30e46c793 100644 --- a/apps/package.json +++ b/apps/package.json @@ -17,6 +17,7 @@ "dev:dex": "node scripts/dev-dex.mjs", "e2e:local": "node scripts/e2e-local.mjs", "e2e:dev": "node scripts/e2e-dev-smoke.mjs", + "test:billing-edge": "BILLING_EDGE_DB_TESTS=1 jest --config api/jest.config.ts --runInBand api/src/billing/billing-edge-cases.integration.spec.ts", "observability:agent-smoke": "node scripts/admin-observability-agent-smoke.mjs", "format": "nx run-many --target=format --all --parallel=$(getconf _NPROCESSORS_ONLN) && prettier --write \"*.{ts,js,json,yaml}\"", "lint": "yarn lint:ts", @@ -212,6 +213,7 @@ "socket.io-client": "^4.8.1", "sonner": "^1.7.4", "starlight-openapi": "^0.19.1", + "stripe": "^22.3.0", "suspend-react": "^0.1.3", "svix": "^1.84.1", "svix-react": "^1.13.7", diff --git a/apps/yarn.lock b/apps/yarn.lock index e2e00f732..fc9e6ccef 100644 --- a/apps/yarn.lock +++ b/apps/yarn.lock @@ -16933,6 +16933,7 @@ __metadata: sqlite3: "npm:^5.1.7" starlight-openapi: "npm:^0.19.1" storybook: "npm:8" + stripe: "npm:^22.3.0" suspend-react: "npm:^0.1.3" svix: "npm:^1.84.1" svix-react: "npm:^1.13.7" @@ -35944,6 +35945,18 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^22.3.0": + version: 22.3.0 + resolution: "stripe@npm:22.3.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/6a41aeefc518031e7329872a4d9c0d468c918eef063408f48e1a1ddf20e47414992379fff158b3c5ed7ce7219da1715c97da2e6b07e104e35fd37cc865516872 + languageName: node + linkType: hard + "strnum@npm:^2.2.3": version: 2.3.0 resolution: "strnum@npm:2.3.0" From a5cf97e7cb5e922643e9c2faef0efb8434d98f35 Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:54:11 +0800 Subject: [PATCH 2/6] feat(billing): add prepaid payment interface --- .../src/billing-api/billingApiClient.test.ts | 51 ++ .../src/billing-api/billingApiClient.ts | 40 ++ .../src/billing-api/types/BillingPayment.ts | 54 ++ apps/dashboard/src/billing-api/types/index.ts | 1 + .../billing/BalanceOverviewCard.tsx | 4 + .../BillingPaymentMethodSection.test.tsx | 164 +++++ .../billing/BillingPaymentMethodSection.tsx | 137 +++++ .../billing/BillingPaymentPanel.test.tsx | 366 +++++++++++ .../billing/BillingPaymentPanel.tsx | 573 ++++++++++++++++++ .../src/components/billing/ascii.tsx | 27 + .../billingPaymentMutations.test.tsx | 174 ++++++ .../useCreateBillingTopUpMutation.ts | 52 ++ .../useSetupBillingPaymentMutation.ts | 26 + .../useUpdateBillingAutoReloadMutation.ts | 26 + apps/dashboard/src/hooks/queries/queryKeys.ts | 7 +- .../hooks/queries/useBillingPaymentQuery.ts | 20 + .../hooks/queries/useBillingReceiptsQuery.ts | 32 + apps/dashboard/src/lib/billing-checkout.ts | 8 + apps/dashboard/src/pages/Billing.test.tsx | 47 +- apps/dashboard/src/pages/Billing.tsx | 154 +++-- 20 files changed, 1902 insertions(+), 61 deletions(-) create mode 100644 apps/dashboard/src/billing-api/types/BillingPayment.ts create mode 100644 apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx create mode 100644 apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx create mode 100644 apps/dashboard/src/components/billing/BillingPaymentPanel.test.tsx create mode 100644 apps/dashboard/src/components/billing/BillingPaymentPanel.tsx create mode 100644 apps/dashboard/src/hooks/mutations/billingPaymentMutations.test.tsx create mode 100644 apps/dashboard/src/hooks/mutations/useCreateBillingTopUpMutation.ts create mode 100644 apps/dashboard/src/hooks/mutations/useSetupBillingPaymentMutation.ts create mode 100644 apps/dashboard/src/hooks/mutations/useUpdateBillingAutoReloadMutation.ts create mode 100644 apps/dashboard/src/hooks/queries/useBillingPaymentQuery.ts create mode 100644 apps/dashboard/src/hooks/queries/useBillingReceiptsQuery.ts create mode 100644 apps/dashboard/src/lib/billing-checkout.ts diff --git a/apps/dashboard/src/billing-api/billingApiClient.test.ts b/apps/dashboard/src/billing-api/billingApiClient.test.ts index 6ab174fad..e7f81e816 100644 --- a/apps/dashboard/src/billing-api/billingApiClient.test.ts +++ b/apps/dashboard/src/billing-api/billingApiClient.test.ts @@ -46,4 +46,55 @@ describe('BillingApiClient routing', () => { await expect(client.getBillingPricing('org-1')).rejects.toThrow('re-authentication started') expect(onUnauthorized).toHaveBeenCalledTimes(1) }) + + it('uses the core API contract for payment setup, auto-reload, top-ups, and receipts', async () => { + const requests: AxiosRequestConfig[] = [] + axios.defaults.adapter = async (config) => { + requests.push(config) + return { data: {}, status: 200, statusText: 'OK', headers: {}, config } + } + const client = new BillingApiClient('https://legacy-billing.test', 'token', 'https://api.test/api') + + await client.getBillingPayment('org-1') + await client.setupBillingPayment('org-1') + await client.updateBillingAutoReload('org-1', { + enabled: true, + thresholdCents: '2000', + targetCents: '10000', + }) + await client.createBillingTopUp('org-1', '50000', 'top-up-request-1') + await client.getBillingReceipts('org-1', { page: 2, pageSize: 8, query: 'failed top up' }) + + expect(requests).toEqual([ + expect.objectContaining({ + baseURL: 'https://api.test/api', + method: 'get', + url: '/organization/org-1/billing/payment', + }), + expect.objectContaining({ + baseURL: 'https://api.test/api', + method: 'post', + url: '/organization/org-1/billing/payment/setup', + }), + expect.objectContaining({ + baseURL: 'https://api.test/api', + data: JSON.stringify({ enabled: true, thresholdCents: '2000', targetCents: '10000' }), + method: 'put', + url: '/organization/org-1/billing/auto-reload', + }), + expect.objectContaining({ + baseURL: 'https://api.test/api', + data: JSON.stringify({ amountCents: '50000' }), + headers: expect.objectContaining({ 'Idempotency-Key': 'top-up-request-1' }), + method: 'post', + url: '/organization/org-1/billing/top-ups', + }), + expect.objectContaining({ + baseURL: 'https://api.test/api', + method: 'get', + params: { page: 2, pageSize: 8, query: 'failed top up' }, + url: '/organization/org-1/billing/receipts', + }), + ]) + }) }) diff --git a/apps/dashboard/src/billing-api/billingApiClient.ts b/apps/dashboard/src/billing-api/billingApiClient.ts index 1717b4f85..1e9f43827 100644 --- a/apps/dashboard/src/billing-api/billingApiClient.ts +++ b/apps/dashboard/src/billing-api/billingApiClient.ts @@ -8,8 +8,14 @@ import { BoxliteError } from '@/api/errors' import axios, { AxiosInstance } from 'axios' import { AutomaticTopUp, + BillingAutoReload, BillingOverview, + BillingPayment, + BillingPaymentSetupResult, BillingPricing, + BillingReceiptsPage, + BillingReceiptsQuery, + BillingTopUpResult, BillingUsageSummary, OrganizationEmail, OrganizationTier, @@ -77,6 +83,40 @@ export class BillingApiClient { return response.data } + public async getBillingPayment(organizationId: string): Promise { + const response = await this.coreAxiosInstance.get(`/organization/${organizationId}/billing/payment`) + return response.data + } + + public async setupBillingPayment(organizationId: string): Promise { + const response = await this.coreAxiosInstance.post(`/organization/${organizationId}/billing/payment/setup`) + return response.data + } + + public async updateBillingAutoReload(organizationId: string, autoReload: BillingAutoReload): Promise { + await this.coreAxiosInstance.put(`/organization/${organizationId}/billing/auto-reload`, autoReload) + } + + public async createBillingTopUp( + organizationId: string, + amountCents: string, + idempotencyKey: string, + ): Promise { + const response = await this.coreAxiosInstance.post( + `/organization/${organizationId}/billing/top-ups`, + { amountCents }, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return response.data + } + + public async getBillingReceipts(organizationId: string, query: BillingReceiptsQuery): Promise { + const response = await this.coreAxiosInstance.get(`/organization/${organizationId}/billing/receipts`, { + params: query, + }) + return response.data + } + public async getBillingPricing(organizationId: string): Promise { const response = await this.coreAxiosInstance.get(`/organization/${organizationId}/billing/pricing`) return response.data diff --git a/apps/dashboard/src/billing-api/types/BillingPayment.ts b/apps/dashboard/src/billing-api/types/BillingPayment.ts new file mode 100644 index 000000000..4387cfb45 --- /dev/null +++ b/apps/dashboard/src/billing-api/types/BillingPayment.ts @@ -0,0 +1,54 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +export interface BillingPaymentMethod { + brand: string + last4: string +} + +export interface BillingAutoReload { + enabled: boolean + thresholdCents: string | null + targetCents: string | null +} + +export interface BillingPayment { + providerMode: 'fake' | 'stripe' + paymentMethod: BillingPaymentMethod | null + autoReload: BillingAutoReload +} + +export interface BillingPaymentSetupResult { + status: 'ready' | 'pending' + checkoutUrl: string | null +} + +export interface BillingTopUpResult { + id: string + status: 'pending' | 'paid' | 'failed' + checkoutUrl: string | null +} + +export interface BillingReceipt { + id: string + createdAt: string + amountCents: string + type: 'top_up' | 'usage' + status: 'paid' | 'failed' + receiptUrl: string | null +} + +export interface BillingReceiptsPage { + items: BillingReceipt[] + page: number + pageSize: number + total: number +} + +export interface BillingReceiptsQuery { + page: number + pageSize: number + query: string +} diff --git a/apps/dashboard/src/billing-api/types/index.ts b/apps/dashboard/src/billing-api/types/index.ts index af1c98f6c..c3aa5b1de 100644 --- a/apps/dashboard/src/billing-api/types/index.ts +++ b/apps/dashboard/src/billing-api/types/index.ts @@ -8,6 +8,7 @@ export * from './OrganizationTier' export * from './OrganizationUsage' export * from './OrganizationWallet' export * from './BillingOverview' +export * from './BillingPayment' export * from './tier' export * from './OrganizationEmail' export * from './Invoice' diff --git a/apps/dashboard/src/components/billing/BalanceOverviewCard.tsx b/apps/dashboard/src/components/billing/BalanceOverviewCard.tsx index cea4009ff..09c9303ad 100644 --- a/apps/dashboard/src/components/billing/BalanceOverviewCard.tsx +++ b/apps/dashboard/src/components/billing/BalanceOverviewCard.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0 */ +import type { ReactNode } from 'react' import { BILLING_BRAND, MatrixAmount } from './ascii' function centsToDisplay(cents: string): string { @@ -30,12 +31,14 @@ export function BalanceOverviewCard({ paidBalanceCents, spentThisMonthCents, freeExpiresAt, + paymentMethod, }: { totalBalanceCents: string freeBalanceCents: string paidBalanceCents: string spentThisMonthCents: string freeExpiresAt: string | null + paymentMethod: ReactNode }) { const expiry = freeExpiresAt ? new Date(freeExpiresAt) : null const expiryLabel = expiry && !Number.isNaN(expiry.getTime()) ? ` · expires ${expiry.toLocaleDateString()}` : '' @@ -54,6 +57,7 @@ export function BalanceOverviewCard({ | Paid balance ${centsToDisplay(paidBalanceCents)} + {paymentMethod} ) } diff --git a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx new file mode 100644 index 000000000..388370b7b --- /dev/null +++ b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { act, type ComponentProps, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { BillingPaymentMethodSection } from './BillingPaymentMethodSection' + +const state = vi.hoisted(() => ({ + mode: 'ready' as 'ready' | 'loading' | 'error', + payment: { + providerMode: 'stripe' as 'fake' | 'stripe', + paymentMethod: null as { brand: string; last4: string } | null, + autoReload: { enabled: false, thresholdCents: null, targetCents: null }, + }, + refetch: vi.fn(), + setupPayment: vi.fn(), + addFunds: vi.fn(), + redirectToCheckout: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('@/hooks/queries/useBillingPaymentQuery', () => ({ + useBillingPaymentQuery: () => + state.mode === 'ready' + ? { data: state.payment, isLoading: false, isError: false, refetch: state.refetch } + : { + data: undefined, + isLoading: state.mode === 'loading', + isError: state.mode === 'error', + refetch: state.refetch, + }, +})) + +vi.mock('@/hooks/mutations/useSetupBillingPaymentMutation', () => ({ + useSetupBillingPaymentMutation: () => ({ mutateAsync: state.setupPayment, isPending: false }), +})) + +vi.mock('@/lib/billing-checkout', () => ({ + redirectToBillingCheckout: state.redirectToCheckout, +})) + +vi.mock('sonner', () => ({ + toast: { success: state.toastSuccess, error: state.toastError }, +})) + +vi.mock('@/components/ui/alert-dialog', () => { + const Container = ({ children }: { children: ReactNode }) => <>{children} + const Button = ({ children, ...props }: ComponentProps<'button'>) => + return { + AlertDialog: Container, + AlertDialogAction: Button, + AlertDialogCancel: Button, + AlertDialogContent: Container, + AlertDialogDescription: Container, + AlertDialogFooter: Container, + AlertDialogHeader: Container, + AlertDialogTitle: Container, + AlertDialogTrigger: Container, + } +}) + +function findButton(label: string): HTMLButtonElement { + const button = Array.from(document.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label, + ) + if (!button) throw new Error(`Button not found: ${label}`) + return button +} + +async function click(element: HTMLElement) { + await act(async () => { + element.click() + await Promise.resolve() + }) +} + +describe('BillingPaymentMethodSection', () => { + let root: Root | null = null + + beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + state.mode = 'ready' + state.payment.providerMode = 'stripe' + state.payment.paymentMethod = null + state.refetch.mockReset() + state.setupPayment.mockReset().mockResolvedValue({ status: 'ready', checkoutUrl: null }) + state.addFunds.mockReset() + state.redirectToCheckout.mockReset() + state.toastSuccess.mockReset() + state.toastError.mockReset() + }) + + afterEach(() => { + act(() => root?.unmount()) + root = null + document.body.innerHTML = '' + vi.clearAllMocks() + }) + + async function renderSection() { + const host = document.createElement('div') + document.body.appendChild(host) + await act(async () => { + root = createRoot(host) + root.render() + }) + } + + it('shows setup only when the Usage balance has no payment method', async () => { + await renderSection() + + expect(document.body.textContent).toContain('Payment method') + expect(document.body.textContent).toContain('No payment method configured') + expect(findButton('Set up payment method')).toBeTruthy() + }) + + it('restores the card-brand row with change-card and add-funds actions', async () => { + state.payment.paymentMethod = { brand: 'visa', last4: '4242' } + await renderSection() + + expect(document.querySelector('[data-testid="billing-payment-method-row"]')).toBeTruthy() + expect(document.body.textContent).toContain('VISA') + expect(document.body.textContent).toContain('···· 4242') + expect(document.querySelector('button[aria-label="Change payment method"]')).toBeTruthy() + await click(findButton('Add funds')) + expect(state.addFunds).toHaveBeenCalledOnce() + }) + + it('requires confirmation and redirects a setup checkout response', async () => { + state.setupPayment.mockResolvedValue({ status: 'pending', checkoutUrl: 'https://checkout.test/setup' }) + await renderSection() + + await click(findButton('Set up payment method')) + expect(state.setupPayment).not.toHaveBeenCalled() + await click(findButton('Confirm setup')) + + expect(state.setupPayment).toHaveBeenCalledWith({ organizationId: 'org-1' }) + expect(state.redirectToCheckout).toHaveBeenCalledWith('https://checkout.test/setup') + }) + + it('keeps loading and failure states honest and retryable', async () => { + state.mode = 'loading' + await renderSection() + expect(document.body.textContent).toContain('Loading payment settings') + expect(document.body.textContent).not.toContain('Set up payment method') + + act(() => root?.unmount()) + root = null + document.body.innerHTML = '' + state.mode = 'error' + await renderSection() + expect(document.body.textContent).toContain('Payment method is unavailable') + await click(findButton('Retry payment method')) + expect(state.refetch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx new file mode 100644 index 000000000..68dca9ac4 --- /dev/null +++ b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx @@ -0,0 +1,137 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingPayment } from '@/billing-api' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ArrowUpRight, Loader2, RefreshCw } from '@/components/ui/icon' +import { useSetupBillingPaymentMutation } from '@/hooks/mutations/useSetupBillingPaymentMutation' +import { useBillingPaymentQuery } from '@/hooks/queries/useBillingPaymentQuery' +import { redirectToBillingCheckout } from '@/lib/billing-checkout' +import { toast } from 'sonner' +import { BILLING_BRAND, CardBrand } from './ascii' + +function PaymentMethodCard({ + organizationId, + payment, + onAddFunds, +}: { + organizationId: string + payment: BillingPayment + onAddFunds?: () => void +}) { + const setupMutation = useSetupBillingPaymentMutation() + + const setupPayment = async () => { + try { + const result = await setupMutation.mutateAsync({ organizationId }) + if (result.checkoutUrl) { + redirectToBillingCheckout(result.checkoutUrl) + return + } + toast.success(result.status === 'ready' ? 'Payment method is ready' : 'Payment setup is pending') + } catch { + toast.error('Failed to start payment setup') + } + } + + return ( +
+ + Payment method + + + + {payment.paymentMethod ? ( + + ) : ( + + )} + + + + Confirm payment method setup + + {payment.providerMode === 'stripe' + ? 'Continue to Stripe to securely add or replace the organization payment method.' + : 'Continue with the test payment provider for this organization.'} + + + + Cancel + + Confirm setup + + + + + {payment.paymentMethod ? ( + + ) : ( + No payment method configured + )} +
+ ) +} + +export function BillingPaymentMethodSection({ + organizationId, + onAddFunds, +}: { + organizationId: string + onAddFunds?: () => void +}) { + const paymentQuery = useBillingPaymentQuery(organizationId) + + if (paymentQuery.isLoading) { + return ( +
+ Payment method / Loading payment settings... +
+ ) + } + + if (paymentQuery.isError || !paymentQuery.data) { + return ( +
+ Payment method is unavailable. + +
+ ) + } + + return +} diff --git a/apps/dashboard/src/components/billing/BillingPaymentPanel.test.tsx b/apps/dashboard/src/components/billing/BillingPaymentPanel.test.tsx new file mode 100644 index 000000000..61ca20e7e --- /dev/null +++ b/apps/dashboard/src/components/billing/BillingPaymentPanel.test.tsx @@ -0,0 +1,366 @@ +// @vitest-environment jsdom +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { act, type ComponentProps, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { BillingPaymentPanel } from './BillingPaymentPanel' + +const state = vi.hoisted(() => ({ + paymentMode: 'ready' as 'ready' | 'loading' | 'error', + receiptsMode: 'ready' as 'ready' | 'loading' | 'error', + payment: { + providerMode: 'stripe' as 'fake' | 'stripe', + paymentMethod: { brand: 'visa', last4: '4242' } as { brand: string; last4: string } | null, + autoReload: { enabled: true, thresholdCents: '2000', targetCents: '10000' }, + }, + receipts: { + items: [ + { + id: 'receipt-top-up', + createdAt: '2026-07-10T08:00:00.000Z', + amountCents: '50000', + type: 'top_up' as const, + status: 'paid' as const, + receiptUrl: 'https://billing.test/receipt-top-up', + }, + { + id: 'receipt-usage', + createdAt: '2026-07-09T08:00:00.000Z', + amountCents: '375', + type: 'usage' as const, + status: 'failed' as const, + receiptUrl: null, + }, + ], + page: 1, + pageSize: 8, + total: 17, + }, + paymentRefetch: vi.fn(), + receiptsRefetch: vi.fn(), + receiptsQueryArgs: vi.fn(), + setupPayment: vi.fn(), + updateAutoReload: vi.fn(), + createTopUp: vi.fn(), + redirectToCheckout: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('@/hooks/queries/useBillingPaymentQuery', () => ({ + useBillingPaymentQuery: () => + state.paymentMode === 'ready' + ? { data: state.payment, isLoading: false, isError: false, refetch: state.paymentRefetch } + : { + data: undefined, + isLoading: state.paymentMode === 'loading', + isError: state.paymentMode === 'error', + refetch: state.paymentRefetch, + }, +})) + +vi.mock('@/hooks/queries/useBillingReceiptsQuery', () => ({ + useBillingReceiptsQuery: (args: unknown) => { + state.receiptsQueryArgs(args) + return state.receiptsMode === 'ready' + ? { data: state.receipts, isLoading: false, isError: false, isFetching: false, refetch: state.receiptsRefetch } + : { + data: undefined, + isLoading: state.receiptsMode === 'loading', + isError: state.receiptsMode === 'error', + isFetching: false, + refetch: state.receiptsRefetch, + } + }, +})) + +vi.mock('@/hooks/mutations/useSetupBillingPaymentMutation', () => ({ + useSetupBillingPaymentMutation: () => ({ mutateAsync: state.setupPayment, isPending: false }), +})) + +vi.mock('@/hooks/mutations/useUpdateBillingAutoReloadMutation', () => ({ + useUpdateBillingAutoReloadMutation: () => ({ mutateAsync: state.updateAutoReload, isPending: false }), +})) + +vi.mock('@/hooks/mutations/useCreateBillingTopUpMutation', () => ({ + useCreateBillingTopUpMutation: () => ({ mutateAsync: state.createTopUp, isPending: false }), +})) + +vi.mock('sonner', () => ({ + toast: { success: state.toastSuccess, error: state.toastError }, +})) + +vi.mock('@/lib/billing-checkout', () => ({ + redirectToBillingCheckout: state.redirectToCheckout, +})) + +vi.mock('@/components/ui/alert-dialog', () => { + const Container = ({ children }: { children: ReactNode }) => <>{children} + const Button = ({ children, ...props }: ComponentProps<'button'>) => + return { + AlertDialog: Container, + AlertDialogAction: Button, + AlertDialogCancel: Button, + AlertDialogContent: Container, + AlertDialogDescription: Container, + AlertDialogFooter: Container, + AlertDialogHeader: Container, + AlertDialogTitle: Container, + AlertDialogTrigger: Container, + } +}) + +vi.mock('@/components/ui/dialog', () => { + const Container = ({ children }: { children: ReactNode }) => <>{children} + return { + Dialog: Container, + DialogClose: Container, + DialogContent: Container, + DialogDescription: Container, + DialogFooter: Container, + DialogHeader: Container, + DialogTitle: Container, + DialogTrigger: Container, + } +}) + +vi.mock('@/components/ui/switch', () => ({ + Switch: ({ + checked, + onCheckedChange, + ...props + }: ComponentProps<'button'> & { checked?: boolean; onCheckedChange?: (value: boolean) => void }) => ( + + + + + Auto-reload + Automatically add funds when the organization balance runs low. + + +
+
+ + +
+ + + + + + {validationError ? ( +
+ {validationError} +
+ ) : null} +
+ + + + + + + +
+ + + ) +} + +function OneTimeTopUp({ organizationId, payment }: { organizationId: string; payment: BillingPayment }) { + const mutation = useCreateBillingTopUpMutation() + const [presetCents, setPresetCents] = useState('50000') + const [customAmount, setCustomAmount] = useState('') + const customAmountCents = dollarsToCents(customAmount) + const amountCents = presetCents ?? customAmountCents + const isBelowMinimum = amountCents != null && BigInt(amountCents) < MINIMUM_TOP_UP_CENTS + const hasValidAmount = amountCents != null && BigInt(amountCents) >= MINIMUM_TOP_UP_CENTS + const canTopUp = Boolean(payment.paymentMethod && hasValidAmount) + + const selectPreset = (nextAmountCents: string) => { + setPresetCents(nextAmountCents) + setCustomAmount('') + } + + const changeCustomAmount = (value: string) => { + setCustomAmount(value) + setPresetCents(null) + } + + const createTopUp = async () => { + if (!canTopUp || amountCents == null) return + try { + const result = await mutation.mutateAsync({ organizationId, amountCents }) + if (result.checkoutUrl) { + redirectToBillingCheckout(result.checkoutUrl) + return + } + if (result.status === 'failed') { + toast.error('Top-up failed') + } else if (result.status === 'paid') { + toast.success('Top-up completed') + } else { + toast.success('Top-up started') + } + } catch { + toast.error('Failed to start top-up') + } + } + + return ( +
+ One-time top-up +
+ {TOP_UP_PRESETS.map((preset) => ( + + ))} + + + + + + + + + + + Confirm {amountCents == null ? 'top-up' : `${formatCents(amountCents)} top-up`} + + + {payment.providerMode === 'stripe' + ? 'You will continue to Stripe if additional payment confirmation is required.' + : 'The test payment provider will process this top-up immediately.'} + + + + Cancel + + Confirm top-up + + + + +
+ + {isBelowMinimum ? ( +
+ Minimum top-up is $5.00. +
+ ) : null} + +
+ {payment.paymentMethod + ? payment.providerMode === 'stripe' + ? 'Stripe may request additional confirmation before funds are added.' + : 'The test provider processes top-ups without leaving this page.' + : 'Configure a payment method in Usage to add funds.'} +
+
+ ) +} + +function ReceiptStatus({ status }: { status: BillingReceipt['status'] }) { + return ( + + + {status} + + ) +} + +function ReceiptRow({ receipt }: { receipt: BillingReceipt }) { + return ( +
+ {formatReceiptDate(receipt.createdAt)} + + {receipt.type === 'top_up' ? 'top up' : 'usage'} + + + + + + + {formatCents(receipt.amountCents)} + + + {receipt.receiptUrl ? ( + + + Open receipt + + ) : ( + + - + + )} + +
+ ) +} + +export function BillingPaymentPanel({ organizationId }: { organizationId: string }) { + const [receiptsPage, setReceiptsPage] = useState(1) + const [receiptsQuery, setReceiptsQuery] = useState('') + const paymentQuery = useBillingPaymentQuery(organizationId) + const receipts = useBillingReceiptsQuery({ + organizationId, + page: receiptsPage, + pageSize: RECEIPTS_PAGE_SIZE, + query: receiptsQuery, + }) + const pageCount = Math.max(1, Math.ceil((receipts.data?.total ?? 0) / RECEIPTS_PAGE_SIZE)) + const paymentDependencyMessage = paymentQuery.isLoading + ? 'Loading payment settings...' + : 'Payment settings are unavailable.' + + const changeReceiptsQuery = (query: string) => { + setReceiptsQuery(query) + setReceiptsPage(1) + } + + return ( +
+
+ +
+ {paymentQuery.data ? ( + + ) : ( + +
+ {paymentDependencyMessage} + {paymentQuery.isError ? ( + + ) : null} +
+
+ )} + +
+ + {paymentQuery.data ? ( + + ) : ( + {paymentDependencyMessage} + )} +
+
+ +
+ + + changeReceiptsQuery(event.target.value)} + placeholder="search receipts..." + className="h-full min-w-0 flex-1 bg-transparent pl-2 text-foreground outline-none placeholder:text-muted-foreground" + /> + + } + /> + + {receipts.isLoading ? ( + Loading receipts... + ) : receipts.isError || !receipts.data ? ( + +
+ Receipts are unavailable. + +
+
+ ) : ( +
+
+ Date + Type + Status + + Amount + +
+ + {receipts.data.items.length === 0 ? ( +
+ No receipts found +
+ ) : ( + receipts.data.items.map((receipt) => ) + )} +
+ )} + + {receipts.data ? ( +
+ + Page {receipts.data.page} of {pageCount} / {receipts.data.total.toLocaleString('en-US')} records + +
+ {receipts.isFetching ? : null} + + +
+
+ ) : null} +
+
+ ) +} diff --git a/apps/dashboard/src/components/billing/ascii.tsx b/apps/dashboard/src/components/billing/ascii.tsx index 413ef14e6..3e0b22fcf 100644 --- a/apps/dashboard/src/components/billing/ascii.tsx +++ b/apps/dashboard/src/components/billing/ascii.tsx @@ -106,6 +106,33 @@ export function SectionTitle({ title, count, right }: { title: string; count?: s ) } +export function CardBrand({ brand, size = 'sm' }: { brand: string; size?: 'sm' | 'lg' }) { + const normalizedBrand = brand.toLowerCase() + const isLarge = size === 'lg' + if (normalizedBrand === 'mastercard') { + const diameter = isLarge ? 16 : 10 + return ( + + + + + ) + } + + return ( + + {normalizedBrand === 'visa' ? 'VISA' : normalizedBrand.toUpperCase() || 'CARD'} + + ) +} + const sparkConfig: ChartConfig = { value: { label: 'value', color: BILLING_BRAND } } function MiniSpark({ id, data }: { id: string; data: { value: number }[] }) { diff --git a/apps/dashboard/src/hooks/mutations/billingPaymentMutations.test.tsx b/apps/dashboard/src/hooks/mutations/billingPaymentMutations.test.tsx new file mode 100644 index 000000000..a0dc4bfee --- /dev/null +++ b/apps/dashboard/src/hooks/mutations/billingPaymentMutations.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingAutoReload, BillingPaymentSetupResult, BillingTopUpResult } from '@/billing-api' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { queryKeys } from '../queries/queryKeys' +import { useCreateBillingTopUpMutation } from './useCreateBillingTopUpMutation' +import { useSetupBillingPaymentMutation } from './useSetupBillingPaymentMutation' +import { useUpdateBillingAutoReloadMutation } from './useUpdateBillingAutoReloadMutation' + +const state = vi.hoisted(() => ({ + setupPayment: vi.fn(), + createTopUp: vi.fn(), + updateAutoReload: vi.fn(), +})) + +vi.mock('@/hooks/useApi', () => ({ + useApi: () => ({ + billingApi: { + setupBillingPayment: state.setupPayment, + createBillingTopUp: state.createTopUp, + updateBillingAutoReload: state.updateAutoReload, + }, + }), +})) + +interface MutationRunners { + setup: (variables: { organizationId: string }) => Promise + topUp: (variables: { organizationId: string; amountCents: string }) => Promise + autoReload: (variables: { organizationId: string; autoReload: BillingAutoReload }) => Promise +} + +describe('billing payment mutations', () => { + let root: Root | null = null + let queryClient: QueryClient + let runners: MutationRunners | null = null + + beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } } }) + state.setupPayment.mockReset() + state.createTopUp.mockReset() + state.updateAutoReload.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => root?.unmount()) + root = null + runners = null + queryClient.clear() + document.body.innerHTML = '' + vi.clearAllMocks() + }) + + function Harness({ children }: { children?: ReactNode }) { + const setup = useSetupBillingPaymentMutation() + const topUp = useCreateBillingTopUpMutation() + const autoReload = useUpdateBillingAutoReloadMutation() + runners = { + setup: (variables) => setup.mutateAsync(variables), + topUp: (variables) => topUp.mutateAsync(variables), + autoReload: (variables) => autoReload.mutateAsync(variables), + } + return children + } + + async function renderHarness() { + const host = document.createElement('div') + document.body.appendChild(host) + await act(async () => { + root = createRoot(host) + root.render( + + + , + ) + }) + if (!runners) throw new Error('Mutation harness did not initialize') + return runners + } + + it('invalidates payment, overview, and receipt roots when setup finishes without a checkout URL', async () => { + state.setupPayment.mockResolvedValue({ status: 'ready', checkoutUrl: null }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const mutations = await renderHarness() + + await act(async () => { + await mutations.setup({ organizationId: 'org-1' }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.payment('org-1') }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.overviewRoot('org-1') }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.receiptsRoot('org-1') }) + }) + + it('invalidates the same roots after an immediate top-up result', async () => { + state.createTopUp.mockResolvedValue({ id: 'top-up-1', status: 'paid', checkoutUrl: null }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const mutations = await renderHarness() + + await act(async () => { + await mutations.topUp({ organizationId: 'org-1', amountCents: '2500' }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.payment('org-1') }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.overviewRoot('org-1') }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.receiptsRoot('org-1') }) + }) + + it('reuses one idempotency key when an ambiguous top-up request is retried', async () => { + state.createTopUp + .mockRejectedValueOnce(new Error('provider response was lost')) + .mockResolvedValueOnce({ id: 'top-up-1', status: 'paid', checkoutUrl: null }) + const mutations = await renderHarness() + const variables = { organizationId: 'org-1', amountCents: '2500' } + + await act(async () => { + await expect(mutations.topUp(variables)).rejects.toThrow('provider response was lost') + }) + await act(async () => { + await mutations.topUp(variables) + }) + + const firstKey = state.createTopUp.mock.calls[0]?.[2] + expect(firstKey).toEqual(expect.any(String)) + expect(state.createTopUp).toHaveBeenNthCalledWith(2, 'org-1', '2500', firstKey) + }) + + it('starts a new idempotency scope after a top-up succeeds', async () => { + state.createTopUp.mockResolvedValue({ id: 'top-up-1', status: 'paid', checkoutUrl: null }) + const mutations = await renderHarness() + const variables = { organizationId: 'org-1', amountCents: '2500' } + + await act(async () => { + await mutations.topUp(variables) + await mutations.topUp(variables) + }) + + const firstKey = state.createTopUp.mock.calls[0]?.[2] + const secondKey = state.createTopUp.mock.calls[1]?.[2] + expect(firstKey).toEqual(expect.any(String)) + expect(secondKey).toEqual(expect.any(String)) + expect(secondKey).not.toBe(firstKey) + }) + + it('does not invalidate before checkout and refreshes payment after auto-reload saves', async () => { + state.setupPayment.mockResolvedValue({ status: 'pending', checkoutUrl: 'https://checkout.test/setup' }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + const mutations = await renderHarness() + + await act(async () => { + await mutations.setup({ organizationId: 'org-1' }) + }) + expect(invalidateQueries).not.toHaveBeenCalled() + + await act(async () => { + await mutations.autoReload({ + organizationId: 'org-1', + autoReload: { enabled: false, thresholdCents: null, targetCents: null }, + }) + }) + expect(invalidateQueries).toHaveBeenCalledOnce() + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.billing.payment('org-1') }) + }) +}) diff --git a/apps/dashboard/src/hooks/mutations/useCreateBillingTopUpMutation.ts b/apps/dashboard/src/hooks/mutations/useCreateBillingTopUpMutation.ts new file mode 100644 index 000000000..4016d021c --- /dev/null +++ b/apps/dashboard/src/hooks/mutations/useCreateBillingTopUpMutation.ts @@ -0,0 +1,52 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingTopUpResult } from '@/billing-api' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { nanoid } from 'nanoid' +import { useRef } from 'react' +import { queryKeys } from '../queries/queryKeys' +import { useApi } from '../useApi' + +interface CreateBillingTopUpVariables { + organizationId: string + amountCents: string +} + +interface PendingTopUpRequest extends CreateBillingTopUpVariables { + idempotencyKey: string +} + +export function useCreateBillingTopUpMutation() { + const { billingApi } = useApi() + const queryClient = useQueryClient() + const pendingRequest = useRef(null) + + return useMutation({ + mutationFn: ({ organizationId, amountCents }) => { + const existing = pendingRequest.current + const request = + existing?.organizationId === organizationId && existing.amountCents === amountCents + ? existing + : { organizationId, amountCents, idempotencyKey: nanoid() } + pendingRequest.current = request + return billingApi.createBillingTopUp(organizationId, amountCents, request.idempotencyKey) + }, + onSuccess: async (result, { organizationId, amountCents }) => { + if ( + pendingRequest.current?.organizationId === organizationId && + pendingRequest.current.amountCents === amountCents + ) { + pendingRequest.current = null + } + if (result.checkoutUrl) return + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.billing.payment(organizationId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.billing.overviewRoot(organizationId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.billing.receiptsRoot(organizationId) }), + ]) + }, + }) +} diff --git a/apps/dashboard/src/hooks/mutations/useSetupBillingPaymentMutation.ts b/apps/dashboard/src/hooks/mutations/useSetupBillingPaymentMutation.ts new file mode 100644 index 000000000..36623a298 --- /dev/null +++ b/apps/dashboard/src/hooks/mutations/useSetupBillingPaymentMutation.ts @@ -0,0 +1,26 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingPaymentSetupResult } from '@/billing-api' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '../queries/queryKeys' +import { useApi } from '../useApi' + +export function useSetupBillingPaymentMutation() { + const { billingApi } = useApi() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ organizationId }) => billingApi.setupBillingPayment(organizationId), + onSuccess: async (result, { organizationId }) => { + if (result.checkoutUrl) return + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.billing.payment(organizationId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.billing.overviewRoot(organizationId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.billing.receiptsRoot(organizationId) }), + ]) + }, + }) +} diff --git a/apps/dashboard/src/hooks/mutations/useUpdateBillingAutoReloadMutation.ts b/apps/dashboard/src/hooks/mutations/useUpdateBillingAutoReloadMutation.ts new file mode 100644 index 000000000..61c6fb868 --- /dev/null +++ b/apps/dashboard/src/hooks/mutations/useUpdateBillingAutoReloadMutation.ts @@ -0,0 +1,26 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingAutoReload } from '@/billing-api' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '../queries/queryKeys' +import { useApi } from '../useApi' + +interface UpdateBillingAutoReloadVariables { + organizationId: string + autoReload: BillingAutoReload +} + +export function useUpdateBillingAutoReloadMutation() { + const { billingApi } = useApi() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ organizationId, autoReload }) => billingApi.updateBillingAutoReload(organizationId, autoReload), + onSuccess: async (_result, { organizationId }) => { + await queryClient.invalidateQueries({ queryKey: queryKeys.billing.payment(organizationId) }) + }, + }) +} diff --git a/apps/dashboard/src/hooks/queries/queryKeys.ts b/apps/dashboard/src/hooks/queries/queryKeys.ts index 9f3d7af63..01119dbf3 100644 --- a/apps/dashboard/src/hooks/queries/queryKeys.ts +++ b/apps/dashboard/src/hooks/queries/queryKeys.ts @@ -43,8 +43,13 @@ export const queryKeys = { }, billing: { all: ['billing'] as const, + overviewRoot: (organizationId: string) => [...queryKeys.billing.all, organizationId, 'overview'] as const, overview: (organizationId: string, from: string, to: string) => - [...queryKeys.billing.all, organizationId, 'overview', { from, to }] as const, + [...queryKeys.billing.overviewRoot(organizationId), { from, to }] as const, + payment: (organizationId: string) => [...queryKeys.billing.all, organizationId, 'payment'] as const, + receiptsRoot: (organizationId: string) => [...queryKeys.billing.all, organizationId, 'receipts'] as const, + receipts: (organizationId: string, page: number, pageSize: number, query: string) => + [...queryKeys.billing.receiptsRoot(organizationId), { page, pageSize, query }] as const, pricing: (organizationId: string) => [...queryKeys.billing.all, organizationId, 'pricing'] as const, boxUsage: (organizationId: string, boxId: string) => [...queryKeys.billing.all, organizationId, 'boxes', boxId] as const, diff --git a/apps/dashboard/src/hooks/queries/useBillingPaymentQuery.ts b/apps/dashboard/src/hooks/queries/useBillingPaymentQuery.ts new file mode 100644 index 000000000..b613f955c --- /dev/null +++ b/apps/dashboard/src/hooks/queries/useBillingPaymentQuery.ts @@ -0,0 +1,20 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingPayment } from '@/billing-api' +import { useQuery } from '@tanstack/react-query' +import { useApi } from '../useApi' +import { queryKeys } from './queryKeys' + +export function useBillingPaymentQuery(organizationId: string, enabled = true) { + const { billingApi } = useApi() + + return useQuery({ + queryKey: queryKeys.billing.payment(organizationId), + queryFn: () => billingApi.getBillingPayment(organizationId), + enabled: Boolean(enabled && organizationId), + refetchOnWindowFocus: true, + }) +} diff --git a/apps/dashboard/src/hooks/queries/useBillingReceiptsQuery.ts b/apps/dashboard/src/hooks/queries/useBillingReceiptsQuery.ts new file mode 100644 index 000000000..ae1d9e070 --- /dev/null +++ b/apps/dashboard/src/hooks/queries/useBillingReceiptsQuery.ts @@ -0,0 +1,32 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { BillingReceiptsPage } from '@/billing-api' +import { useQuery } from '@tanstack/react-query' +import { useApi } from '../useApi' +import { queryKeys } from './queryKeys' + +export function useBillingReceiptsQuery({ + organizationId, + page, + pageSize, + query, + enabled = true, +}: { + organizationId: string + page: number + pageSize: number + query: string + enabled?: boolean +}) { + const { billingApi } = useApi() + + return useQuery({ + queryKey: queryKeys.billing.receipts(organizationId, page, pageSize, query), + queryFn: () => billingApi.getBillingReceipts(organizationId, { page, pageSize, query }), + enabled: Boolean(enabled && organizationId), + refetchOnWindowFocus: true, + }) +} diff --git a/apps/dashboard/src/lib/billing-checkout.ts b/apps/dashboard/src/lib/billing-checkout.ts new file mode 100644 index 000000000..3cba61f53 --- /dev/null +++ b/apps/dashboard/src/lib/billing-checkout.ts @@ -0,0 +1,8 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +export function redirectToBillingCheckout(checkoutUrl: string) { + globalThis.location.assign(checkoutUrl) +} diff --git a/apps/dashboard/src/pages/Billing.test.tsx b/apps/dashboard/src/pages/Billing.test.tsx index 93e006bf5..d3d39521d 100644 --- a/apps/dashboard/src/pages/Billing.test.tsx +++ b/apps/dashboard/src/pages/Billing.test.tsx @@ -64,6 +64,21 @@ vi.mock('@/components/PageLayout', () => ({ PageTitle: ({ children }: { children: ReactNode }) =>

{children}

, })) +vi.mock('@/components/billing/BillingPaymentPanel', () => ({ + BillingPaymentPanel: () =>
Auto-reload One-time top-up Receipts
, +})) + +vi.mock('@/components/billing/BillingPaymentMethodSection', () => ({ + BillingPaymentMethodSection: ({ onAddFunds }: { onAddFunds?: () => void }) => ( +
+ Payment method Set up payment method + +
+ ), +})) + vi.mock('@/hooks/useSelectedOrganization', () => ({ useSelectedOrganization: () => ({ selectedOrganization: { @@ -133,7 +148,7 @@ describe('Billing page', () => { await flushReactWork() } - it('renders the terminal usage layout around real wallet and usage data', async () => { + it('restores the PM usage layout around real wallet and usage data', async () => { await renderBilling() expect(document.querySelector('[data-testid="billing-balance-overview"]')).toBeTruthy() @@ -147,6 +162,36 @@ describe('Billing page', () => { expect(document.body.textContent).not.toContain('Billing is on the way') }) + it('keeps payment setup with the Usage balance and limits Billing to payment operations', async () => { + await renderBilling() + + const usageTab = Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Usage') + const billingTab = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Billing', + ) + expect(usageTab?.getAttribute('data-state')).toBe('active') + expect(billingTab).toBeTruthy() + expect(document.body.textContent).toContain('Payment method') + expect(document.body.textContent).toContain('Set up payment method') + if (!billingTab) throw new Error('Billing tab is missing') + const addFunds = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Add funds', + ) + if (!addFunds) throw new Error('Add funds button is missing') + + await act(async () => { + addFunds.click() + }) + + expect(billingTab.getAttribute('data-state')).toBe('active') + expect(document.body.textContent).toContain('Auto-reload') + expect(document.body.textContent).toContain('One-time top-up') + expect(document.body.textContent).toContain('Receipts') + expect(document.body.textContent).not.toContain('Payment method') + expect(document.body.textContent).not.toContain('Set up payment method') + expect(document.body.textContent).not.toContain('Rated usage') + }) + it('does not present unavailable billing data as a zero balance', async () => { state.mode = 'error' await renderBilling() diff --git a/apps/dashboard/src/pages/Billing.tsx b/apps/dashboard/src/pages/Billing.tsx index 98cf70359..7eef913f7 100644 --- a/apps/dashboard/src/pages/Billing.tsx +++ b/apps/dashboard/src/pages/Billing.tsx @@ -5,10 +5,13 @@ import { PageContent, PageHeader, PageLayout, PageTitle } from '@/components/PageLayout' import { BalanceOverviewCard } from '@/components/billing/BalanceOverviewCard' +import { BillingPaymentMethodSection } from '@/components/billing/BillingPaymentMethodSection' +import { BillingPaymentPanel } from '@/components/billing/BillingPaymentPanel' import { QuotaPanel } from '@/components/billing/QuotaPanel' import { UsageTrendCharts } from '@/components/billing/UsageTrendCharts' import { SectionTitle } from '@/components/billing/ascii' import { Calendar, RefreshCw } from '@/components/ui/icon' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useBillingOverviewQuery } from '@/hooks/queries/useBillingOverviewQuery' import { useSelectedOrganization } from '@/hooks/useSelectedOrganization' import { cn } from '@/lib/utils' @@ -24,6 +27,7 @@ const RANGE_OPTIONS = [ ] as const type RangeId = (typeof RANGE_OPTIONS)[number]['id'] +type BillingTab = 'usage' | 'billing' function StatePanel({ children, tone = 'default' }: { children: ReactNode; tone?: 'default' | 'error' }) { return ( @@ -40,6 +44,7 @@ function StatePanel({ children, tone = 'default' }: { children: ReactNode; tone? function Billing() { const { selectedOrganization, authenticatedUserOrganizationMember } = useSelectedOrganization() + const [activeTab, setActiveTab] = useState('usage') const [rangeId, setRangeId] = useState('30d') const [rangeEnd, setRangeEnd] = useState(() => new Date()) const isOwner = authenticatedUserOrganizationMember?.role === OrganizationUserRoleEnum.OWNER @@ -77,72 +82,103 @@ function Billing() { Select an organization to view billing. ) : !isOwner ? ( Only organization owners can view billing. - ) : overviewQuery.isLoading ? ( - Loading billing data... - ) : overviewQuery.isError || !overviewQuery.data ? ( - -
- Billing data is unavailable. - -
-
) : ( -
- - - + setActiveTab(value as BillingTab)} className="gap-6"> + + + Usage + + + Billing + + -
- - + + {overviewQuery.isLoading ? ( + Loading billing data... + ) : overviewQuery.isError || !overviewQuery.data ? ( + +
+ Billing data is unavailable.
- } - /> - -
-
+ + ) : ( +
+ setActiveTab('billing')} + /> + } + /> + + + +
+ + + +
+ } + /> + + + + )} + + + + + + )} From fa2ecfd6fb2ac6bdd1e45d66269f5aae515cc27e Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:37:42 +0800 Subject: [PATCH 3/6] test(billing): add layered verification suites --- apps/package.json | 15 ++ apps/scripts/billing-local-e2e.mjs | 235 ++++++++++++++++++++++++++++ apps/scripts/billing-test-suite.mjs | 161 +++++++++++++++++++ apps/yarn.lock | 10 ++ 4 files changed, 421 insertions(+) create mode 100644 apps/scripts/billing-local-e2e.mjs create mode 100644 apps/scripts/billing-test-suite.mjs diff --git a/apps/package.json b/apps/package.json index 30e46c793..8fe534c1b 100644 --- a/apps/package.json +++ b/apps/package.json @@ -16,7 +16,21 @@ "start:storybook": "nx run dashboard:storybook", "dev:dex": "node scripts/dev-dex.mjs", "e2e:local": "node scripts/e2e-local.mjs", + "e2e:billing:local": "node scripts/billing-local-e2e.mjs", + "e2e:billing:all": "node scripts/billing-test-suite.mjs all && node scripts/billing-local-e2e.mjs", "e2e:dev": "node scripts/e2e-dev-smoke.mjs", + "test:billing": "node scripts/billing-test-suite.mjs quick", + "test:billing:list": "node scripts/billing-test-suite.mjs --list", + "test:billing:prereq": "node scripts/billing-test-suite.mjs prereq", + "test:billing:usage": "node scripts/billing-test-suite.mjs usage", + "test:billing:rating": "node scripts/billing-test-suite.mjs rating", + "test:billing:wallet": "node scripts/billing-test-suite.mjs wallet", + "test:billing:payment": "node scripts/billing-test-suite.mjs payment", + "test:billing:read": "node scripts/billing-test-suite.mjs read", + "test:billing:ui": "node scripts/billing-test-suite.mjs ui", + "test:billing:db": "node scripts/billing-test-suite.mjs db", + "test:billing:all": "node scripts/billing-test-suite.mjs all", + "test:billing:verify": "node scripts/billing-test-suite.mjs verify", "test:billing-edge": "BILLING_EDGE_DB_TESTS=1 jest --config api/jest.config.ts --runInBand api/src/billing/billing-edge-cases.integration.spec.ts", "observability:agent-smoke": "node scripts/admin-observability-agent-smoke.mjs", "format": "nx run-many --target=format --all --parallel=$(getconf _NPROCESSORS_ONLN) && prettier --write \"*.{ts,js,json,yaml}\"", @@ -304,6 +318,7 @@ "markdownlint-cli2": "^0.17.2", "nx": "21.4.1", "pino-pretty": "^13.1.2", + "playwright-core": "^1.61.1", "postcss": "8.5.10", "prettier": "^3.5.0", "prettier-plugin-astro": "^0.14.1", diff --git a/apps/scripts/billing-local-e2e.mjs b/apps/scripts/billing-local-e2e.mjs new file mode 100644 index 000000000..67dc7b121 --- /dev/null +++ b/apps/scripts/billing-local-e2e.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright-core' + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptsRoot, '..', '..') +const dashboardUrl = stripTrailingSlash(process.env.BOXLITE_E2E_BASE_URL || 'http://localhost:3000') +const loginEmail = process.env.BOXLITE_E2E_LOGIN_EMAIL || 'admin@boxlite.dev' +const loginPassword = process.env.BOXLITE_E2E_LOGIN_PASSWORD || 'password' +const timeoutMs = Number(process.env.BILLING_E2E_TIMEOUT_MS || 20_000) +const chromeExecutablePath = + process.env.CHROME_EXECUTABLE_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const runId = new Date().toISOString().replaceAll(/[:.]/g, '-') +const artifactsDir = + process.env.BOXLITE_BILLING_E2E_ARTIFACTS || path.join(repoRoot, '.apps-local', 'logs', 'billing-e2e', runId) + +await fs.mkdir(artifactsDir, { recursive: true }) + +const browser = await chromium.launch({ + headless: process.env.HEADLESS !== 'false', + executablePath: chromeExecutablePath, +}) +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) +const page = await context.newPage() +const browserErrors = [] +const expectedHttpErrors = [] +const externalHttpWarnings = [] +let tracingStopped = false + +page.setDefaultTimeout(timeoutMs) +page.on('console', (message) => { + if (message.type() === 'error' && !isBrowserNetworkConsoleMessage(message.text())) { + browserErrors.push(`console: ${message.text()}`) + } +}) +page.on('pageerror', (error) => browserErrors.push(`page: ${error.message}`)) +page.on('response', (response) => { + if (response.status() < 400) return + + const httpError = describeHttpError(response) + if (process.env.BILLING_E2E_DEBUG_HTTP === '1') console.error(`[billing-e2e] ${httpError}`) + + if (isExpectedAdminAccessProbe(response)) { + expectedHttpErrors.push(httpError) + } else if (isLocalUrl(response.url())) { + browserErrors.push(`http: ${httpError}`) + } else { + externalHttpWarnings.push(httpError) + } +}) +page.on('requestfailed', (request) => { + const requestError = `REQUEST FAILED ${request.method()} ${request.resourceType()} ${request.url()}: ${request.failure()?.errorText ?? 'unknown error'}` + if (isLocalUrl(request.url())) { + browserErrors.push(`network: ${requestError}`) + } else { + externalHttpWarnings.push(requestError) + } +}) + +await context.tracing.start({ screenshots: true, snapshots: true, sources: true }) + +try { + await signIn() + await verifyUsageTab() + await verifyBillingTab() + assertNoBrowserErrors() + await context.tracing.stop() + tracingStopped = true + console.log( + JSON.stringify( + { + ok: true, + dashboardUrl, + artifactsDir, + screenshots: ['usage.png', 'billing.png'], + expectedHttpErrors, + externalHttpWarnings, + }, + null, + 2, + ), + ) +} catch (error) { + await page.screenshot({ path: path.join(artifactsDir, 'failure.png'), fullPage: true }).catch(() => {}) + if (!tracingStopped) { + await context.tracing.stop({ path: path.join(artifactsDir, 'trace.zip') }).catch(() => {}) + tracingStopped = true + } + throw error +} finally { + if (!tracingStopped) await context.tracing.stop().catch(() => {}) + await browser.close() +} + +async function signIn() { + await page.goto(`${dashboardUrl}/dashboard/billing`, { waitUntil: 'domcontentloaded' }) + await settleAuthState() + + for (let attempt = 0; attempt < 3; attempt += 1) { + if (await isVisible(page.locator('#login'))) { + await page.locator('#login').fill(loginEmail) + await page.locator('#password').fill(loginPassword) + await page.locator('#submit-login').click() + await settleAuthState({ allowDashboardOrigin: true }) + continue + } + + const grantButton = page.getByRole('button', { name: 'Grant Access' }) + if (await isVisible(grantButton)) { + await grantButton.click() + await settleAuthState({ allowDashboardOrigin: true }) + continue + } + + break + } + + if (await isVisible(page.locator('#login'))) throw new Error('Dex login is still visible') + if (await isVisible(page.getByRole('button', { name: 'Grant Access' }))) { + throw new Error('Dex approval is still visible') + } +} + +async function verifyUsageTab() { + await page.goto(`${dashboardUrl}/dashboard/billing`, { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: 'Billing', exact: true }).waitFor() + await page.getByRole('tab', { name: 'Usage', exact: true }).click() + await assertBodyText([ + 'Current balance', + 'Spent this month', + 'Payment method', + 'Limits', + 'Per-box maximums', + 'Usage over time', + 'Usage Cost', + 'vCPU Hours', + 'RAM Hours', + 'Disk Hours', + ]) + await page.screenshot({ path: path.join(artifactsDir, 'usage.png'), fullPage: true }) +} + +async function verifyBillingTab() { + await page.getByRole('tab', { name: 'Billing', exact: true }).click() + await assertBodyText(['Top-up', 'Auto-reload', 'One-time top-up', 'Receipts']) + await page.getByPlaceholder('search receipts...').waitFor() + await page.screenshot({ path: path.join(artifactsDir, 'billing.png'), fullPage: true }) +} + +async function settleAuthState({ allowDashboardOrigin = false } = {}) { + const dashboardOrigin = new URL(dashboardUrl).origin + let dashboardSeenAt = 0 + let lastBodyText = '' + + for (let attempt = 0; attempt < 100; attempt += 1) { + const currentUrl = page.url() + const bodyText = await page + .locator('body') + .innerText() + .catch(() => '') + lastBodyText = bodyText + if (/Log in to Your Account|Grant Access|Billing|Boxes/.test(bodyText)) return + if (allowDashboardOrigin && currentUrl.startsWith(dashboardOrigin)) { + dashboardSeenAt ||= Date.now() + if (Date.now() - dashboardSeenAt > 3_000) return + } else { + dashboardSeenAt = 0 + } + await delay(250) + } + + throw new Error(`Timed out waiting for dashboard or Dex state at ${page.url()}; body=${lastBodyText.slice(0, 240)}`) +} + +async function assertBodyText(expectedTexts) { + let missing = expectedTexts + let lastBodyText = '' + for (let attempt = 0; attempt < 80; attempt += 1) { + lastBodyText = await page.locator('body').innerText() + const normalizedBodyText = lastBodyText.toLowerCase() + missing = expectedTexts.filter((text) => !normalizedBodyText.includes(text.toLowerCase())) + if (missing.length === 0) return + await delay(250) + } + throw new Error(`Missing expected text: ${missing.join(', ')}; body=${lastBodyText.slice(0, 240)}`) +} + +function assertNoBrowserErrors() { + if (browserErrors.length > 0) { + throw new Error(`Billing browser errors:\n${browserErrors.join('\n')}`) + } +} + +function describeHttpError(response) { + return `HTTP ${response.status()} ${response.request().method()} ${response.request().resourceType()} ${response.url()}` +} + +function isExpectedAdminAccessProbe(response) { + const request = response.request() + return ( + response.status() === 403 && + request.method() === 'GET' && + request.resourceType() === 'xhr' && + new URL(response.url()).pathname === '/api/admin/overview' + ) +} + +function isBrowserNetworkConsoleMessage(message) { + return message.startsWith('Failed to load resource:') +} + +function isLocalUrl(value) { + const hostname = new URL(value).hostname + return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]' +} + +async function isVisible(locator) { + try { + return await locator.isVisible() + } catch { + return false + } +} + +function stripTrailingSlash(value) { + return value.replace(/\/+$/, '') +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} diff --git a/apps/scripts/billing-test-suite.mjs b/apps/scripts/billing-test-suite.mjs new file mode 100644 index 000000000..e81dfc689 --- /dev/null +++ b/apps/scripts/billing-test-suite.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)) +const appsRoot = path.resolve(scriptsRoot, '..') +const yarn = process.platform === 'win32' ? 'yarn.cmd' : 'yarn' + +const apiSpecs = [ + 'api/src/usage/usage.service.spec.ts', + 'api/src/billing/billing-read.service.spec.ts', + 'api/src/billing/billing.controller.spec.ts', + 'api/src/billing/payment/payment-provider.spec.ts', + 'api/src/billing/payment/payment.controller.spec.ts', + 'api/src/billing/payment/payment.service.spec.ts', + 'api/src/billing/rating/rate-math.spec.ts', + 'api/src/billing/rating/rating.service.spec.ts', + 'api/src/billing/settlement.service.spec.ts', + 'api/src/billing/wallet.service.spec.ts', +] + +const prerequisiteSpecs = ['api/src/organization/services/organization.service.regions.spec.ts'] + +const dashboardSpecs = [ + 'dashboard/src/billing-api/billingApiClient.test.ts', + 'dashboard/src/components/Box/CreateBoxDialog.test.tsx', + 'dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx', + 'dashboard/src/components/billing/BillingPaymentPanel.test.tsx', + 'dashboard/src/components/billing/cost.spec.ts', + 'dashboard/src/components/boxes/BoxDetails.test.tsx', + 'dashboard/src/hooks/mutations/billingPaymentMutations.test.tsx', + 'dashboard/src/pages/Billing.test.tsx', +] + +const jest = (label, specs) => ({ + label, + args: ['jest', '--config', 'api/jest.config.ts', '--runInBand', ...specs], +}) + +const stages = { + apiUnit: jest('API unit and local prerequisites', [...apiSpecs, ...prerequisiteSpecs]), + prereq: jest('Local Billing prerequisites', prerequisiteSpecs), + usage: jest('Usage', ['api/src/usage/usage.service.spec.ts']), + rating: jest('Rating', ['api/src/billing/rating/rate-math.spec.ts', 'api/src/billing/rating/rating.service.spec.ts']), + wallet: jest('Wallet and settlement', [ + 'api/src/billing/wallet.service.spec.ts', + 'api/src/billing/settlement.service.spec.ts', + ]), + payment: jest('Payment', [ + 'api/src/billing/payment/payment-provider.spec.ts', + 'api/src/billing/payment/payment.controller.spec.ts', + 'api/src/billing/payment/payment.service.spec.ts', + ]), + read: jest('Billing read API', [ + 'api/src/billing/billing-read.service.spec.ts', + 'api/src/billing/billing.controller.spec.ts', + ]), + ui: { + label: 'Dashboard UI', + args: ['vitest', 'run', '--config', 'dashboard/vite.config.mts', ...dashboardSpecs], + }, + db: { + label: 'PostgreSQL edge cases', + args: ['test:billing-edge'], + }, + lint: { + label: 'Billing scoped ESLint', + args: [ + 'eslint', + 'api/src/usage', + 'api/src/billing', + 'dashboard/src/pages/Billing.tsx', + 'dashboard/src/components/billing', + 'dashboard/src/components/Box/CreateBoxDialog.tsx', + 'dashboard/src/components/boxes/BoxDetails.tsx', + 'dashboard/src/billing-api', + 'dashboard/src/hooks/mutations', + 'scripts/billing-local-e2e.mjs', + 'scripts/billing-test-suite.mjs', + ], + }, + apiBuild: { + label: 'API build', + args: ['nx', 'run', 'api:build', '--configuration=development', '--skip-nx-cache'], + }, + dashboardBuild: { + label: 'Dashboard build', + args: ['nx', 'run', 'dashboard:build', '--configuration=development', '--skip-nx-cache'], + }, +} + +const suites = { + prereq: { description: 'Organization and Region prerequisites for local Billing', stages: [stages.prereq] }, + usage: { description: 'PR1 usage lifecycle tests', stages: [stages.usage] }, + rating: { description: 'PR2 rating and price snapshots', stages: [stages.rating] }, + wallet: { description: 'PR3 wallet and settlement', stages: [stages.wallet] }, + payment: { description: 'PR5 providers, webhooks, and top-ups', stages: [stages.payment] }, + read: { description: 'PR4 billing read API', stages: [stages.read] }, + ui: { description: 'PR4/PR5 dashboard behavior', stages: [stages.ui] }, + db: { description: 'Real PostgreSQL concurrency and recovery', stages: [stages.db] }, + quick: { + description: 'Fast deterministic API and UI regression suite', + stages: [stages.apiUnit, stages.ui], + }, + all: { + description: 'Complete deterministic Billing collection', + stages: [stages.apiUnit, stages.db, stages.ui], + }, + verify: { + description: 'Complete collection plus lint and builds', + stages: [stages.apiUnit, stages.db, stages.ui, stages.lint, stages.apiBuild, stages.dashboardBuild], + }, +} + +const requestedSuite = process.argv[2] ?? 'quick' +if (requestedSuite === '--list' || requestedSuite === 'list') { + for (const [name, suite] of Object.entries(suites)) { + console.log(`${name.padEnd(8)} ${suite.description}`) + } + process.exit(0) +} + +const suite = suites[requestedSuite] +if (!suite) { + console.error(`Unknown Billing test suite: ${requestedSuite}`) + console.error(`Available suites: ${Object.keys(suites).join(', ')}`) + process.exit(2) +} + +const suiteStartedAt = Date.now() +console.log(`[billing-test] ${requestedSuite}: ${suite.description}`) + +for (const [index, stage] of suite.stages.entries()) { + const startedAt = Date.now() + console.log(`\n[billing-test] ${index + 1}/${suite.stages.length} ${stage.label}`) + await run(stage.args) + console.log(`[billing-test] PASS ${stage.label} (${formatDuration(Date.now() - startedAt)})`) +} + +console.log(`\n[billing-test] PASS ${requestedSuite} (${formatDuration(Date.now() - suiteStartedAt)})`) + +function run(args) { + return new Promise((resolve, reject) => { + const child = spawn(yarn, args, { cwd: appsRoot, env: process.env, stdio: 'inherit' }) + child.on('error', reject) + child.on('exit', (code, signal) => { + if (code === 0) { + resolve() + return + } + reject(new Error(`${yarn} ${args.join(' ')} failed with ${signal ?? `exit ${code}`}`)) + }) + }) +} + +function formatDuration(milliseconds) { + return `${(milliseconds / 1000).toFixed(1)}s` +} diff --git a/apps/yarn.lock b/apps/yarn.lock index fc9e6ccef..28538eaf5 100644 --- a/apps/yarn.lock +++ b/apps/yarn.lock @@ -16902,6 +16902,7 @@ __metadata: pino: "npm:^9.13.1" pino-http: "npm:^10.5.0" pino-pretty: "npm:^13.1.2" + playwright-core: "npm:^1.61.1" postcss: "npm:8.5.10" posthog-js: "npm:^1.217.6" posthog-node: "npm:^4.10.1" @@ -30961,6 +30962,15 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:^1.61.1": + version: 1.61.1 + resolution: "playwright-core@npm:1.61.1" + bin: + playwright-core: cli.js + checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd + languageName: node + linkType: hard + "pluralize@npm:8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0" From 636d703d3d7252eda5bf169cf2ab399757fd9fcb Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:03:01 +0800 Subject: [PATCH 4/6] fix(billing): preserve webhook parser signature --- apps/api/src/billing/billing-edge-cases.integration.spec.ts | 3 ++- apps/api/src/billing/payment/fake-payment.provider.ts | 4 +++- apps/api/src/billing/payment/payment.service.spec.ts | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/billing/billing-edge-cases.integration.spec.ts b/apps/api/src/billing/billing-edge-cases.integration.spec.ts index 4d84c8065..69550af61 100644 --- a/apps/api/src/billing/billing-edge-cases.integration.spec.ts +++ b/apps/api/src/billing/billing-edge-cases.integration.spec.ts @@ -50,7 +50,8 @@ class AmbiguousThenSuccessfulProvider extends FakePaymentProvider { } class TestWebhookPaymentProvider extends FakePaymentProvider { - override async parseWebhook(payload: Buffer): Promise { + override async parseWebhook(payload: Buffer, signature: string): Promise { + void signature return JSON.parse(payload.toString('utf8')) as ProviderWebhookEvent } } diff --git a/apps/api/src/billing/payment/fake-payment.provider.ts b/apps/api/src/billing/payment/fake-payment.provider.ts index 3aa6fb751..384dbf283 100644 --- a/apps/api/src/billing/payment/fake-payment.provider.ts +++ b/apps/api/src/billing/payment/fake-payment.provider.ts @@ -34,7 +34,9 @@ export class FakePaymentProvider implements PaymentProvider { return this.paidResult(input) } - async parseWebhook(): Promise { + async parseWebhook(payload: Buffer, signature: string): Promise { + void payload + void signature throw new BadRequestException('fake payment provider does not accept webhooks') } diff --git a/apps/api/src/billing/payment/payment.service.spec.ts b/apps/api/src/billing/payment/payment.service.spec.ts index fd515d316..02fef037f 100644 --- a/apps/api/src/billing/payment/payment.service.spec.ts +++ b/apps/api/src/billing/payment/payment.service.spec.ts @@ -70,7 +70,8 @@ class PendingPaymentProvider extends FakePaymentProvider { } class TestWebhookPaymentProvider extends PendingPaymentProvider { - override async parseWebhook(payload: Buffer): Promise { + override async parseWebhook(payload: Buffer, signature: string): Promise { + void signature return JSON.parse(payload.toString('utf8')) as ProviderWebhookEvent } } From 191f75b8ddab96384a1d4a2c0bbe8f15bcc2c4f2 Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:04:16 +0800 Subject: [PATCH 5/6] fix(billing): ignore canceled browser requests --- apps/scripts/billing-local-e2e.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/scripts/billing-local-e2e.mjs b/apps/scripts/billing-local-e2e.mjs index 67dc7b121..586b817eb 100644 --- a/apps/scripts/billing-local-e2e.mjs +++ b/apps/scripts/billing-local-e2e.mjs @@ -53,7 +53,10 @@ page.on('response', (response) => { } }) page.on('requestfailed', (request) => { - const requestError = `REQUEST FAILED ${request.method()} ${request.resourceType()} ${request.url()}: ${request.failure()?.errorText ?? 'unknown error'}` + const failureText = request.failure()?.errorText ?? 'unknown error' + if (failureText === 'net::ERR_ABORTED') return + + const requestError = `REQUEST FAILED ${request.method()} ${request.resourceType()} ${request.url()}: ${failureText}` if (isLocalUrl(request.url())) { browserErrors.push(`network: ${requestError}`) } else { From 5c4e43bda097ed7344bb1c76d898451a89e04be2 Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:25 +0800 Subject: [PATCH 6/6] fix(billing): configure dev payment defaults --- apps/infra/sst.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/infra/sst.config.ts b/apps/infra/sst.config.ts index 179a21a9b..faa71aa5f 100644 --- a/apps/infra/sst.config.ts +++ b/apps/infra/sst.config.ts @@ -456,6 +456,11 @@ export default $config({ VERSION: '0.1.0', DEFAULT_REGION_ENFORCE_QUOTAS: 'false', DEFAULT_TEMPLATE: envOr('DEFAULT_TEMPLATE', 'boxlite/base'), + BILLING_TRIAL_GRANT_CENTS: envOr('BILLING_TRIAL_GRANT_CENTS', '10000'), + BILLING_TRIAL_DURATION_DAYS: envOr('BILLING_TRIAL_DURATION_DAYS', '30'), + BILLING_PAYMENT_PROVIDER: isProd + ? requireEnv('BILLING_PAYMENT_PROVIDER', 'for production billing') + : envOr('BILLING_PAYMENT_PROVIDER', 'fake'), // Box base images: only the three digest-pinned *_IMAGE refs below are live — the // API gates box creation to that curated set (apps/api curated-images.constant.ts) // and the runner pulls them straight from ghcr.io with its GHCR_TOKEN. IMAGE_TAG and