Files
dezky/services/platform-api/src/billing/payout.worker.ts
T
Ronni Baslund 6a7802c870 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.
2026-05-30 14:40:01 +02:00

68 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)}`,
)
}
}
}