feat(billing): partner payout-ledger generation (worker + operator trigger)

Add BillingService.generatePayouts: idempotent per-partner/month/currency snapshot of gross MRR x marginPct into Payout rows (never rewrites a paid row), plus platformPayouts(). A PayoutWorker generates the current month daily (and on boot; PAYOUTS_AUTOGEN=false to disable). Operator endpoints GET /billing/payouts + POST /billing/payouts/generate, an operator payouts ledger table with a Generate button, and the proxy routes. The partner Payouts tab now shows real data.
This commit is contained in:
Ronni Baslund
2026-05-30 14:40:01 +02:00
parent 9c65a65bcd
commit 6a7802c870
7 changed files with 229 additions and 5 deletions
@@ -0,0 +1,67 @@
// Daily-ish generator for the partner payout ledger. Mirrors ArchiveWorker:
// a cheap hourly tick with a once-per-day guard, no cron lib needed. Each run
// (re)generates the CURRENT month's payout rows from live MRR × marginPct, so
// the partner Payouts tab reflects this month's accrual; past months freeze at
// their last snapshot once the month rolls over. The operator UI can force a
// run (any period) via POST /billing/payouts/generate regardless of this timer.
import { Injectable, Logger, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { BillingService } from './billing.service.js'
const TICK_MS = 60 * 60 * 1000 // 1 hour; a day-guard limits real work to once/day.
const DEFAULT_RUN_HOUR_UTC = 2 // 02:00 UTC daily (before the audit archive at 03:00).
@Injectable()
export class PayoutWorker implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(PayoutWorker.name)
private readonly enabled: boolean
private readonly runHour: number
private timer: NodeJS.Timeout | null = null
private lastRunDay: string | null = null
constructor(
private readonly billing: BillingService,
config: ConfigService,
) {
// On by default — payout generation is cheap, derived, and Stripe-independent.
// Set PAYOUTS_AUTOGEN=false to leave it to manual operator runs only.
this.enabled = config.get('PAYOUTS_AUTOGEN') !== 'false'
this.runHour = Number(config.get('PAYOUTS_RUN_HOUR_UTC') ?? DEFAULT_RUN_HOUR_UTC)
}
onApplicationBootstrap(): void {
if (!this.enabled) {
this.logger.log('PAYOUTS_AUTOGEN=false — payout scheduler dormant. Operator UI can force runs.')
return
}
this.logger.log(`Payout scheduler active · runs daily at ${this.runHour}:00 UTC`)
// Generate once on startup so the current month's ledger exists immediately;
// the day-guard then prevents repeat runs within the same UTC day.
void this.run('startup')
this.timer = setInterval(() => void this.tick(), TICK_MS)
}
onModuleDestroy(): void {
if (this.timer) clearInterval(this.timer)
}
private async tick(): Promise<void> {
if (new Date().getUTCHours() !== this.runHour) return
const today = new Date().toISOString().slice(0, 10)
if (this.lastRunDay === today) return
await this.run('tick')
}
private async run(trigger: string): Promise<void> {
this.lastRunDay = new Date().toISOString().slice(0, 10)
try {
const res = await this.billing.generatePayouts()
this.logger.log(`Payout run (${trigger}) for ${res.periodMonth}: ${res.rows} row(s)`)
} catch (err) {
this.logger.error(
`Payout run (${trigger}) failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
}