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
@@ -12,6 +12,7 @@ import { UsersModule } from '../users/users.module.js'
import { BillingService } from './billing.service.js'
import { OperatorBillingController } from './operator-billing.controller.js'
import { PartnerBillingController } from './partner-billing.controller.js'
import { PayoutWorker } from './payout.worker.js'
import { StripeWebhookController } from './stripe-webhook.controller.js'
@Module({
@@ -29,6 +30,6 @@ import { StripeWebhookController } from './stripe-webhook.controller.js'
UsersModule,
],
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
providers: [BillingService],
providers: [BillingService, PayoutWorker],
})
export class BillingModule {}
@@ -18,6 +18,11 @@ function toCurrency(c?: string): Currency {
return up === 'EUR' || up === 'USD' ? up : 'DKK'
}
// Current calendar month as YYYY-MM (UTC). The payout ledger keys on this.
function currentPeriodMonth(): string {
return new Date().toISOString().slice(0, 7)
}
// Billing reads run on DERIVED data (Subscription + Price + marginPct + the
// Invoice/Payout collections), so the billing pages show real numbers in dev
// even with no live Stripe. The webhook populates Invoices/Subscription status
@@ -83,6 +88,63 @@ export class BillingService {
return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec()
}
// ── Payout ledger generation ──────────────────────────────────────────────
// v1 is a COMPUTED ledger (not Stripe Connect): for the given month we snapshot
// each partner's gross MRR per currency and their marginPct cut into Payout rows.
// Idempotent — re-running the same period refreshes the (still-pending) amounts;
// a row already marked `paid` is left untouched so a settled payout is never
// rewritten. Disbursement stays out-of-band (operator marks rows paid).
//
// NOTE: MRR is a CURRENT snapshot, so regenerating a closed month would re-read
// today's subscriptions. The worker only ever generates the current month, so a
// past month freezes at its last snapshot once the month rolls over.
async generatePayouts(
periodMonth?: string,
actor?: AuditActor,
): Promise<{ periodMonth: string; partners: number; rows: number }> {
const period = periodMonth ?? currentPeriodMonth()
const partners = await this.partnerModel.find({}, { marginPct: 1 }).exec()
let rows = 0
for (const p of partners) {
const marginPct = p.marginPct ?? 0
const mrr = await this.users.partnerMrr(p._id)
for (const t of mrr.totals) {
// Never mutate a settled payout.
const existing = await this.payoutModel
.findOne({ partnerId: p._id, periodMonth: period, currency: t.currency }, { status: 1 })
.exec()
if (existing?.status === 'paid') continue
const payoutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
await this.payoutModel
.updateOne(
{ partnerId: p._id, periodMonth: period, currency: t.currency },
{
$set: { grossMrrMinor: t.monthlyMinor, marginPct, payoutMinor },
$setOnInsert: { status: 'pending' },
},
{ upsert: true },
)
.exec()
rows++
}
}
void this.audit.record(
{
action: 'billing.payouts_generated',
resourceType: 'subscription',
resourceId: period,
metadata: { periodMonth: period, partners: partners.length, rows },
},
actor,
)
this.logger.log(`Generated payouts for ${period}: ${rows} row(s) across ${partners.length} partner(s)`)
return { periodMonth: period, partners: partners.length, rows }
}
async platformPayouts(): Promise<PayoutDocument[]> {
return this.payoutModel.find().sort({ periodMonth: -1 }).exec()
}
// ── Operator (platform-wide) billing reads ───────────────────────────────
async platformSummary(): Promise<{
invoicedMinor: number
@@ -1,13 +1,20 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common'
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { BillingService } from './billing.service.js'
// Platform-wide billing reads. Operator-only.
// Platform-wide billing reads + payout-ledger generation. Operator-only.
@Controller('billing')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class OperatorBillingController {
constructor(private readonly billing: BillingService) {}
constructor(
private readonly billing: BillingService,
private readonly actor: ActorService,
) {}
@Get('summary')
summary() {
@@ -23,4 +30,26 @@ export class OperatorBillingController {
tenantInvoices(@Param('slug') slug: string) {
return this.billing.tenantInvoicesBySlug(slug)
}
// Full partner-cut ledger across all partners (most recent month first).
@Get('payouts')
payouts() {
return this.billing.platformPayouts()
}
// Force a payout-ledger generation run. Optional ?period=YYYY-MM (defaults to
// the current month). Idempotent; never rewrites a row already marked paid.
@Post('payouts/generate')
async generatePayouts(
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
@Query('period') period?: string,
) {
const user = await this.actor.resolve(jwt)
return this.billing.generatePayouts(period, {
userId: user._id,
email: user.email,
ip: clientIp(req),
})
}
}
@@ -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)}`,
)
}
}
}