6a7802c870
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.
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
// 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)}`,
|
||
)
|
||
}
|
||
}
|
||
}
|