From e92bcd1564a42baf061443b1a8a079523163db7c Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:13:17 +0800 Subject: [PATCH] feat(billing): rate archived usage periods --- apps/api/src/app.module.ts | 2 + apps/api/src/billing/billing.module.ts | 18 ++ .../billing/entities/pricing-plan.entity.ts | 43 ++++ .../billing/entities/rated-period.entity.ts | 42 ++++ apps/api/src/billing/rating/rate-math.spec.ts | 167 ++++++++++++++ apps/api/src/billing/rating/rate-math.ts | 214 ++++++++++++++++++ .../src/billing/rating/rating.service.spec.ts | 192 ++++++++++++++++ apps/api/src/billing/rating/rating.service.ts | 100 ++++++++ .../1782700100000-migration.spec.ts | 43 ++++ .../pre-deploy/1782700100000-migration.ts | 76 +++++++ apps/package.json | 1 + apps/yarn.lock | 1 + 12 files changed, 899 insertions(+) create mode 100644 apps/api/src/billing/billing.module.ts create mode 100644 apps/api/src/billing/entities/pricing-plan.entity.ts create mode 100644 apps/api/src/billing/entities/rated-period.entity.ts create mode 100644 apps/api/src/billing/rating/rate-math.spec.ts create mode 100644 apps/api/src/billing/rating/rate-math.ts create mode 100644 apps/api/src/billing/rating/rating.service.spec.ts create mode 100644 apps/api/src/billing/rating/rating.service.ts create mode 100644 apps/api/src/migrations/pre-deploy/1782700100000-migration.spec.ts create mode 100644 apps/api/src/migrations/pre-deploy/1782700100000-migration.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f9c15c124..cd3fdb28e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,6 +45,7 @@ import { ClickHouseModule } from './clickhouse/clickhouse.module' import { BoxTelemetryModule } from './box-telemetry/box-telemetry.module' import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module' import { UsageModule } from './usage/usage.module' +import { BillingModule } from './billing/billing.module' @Module({ imports: [ @@ -201,6 +202,7 @@ import { UsageModule } from './usage/usage.module' BoxTelemetryModule, BoxliteRestModule, UsageModule, + BillingModule, OpenFeatureModule.forRoot({ contextFactory: (request: ExecutionContext) => { const req = request.switchToHttp().getRequest() diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts new file mode 100644 index 000000000..e2fd6afa9 --- /dev/null +++ b/apps/api/src/billing/billing.module.ts @@ -0,0 +1,18 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { BoxUsagePeriodArchive } from '../usage/entities/box-usage-period-archive.entity' +import { PricingPlan } from './entities/pricing-plan.entity' +import { RatedPeriod } from './entities/rated-period.entity' +import { RatingService } from './rating/rating.service' + +@Module({ + imports: [TypeOrmModule.forFeature([BoxUsagePeriodArchive, PricingPlan, RatedPeriod])], + providers: [RatingService], + exports: [RatingService], +}) +export class BillingModule {} diff --git a/apps/api/src/billing/entities/pricing-plan.entity.ts b/apps/api/src/billing/entities/pricing-plan.entity.ts new file mode 100644 index 000000000..46285f4a8 --- /dev/null +++ b/apps/api/src/billing/entities/pricing-plan.entity.ts @@ -0,0 +1,43 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' + +@Entity('pricing_plan') +@Index('pricing_plan_version_idx', ['version'], { unique: true }) +@Index('pricing_plan_effective_idx', ['effectiveFrom']) +@Check('pricing_plan_effective_interval', '"effectiveTo" IS NULL OR "effectiveTo" > "effectiveFrom"') +@Check( + 'pricing_plan_non_negative_rates', + '"cpuRateCentsPerSec" >= 0 AND "memRateCentsPerSec" >= 0 AND "diskRateCentsPerSec" >= 0 AND "gpuRateCentsPerSec" >= 0', +) +export class PricingPlan { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column({ type: 'int' }) + version: number + + @Column({ type: 'numeric', precision: 38, scale: 18 }) + cpuRateCentsPerSec: string + + @Column({ type: 'numeric', precision: 38, scale: 18 }) + memRateCentsPerSec: string + + @Column({ type: 'numeric', precision: 38, scale: 18 }) + diskRateCentsPerSec: string + + @Column({ type: 'numeric', precision: 38, scale: 18 }) + gpuRateCentsPerSec: string + + @Column({ type: 'timestamp with time zone' }) + effectiveFrom: Date + + @Column({ type: 'timestamp with time zone', nullable: true }) + effectiveTo: Date | null + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + createdAt: Date +} diff --git a/apps/api/src/billing/entities/rated-period.entity.ts b/apps/api/src/billing/entities/rated-period.entity.ts new file mode 100644 index 000000000..4087f16e4 --- /dev/null +++ b/apps/api/src/billing/entities/rated-period.entity.ts @@ -0,0 +1,42 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import type { PricingSegment, UsageTotals } from '../rating/rate-math' + +@Entity('rated_period') +@Index('rated_period_usage_archive_idx', ['usagePeriodArchiveId'], { unique: true }) +@Index('rated_period_org_rated_at_idx', ['organizationId', 'ratedAt']) +export class RatedPeriod { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column({ type: 'uuid' }) + usagePeriodArchiveId: string + + @Column({ type: 'uuid' }) + organizationId: string + + @Column() + boxId: string + + @Column({ type: 'jsonb' }) + pricingSegments: PricingSegment[] + + @Column({ type: 'jsonb' }) + usageTotals: UsageTotals + + @Column({ type: 'numeric', precision: 38, scale: 3 }) + billedSeconds: string + + @Column({ type: 'numeric', precision: 38, scale: 18 }) + preciseCents: string + + @Column({ type: 'bigint' }) + ratedCents: string + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + ratedAt: Date +} diff --git a/apps/api/src/billing/rating/rate-math.spec.ts b/apps/api/src/billing/rating/rate-math.spec.ts new file mode 100644 index 000000000..be85123ed --- /dev/null +++ b/apps/api/src/billing/rating/rate-math.spec.ts @@ -0,0 +1,167 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { + computeRatedCents, + periodBillableTotals, + ratePeriodByPlans, + type PricingPlanSnapshot, + type RateSnapshot, + type RateableUsagePeriod, +} from './rate-math' + +const period = (overrides: Partial = {}): RateableUsagePeriod => ({ + startAt: new Date('2026-07-08T00:00:00Z'), + endAt: new Date('2026-07-08T00:01:00Z'), + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + ...overrides, +}) + +const rates = (overrides: Partial = {}): RateSnapshot => ({ + cpuRateCentsPerSec: '2', + memRateCentsPerSec: '1', + diskRateCentsPerSec: '0.5', + gpuRateCentsPerSec: '10', + ...overrides, +}) + +const plan = (overrides: Partial = {}): PricingPlanSnapshot => ({ + version: 1, + effectiveFrom: new Date('2026-01-01T00:00:00Z'), + effectiveTo: null, + ...rates(), + ...overrides, +}) + +describe('periodBillableTotals', () => { + it('uses every stored resource dimension for the exact elapsed seconds', () => { + expect(periodBillableTotals(period())).toEqual({ + billedSeconds: '60', + usageTotals: { + cpuSeconds: '120', + memGibSeconds: '240', + diskGibSeconds: '600', + gpuSeconds: '60', + }, + }) + }) + + it('charges only disk when the archived row stores zero running resources', () => { + expect(periodBillableTotals(period({ cpu: 0, mem: 0, gpu: 0 }))).toEqual({ + billedSeconds: '60', + usageTotals: { + cpuSeconds: '0', + memGibSeconds: '0', + diskGibSeconds: '600', + gpuSeconds: '0', + }, + }) + }) +}) + +describe('computeRatedCents', () => { + it('keeps sub-cent precision and rounds the final cents half-up', () => { + expect( + computeRatedCents( + { + cpuSeconds: '1', + memGibSeconds: '1', + diskGibSeconds: '1', + gpuSeconds: '0', + }, + rates({ + cpuRateCentsPerSec: '0.1', + memRateCentsPerSec: '0.2', + diskRateCentsPerSec: '0.2', + gpuRateCentsPerSec: '0', + }), + ), + ).toEqual({ preciseCents: '0.5', ratedCents: '1' }) + }) + + it('rejects negative rates instead of turning usage into wallet credit', () => { + expect(() => + computeRatedCents( + { + cpuSeconds: '1', + memGibSeconds: '0', + diskGibSeconds: '0', + gpuSeconds: '0', + }, + rates({ cpuRateCentsPerSec: '-1' }), + ), + ).toThrow('cpu rate must be non-negative') + }) +}) + +describe('ratePeriodByPlans', () => { + it('snapshots one price version for a period inside one effective interval', () => { + const result = ratePeriodByPlans(period(), [plan()]) + + expect(result).toMatchObject({ + billedSeconds: '60', + preciseCents: '1380', + ratedCents: '1380', + pricingSegments: [ + { + pricingVersion: 1, + startAt: '2026-07-08T00:00:00.000Z', + endAt: '2026-07-08T00:01:00.000Z', + billedSeconds: '60', + unitRates: rates(), + preciseCents: '1380', + }, + ], + }) + }) + + it('splits a period at a pricing boundary and rounds only the aggregate charge', () => { + const boundary = new Date('2026-07-08T00:00:30Z') + const result = ratePeriodByPlans(period({ cpu: 1, mem: 0, disk: 0, gpu: 0 }), [ + plan({ + version: 1, + cpuRateCentsPerSec: '0.01', + memRateCentsPerSec: '0', + diskRateCentsPerSec: '0', + gpuRateCentsPerSec: '0', + effectiveTo: boundary, + }), + plan({ + version: 2, + cpuRateCentsPerSec: '0.02', + memRateCentsPerSec: '0', + diskRateCentsPerSec: '0', + gpuRateCentsPerSec: '0', + effectiveFrom: boundary, + }), + ]) + + expect(result.pricingSegments).toHaveLength(2) + expect(result.pricingSegments.map((segment) => segment.pricingVersion)).toEqual([1, 2]) + expect(result.pricingSegments.map((segment) => segment.preciseCents)).toEqual(['0.3', '0.6']) + expect(result).toMatchObject({ billedSeconds: '60', preciseCents: '0.9', ratedCents: '1' }) + }) + + it('rejects an uncovered interval instead of silently undercharging', () => { + expect(() => + ratePeriodByPlans(period(), [ + plan({ effectiveTo: new Date('2026-07-08T00:00:20Z') }), + plan({ version: 2, effectiveFrom: new Date('2026-07-08T00:00:30Z') }), + ]), + ).toThrow('pricing gap') + }) + + it('rejects overlapping price versions instead of choosing one arbitrarily', () => { + expect(() => + ratePeriodByPlans(period(), [ + plan({ effectiveTo: new Date('2026-07-08T00:00:40Z') }), + plan({ version: 2, effectiveFrom: new Date('2026-07-08T00:00:30Z') }), + ]), + ).toThrow('pricing overlap') + }) +}) diff --git a/apps/api/src/billing/rating/rate-math.ts b/apps/api/src/billing/rating/rate-math.ts new file mode 100644 index 000000000..9954828b0 --- /dev/null +++ b/apps/api/src/billing/rating/rate-math.ts @@ -0,0 +1,214 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import Decimal from 'decimal.js' + +export interface UsageTotals { + cpuSeconds: string + memGibSeconds: string + diskGibSeconds: string + gpuSeconds: string +} + +export interface RateSnapshot { + cpuRateCentsPerSec: string + memRateCentsPerSec: string + diskRateCentsPerSec: string + gpuRateCentsPerSec: string +} + +export interface PricingPlanSnapshot extends RateSnapshot { + version: number + effectiveFrom: Date + effectiveTo: Date | null +} + +export interface RateableUsagePeriod { + startAt: Date + endAt: Date + cpu: number + mem: number + disk: number + gpu: number +} + +export interface PricingSegment { + pricingVersion: number + startAt: string + endAt: string + billedSeconds: string + unitRates: RateSnapshot + usageTotals: UsageTotals + preciseCents: string +} + +export interface RatedPeriodComputation { + pricingSegments: PricingSegment[] + billedSeconds: string + usageTotals: UsageTotals + preciseCents: string + ratedCents: string +} + +const ZERO_TOTALS: UsageTotals = { + cpuSeconds: '0', + memGibSeconds: '0', + diskGibSeconds: '0', + gpuSeconds: '0', +} + +function elapsedSeconds(startAt: Date, endAt: Date): Decimal { + const milliseconds = endAt.getTime() - startAt.getTime() + if (milliseconds < 0) { + throw new Error(`usage period ends before it starts: ${startAt.toISOString()} - ${endAt.toISOString()}`) + } + return new Decimal(milliseconds).div(1000) +} + +function decimalResource(value: number, name: string): Decimal { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a finite non-negative number`) + } + return new Decimal(value.toString()) +} + +function decimalRate(value: string, name: string): Decimal { + const rate = new Decimal(value) + if (rate.isNegative()) { + throw new Error(`${name} rate must be non-negative`) + } + return rate +} + +function snapshotRates(plan: PricingPlanSnapshot): RateSnapshot { + return { + cpuRateCentsPerSec: plan.cpuRateCentsPerSec, + memRateCentsPerSec: plan.memRateCentsPerSec, + diskRateCentsPerSec: plan.diskRateCentsPerSec, + gpuRateCentsPerSec: plan.gpuRateCentsPerSec, + } +} + +function addUsageTotals(left: UsageTotals, right: UsageTotals): UsageTotals { + return { + cpuSeconds: new Decimal(left.cpuSeconds).plus(right.cpuSeconds).toString(), + memGibSeconds: new Decimal(left.memGibSeconds).plus(right.memGibSeconds).toString(), + diskGibSeconds: new Decimal(left.diskGibSeconds).plus(right.diskGibSeconds).toString(), + gpuSeconds: new Decimal(left.gpuSeconds).plus(right.gpuSeconds).toString(), + } +} + +export function periodBillableTotals( + period: RateableUsagePeriod, + startAt = period.startAt, + endAt = period.endAt, +): { billedSeconds: string; usageTotals: UsageTotals } { + const seconds = elapsedSeconds(startAt, endAt) + + return { + billedSeconds: seconds.toString(), + usageTotals: { + cpuSeconds: decimalResource(period.cpu, 'cpu').mul(seconds).toString(), + memGibSeconds: decimalResource(period.mem, 'mem').mul(seconds).toString(), + diskGibSeconds: decimalResource(period.disk, 'disk').mul(seconds).toString(), + gpuSeconds: decimalResource(period.gpu, 'gpu').mul(seconds).toString(), + }, + } +} + +export function computeRatedCents( + totals: UsageTotals, + rates: RateSnapshot, +): { preciseCents: string; ratedCents: string } { + const preciseCents = new Decimal(totals.cpuSeconds) + .mul(decimalRate(rates.cpuRateCentsPerSec, 'cpu')) + .plus(new Decimal(totals.memGibSeconds).mul(decimalRate(rates.memRateCentsPerSec, 'memory'))) + .plus(new Decimal(totals.diskGibSeconds).mul(decimalRate(rates.diskRateCentsPerSec, 'disk'))) + .plus(new Decimal(totals.gpuSeconds).mul(decimalRate(rates.gpuRateCentsPerSec, 'gpu'))) + + return { + preciseCents: preciseCents.toString(), + ratedCents: preciseCents.toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString(), + } +} + +export function ratePeriodByPlans( + period: RateableUsagePeriod, + pricingPlans: PricingPlanSnapshot[], +): RatedPeriodComputation { + const periodStart = period.startAt.getTime() + const periodEnd = period.endAt.getTime() + if (periodEnd < periodStart) { + throw new Error(`usage period ends before it starts: ${period.startAt.toISOString()} - ${period.endAt.toISOString()}`) + } + + const plans = [...pricingPlans].sort((left, right) => { + const byStart = left.effectiveFrom.getTime() - right.effectiveFrom.getTime() + return byStart || left.version - right.version + }) + let cursor = periodStart + const segments: PricingSegment[] = [] + + while (cursor < periodEnd) { + const activePlans = plans.filter((plan) => { + const effectiveFrom = plan.effectiveFrom.getTime() + const effectiveTo = plan.effectiveTo?.getTime() ?? Number.POSITIVE_INFINITY + return effectiveFrom <= cursor && cursor < effectiveTo + }) + + if (activePlans.length === 0) { + throw new Error(`pricing gap at ${new Date(cursor).toISOString()}`) + } + if (activePlans.length > 1) { + throw new Error(`pricing overlap at ${new Date(cursor).toISOString()}`) + } + + const activePlan = activePlans[0] + const activeEnd = activePlan.effectiveTo?.getTime() ?? Number.POSITIVE_INFINITY + const nextPlanStart = plans + .map((plan) => plan.effectiveFrom.getTime()) + .filter((effectiveFrom) => effectiveFrom > cursor) + .reduce((earliest, effectiveFrom) => Math.min(earliest, effectiveFrom), Number.POSITIVE_INFINITY) + const segmentEnd = Math.min(periodEnd, activeEnd, nextPlanStart) + if (segmentEnd <= cursor) { + throw new Error(`pricing interval does not advance at ${new Date(cursor).toISOString()}`) + } + + const startAt = new Date(cursor) + const endAt = new Date(segmentEnd) + const { billedSeconds, usageTotals } = periodBillableTotals(period, startAt, endAt) + const unitRates = snapshotRates(activePlan) + const { preciseCents } = computeRatedCents(usageTotals, unitRates) + segments.push({ + pricingVersion: activePlan.version, + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + billedSeconds, + unitRates, + usageTotals, + preciseCents, + }) + cursor = segmentEnd + } + + const usageTotals = segments.reduce( + (totals, segment) => addUsageTotals(totals, segment.usageTotals), + ZERO_TOTALS, + ) + const billedSeconds = segments + .reduce((seconds, segment) => seconds.plus(segment.billedSeconds), new Decimal(0)) + .toString() + const preciseCents = segments + .reduce((cents, segment) => cents.plus(segment.preciseCents), new Decimal(0)) + .toString() + + return { + pricingSegments: segments, + billedSeconds, + usageTotals, + preciseCents, + ratedCents: new Decimal(preciseCents).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString(), + } +} diff --git a/apps/api/src/billing/rating/rating.service.spec.ts b/apps/api/src/billing/rating/rating.service.spec.ts new file mode 100644 index 000000000..9c3f6a9f6 --- /dev/null +++ b/apps/api/src/billing/rating/rating.service.spec.ts @@ -0,0 +1,192 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { QueryFailedError } from 'typeorm' +import { BoxUsagePeriodArchive } from '../../usage/entities/box-usage-period-archive.entity' +import { PricingPlan } from '../entities/pricing-plan.entity' +import { RatedPeriod } from '../entities/rated-period.entity' +import { RatingService } from './rating.service' + +class FakeQueryBuilder { + constructor(private readonly rows: T[]) {} + + leftJoin() { + return this + } + + where() { + return this + } + + andWhere() { + return this + } + + orderBy() { + return this + } + + take() { + return this + } + + async getMany(): Promise { + return this.rows + } +} + +class FakeUsageArchiveRepository { + rows: BoxUsagePeriodArchive[] = [] + + createQueryBuilder() { + return new FakeQueryBuilder(this.rows) + } +} + +class FakePricingPlanRepository { + rows: PricingPlan[] = [] + + createQueryBuilder() { + return new FakeQueryBuilder(this.rows) + } +} + +class FakeRatedPeriodRepository { + rows: RatedPeriod[] = [] + saveError: Error | null = null + + create(input: Partial): RatedPeriod { + return input as RatedPeriod + } + + async save(row: RatedPeriod): Promise { + if (this.saveError) { + throw this.saveError + } + if (this.rows.some((existing) => existing.usagePeriodArchiveId === row.usagePeriodArchiveId)) { + throw uniqueViolation() + } + row.id = row.id ?? `rated-${this.rows.length + 1}` + this.rows.push(row) + return row + } +} + +function uniqueViolation(): QueryFailedError { + return new QueryFailedError('INSERT INTO rated_period', [], Object.assign(new Error('duplicate'), { code: '23505' })) +} + +function archivedPeriod(overrides: Partial = {}): BoxUsagePeriodArchive { + return { + id: 'b3fbf3a8-0c33-4962-bd77-8ae77313baf1', + boxId: 'box-1', + organizationId: 'f5de33a9-4eb2-4279-a8de-9f02d63cc4f0', + region: 'us', + startAt: new Date('2026-07-08T00:00:00Z'), + endAt: new Date('2026-07-08T00:01:00Z'), + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + ...overrides, + } as BoxUsagePeriodArchive +} + +function pricingPlan(overrides: Partial = {}): PricingPlan { + return { + id: 'd4624b9b-d5d7-471c-9dce-f1e96ab0ab47', + version: 1, + cpuRateCentsPerSec: '2', + memRateCentsPerSec: '1', + diskRateCentsPerSec: '0.5', + gpuRateCentsPerSec: '10', + effectiveFrom: new Date('2026-01-01T00:00:00Z'), + effectiveTo: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + } as PricingPlan +} + +function createService() { + const usageArchives = new FakeUsageArchiveRepository() + const ratedPeriods = new FakeRatedPeriodRepository() + const pricingPlans = new FakePricingPlanRepository() + const service = new RatingService(usageArchives as never, ratedPeriods as never, pricingPlans as never) + return { service, usageArchives, ratedPeriods, pricingPlans } +} + +describe('RatingService', () => { + it('rates an archived usage period exactly once and stores immutable segment snapshots', async () => { + const { service, usageArchives, ratedPeriods, pricingPlans } = createService() + usageArchives.rows.push(archivedPeriod()) + pricingPlans.rows.push(pricingPlan()) + + expect(await service.rateClosedPeriods()).toEqual({ rated: 1, skipped: 0 }) + expect(await service.rateClosedPeriods()).toEqual({ rated: 0, skipped: 1 }) + expect(ratedPeriods.rows).toHaveLength(1) + expect(ratedPeriods.rows[0]).toMatchObject({ + usagePeriodArchiveId: 'b3fbf3a8-0c33-4962-bd77-8ae77313baf1', + organizationId: 'f5de33a9-4eb2-4279-a8de-9f02d63cc4f0', + boxId: 'box-1', + billedSeconds: '60', + preciseCents: '1380', + ratedCents: '1380', + pricingSegments: [{ pricingVersion: 1 }], + }) + }) + + it('stores both price snapshots when a usage period crosses an effective boundary', async () => { + const { service, usageArchives, ratedPeriods, pricingPlans } = createService() + const boundary = new Date('2026-07-08T00:00:30Z') + usageArchives.rows.push(archivedPeriod({ cpu: 1, mem: 0, disk: 0, gpu: 0 })) + pricingPlans.rows.push( + pricingPlan({ cpuRateCentsPerSec: '0.01', effectiveTo: boundary }), + pricingPlan({ + id: 'f838ba1d-ef3c-45ab-a036-af916da6d0d2', + version: 2, + cpuRateCentsPerSec: '0.02', + effectiveFrom: boundary, + }), + ) + + await service.rateClosedPeriods() + + expect(ratedPeriods.rows[0]).toMatchObject({ + preciseCents: '0.9', + ratedCents: '1', + pricingSegments: [{ pricingVersion: 1 }, { pricingVersion: 2 }], + }) + }) + + it('fails on a pricing gap without mutating the usage archive', async () => { + const { service, usageArchives, ratedPeriods, pricingPlans } = createService() + const source = archivedPeriod() + const sourceBefore = { ...source } + usageArchives.rows.push(source) + pricingPlans.rows.push(pricingPlan({ effectiveTo: new Date('2026-07-08T00:00:20Z') })) + + await expect(service.rateClosedPeriods()).rejects.toThrow('pricing gap') + expect(ratedPeriods.rows).toHaveLength(0) + expect(source).toEqual(sourceBefore) + }) + + it('treats a PostgreSQL unique violation as an idempotent skip', async () => { + const { service, usageArchives, ratedPeriods, pricingPlans } = createService() + usageArchives.rows.push(archivedPeriod()) + pricingPlans.rows.push(pricingPlan()) + ratedPeriods.saveError = uniqueViolation() + + expect(await service.rateClosedPeriods()).toEqual({ rated: 0, skipped: 1 }) + }) + + it('propagates unrelated persistence failures', async () => { + const { service, usageArchives, ratedPeriods, pricingPlans } = createService() + usageArchives.rows.push(archivedPeriod()) + pricingPlans.rows.push(pricingPlan()) + ratedPeriods.saveError = new Error('database unavailable') + + await expect(service.rateClosedPeriods()).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/api/src/billing/rating/rating.service.ts b/apps/api/src/billing/rating/rating.service.ts new file mode 100644 index 000000000..a063fc115 --- /dev/null +++ b/apps/api/src/billing/rating/rating.service.ts @@ -0,0 +1,100 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { InjectRepository } from '@nestjs/typeorm' +import { QueryFailedError, Repository } from 'typeorm' +import { BoxUsagePeriodArchive } from '../../usage/entities/box-usage-period-archive.entity' +import { PricingPlan } from '../entities/pricing-plan.entity' +import { RatedPeriod } from '../entities/rated-period.entity' +import { ratePeriodByPlans } from './rate-math' + +const PG_UNIQUE_VIOLATION = '23505' +const RATING_BATCH_SIZE = 100 + +function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof QueryFailedError && + (error.driverError as { code?: string } | undefined)?.code === PG_UNIQUE_VIOLATION + ) +} + +@Injectable() +export class RatingService { + private readonly logger = new Logger(RatingService.name) + + constructor( + @InjectRepository(BoxUsagePeriodArchive) + private readonly usageArchives: Repository, + @InjectRepository(RatedPeriod) + private readonly ratedPeriods: Repository, + @InjectRepository(PricingPlan) + private readonly pricingPlans: Repository, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES, { name: 'rate-usage-periods' }) + async scheduledSweep(): Promise { + const result = await this.rateClosedPeriods() + if (result.rated || result.skipped) { + this.logger.log(`rating sweep: rated ${result.rated}, skipped ${result.skipped}`) + } + } + + async rateClosedPeriods(limit = RATING_BATCH_SIZE): Promise<{ rated: number; skipped: number }> { + const periods = await this.findUnratedArchivedPeriods(limit) + let rated = 0 + let skipped = 0 + + for (const period of periods) { + if (await this.ratePeriod(period)) { + rated++ + } else { + skipped++ + } + } + + return { rated, skipped } + } + + async ratePeriod(period: BoxUsagePeriodArchive): Promise { + const plans = await this.findPricingPlans(period) + const computation = ratePeriodByPlans(period, plans) + const row = this.ratedPeriods.create({ + usagePeriodArchiveId: period.id, + organizationId: period.organizationId, + boxId: period.boxId, + ...computation, + }) + + try { + return (await this.ratedPeriods.save(row)) ?? null + } catch (error) { + if (isUniqueViolation(error)) { + return null + } + throw error + } + } + + private findUnratedArchivedPeriods(limit: number): Promise { + return this.usageArchives + .createQueryBuilder('up') + .leftJoin(RatedPeriod, 'rp', 'rp."usagePeriodArchiveId" = up.id') + .where('rp.id IS NULL') + .orderBy('up.startAt', 'ASC') + .take(limit) + .getMany() + } + + private findPricingPlans(period: BoxUsagePeriodArchive): Promise { + return this.pricingPlans + .createQueryBuilder('p') + .where('p."effectiveFrom" < :endAt', { endAt: period.endAt }) + .andWhere('(p."effectiveTo" IS NULL OR p."effectiveTo" > :startAt)', { startAt: period.startAt }) + .orderBy('p.effectiveFrom', 'ASC') + .getMany() + } +} diff --git a/apps/api/src/migrations/pre-deploy/1782700100000-migration.spec.ts b/apps/api/src/migrations/pre-deploy/1782700100000-migration.spec.ts new file mode 100644 index 000000000..5a819c852 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700100000-migration.spec.ts @@ -0,0 +1,43 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { QueryRunner } from 'typeorm' +import { Migration1782700100000 } from './1782700100000-migration' + +class RecordingQueryRunner { + readonly queries: string[] = [] + + async query(sql: string): Promise { + this.queries.push(sql.replace(/\s+/g, ' ').trim()) + } +} + +describe('Migration1782700100000', () => { + it('creates versioned pricing and one idempotent rated row per usage archive', async () => { + const runner = new RecordingQueryRunner() + + await new Migration1782700100000().up(runner as unknown as QueryRunner) + + const sql = runner.queries.join('\n') + expect(sql).toContain('CREATE TABLE "pricing_plan"') + expect(sql).toContain('CREATE TABLE "rated_period"') + expect(sql).toContain('"pricingSegments" jsonb NOT NULL') + expect(sql).toContain('CREATE UNIQUE INDEX "rated_period_usage_archive_idx"') + expect(sql).toContain('CONSTRAINT "pricing_plan_non_negative_rates" CHECK') + expect(sql).toContain('0.0014') + expect(sql).toContain('0.00045') + expect(sql).toContain('0.000003') + expect(sql).not.toContain('defaultGrantCents') + expect(sql).not.toContain('warnThresholdCents') + }) + + it('drops the dependent rated table before pricing plans', async () => { + const runner = new RecordingQueryRunner() + + await new Migration1782700100000().down(runner as unknown as QueryRunner) + + expect(runner.queries).toEqual(['DROP TABLE "rated_period"', 'DROP TABLE "pricing_plan"']) + }) +}) diff --git a/apps/api/src/migrations/pre-deploy/1782700100000-migration.ts b/apps/api/src/migrations/pre-deploy/1782700100000-migration.ts new file mode 100644 index 000000000..11d2affa1 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700100000-migration.ts @@ -0,0 +1,76 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1782700100000 implements MigrationInterface { + name = 'Migration1782700100000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "pricing_plan" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "version" integer NOT NULL, + "cpuRateCentsPerSec" numeric(38,18) NOT NULL, + "memRateCentsPerSec" numeric(38,18) NOT NULL, + "diskRateCentsPerSec" numeric(38,18) NOT NULL, + "gpuRateCentsPerSec" numeric(38,18) NOT NULL, + "effectiveFrom" TIMESTAMP WITH TIME ZONE NOT NULL, + "effectiveTo" TIMESTAMP WITH TIME ZONE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "pricing_plan_effective_interval" CHECK ("effectiveTo" IS NULL OR "effectiveTo" > "effectiveFrom"), + CONSTRAINT "pricing_plan_non_negative_rates" CHECK ( + "cpuRateCentsPerSec" >= 0 AND + "memRateCentsPerSec" >= 0 AND + "diskRateCentsPerSec" >= 0 AND + "gpuRateCentsPerSec" >= 0 + ), + CONSTRAINT "pricing_plan_id_pk" PRIMARY KEY ("id") + )`, + ) + await queryRunner.query(`CREATE UNIQUE INDEX "pricing_plan_version_idx" ON "pricing_plan" ("version")`) + await queryRunner.query(`CREATE INDEX "pricing_plan_effective_idx" ON "pricing_plan" ("effectiveFrom")`) + await queryRunner.query( + `INSERT INTO "pricing_plan" ( + "version", + "cpuRateCentsPerSec", + "memRateCentsPerSec", + "diskRateCentsPerSec", + "gpuRateCentsPerSec", + "effectiveFrom", + "effectiveTo" + ) VALUES (1, 0.0014, 0.00045, 0.000003, 0, '2026-01-01T00:00:00Z', NULL)`, + ) + + await queryRunner.query( + `CREATE TABLE "rated_period" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "usagePeriodArchiveId" uuid NOT NULL, + "organizationId" uuid NOT NULL, + "boxId" character varying NOT NULL, + "pricingSegments" jsonb NOT NULL, + "usageTotals" jsonb NOT NULL, + "billedSeconds" numeric(38,3) NOT NULL, + "preciseCents" numeric(38,18) NOT NULL, + "ratedCents" bigint NOT NULL, + "ratedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "rated_period_usage_archive_fk" FOREIGN KEY ("usagePeriodArchiveId") + REFERENCES "box_usage_period_archive"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "rated_period_id_pk" PRIMARY KEY ("id") + )`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "rated_period_usage_archive_idx" ON "rated_period" ("usagePeriodArchiveId")`, + ) + await queryRunner.query( + `CREATE INDEX "rated_period_org_rated_at_idx" ON "rated_period" ("organizationId", "ratedAt")`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "rated_period"`) + await queryRunner.query(`DROP TABLE "pricing_plan"`) + } +} diff --git a/apps/package.json b/apps/package.json index 06406c147..fbb640c47 100644 --- a/apps/package.json +++ b/apps/package.json @@ -147,6 +147,7 @@ "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "date-fns": "^4.1.0", + "decimal.js": "^10.4.3", "dockerode": "^4.0.4", "dotenv": "^17.0.1", "ejs": "^3.1.10", diff --git a/apps/yarn.lock b/apps/yarn.lock index 6e4ecb436..e2e00f732 100644 --- a/apps/yarn.lock +++ b/apps/yarn.lock @@ -16844,6 +16844,7 @@ __metadata: cmdk: "npm:^1.1.1" cookie-parser: "npm:^1.4.7" date-fns: "npm:^4.1.0" + decimal.js: "npm:^10.4.3" dockerode: "npm:^4.0.4" dotenv: "npm:^17.0.1" ejs: "npm:^3.1.10"