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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
484 changes: 484 additions & 0 deletions apps/api/src/billing/billing-edge-cases.integration.spec.ts

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions apps/api/src/billing/billing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {}
22 changes: 22 additions & 0 deletions apps/api/src/billing/entities/payment-provider-event.entity.ts
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions apps/api/src/billing/entities/top-up-record.entity.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/billing/entities/wallet.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/billing/payment/fake-payment.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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<PaymentSetupResult> {
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<TopUpPaymentResult> {
return this.paidResult(input)
}

async chargeSavedMethod(input: TopUpPaymentInput): Promise<TopUpPaymentResult> {
return this.paidResult(input)
}

async parseWebhook(payload: Buffer, signature: string): Promise<ProviderWebhookEvent | null> {
void payload
void signature
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,
}
}
}
22 changes: 22 additions & 0 deletions apps/api/src/billing/payment/payment-provider.factory.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading