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:
@@ -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)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user