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
+54 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Partner } from '~/types/partner'
import type { Tenant } from '~/types/tenant'
// Platform-wide billing. Real data from /api/billing/* → platform-api
@@ -26,14 +27,40 @@ interface Invoice {
pdfUrl?: string
}
interface Payout {
_id: string
partnerId: string
periodMonth: string
currency: 'DKK' | 'EUR' | 'USD'
grossMrrMinor: number
marginPct: number
payoutMinor: number
status: 'pending' | 'paid'
}
const { data: summary, pending, refresh: rS } = await useFetch<BillingSummary>('/api/billing/summary', {
default: () => ({ invoicedMinor: 0, paidMinor: 0, outstandingMinor: 0, openInvoices: 0, pastDueInvoices: 0, stripeLive: false }),
})
const { data: invoices, refresh: rI } = await useFetch<Invoice[]>('/api/billing/invoices', { default: () => [] })
const { data: payouts, refresh: rP } = await useFetch<Payout[]>('/api/billing/payouts', { default: () => [] })
const { data: tenants } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
const { data: partners } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
const tenantName = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.name ?? id
const partnerName = (id: string) => (partners.value ?? []).find((p) => p._id === id)?.name ?? id
const money = (m: number) => Math.round(m / 100).toLocaleString('da-DK')
// Force a payout-ledger run (current month), then refresh the table.
const generating = ref(false)
async function generatePayouts() {
generating.value = true
try {
await $fetch('/api/billing/payouts/generate', { method: 'POST' })
await rP()
} finally {
generating.value = false
}
}
function invoiceTone(s: string): 'ok' | 'warn' | 'bad' | 'neutral' {
if (s === 'paid') return 'ok'
if (s === 'past_due' || s === 'uncollectible') return 'bad'
@@ -44,7 +71,7 @@ function fmtDate(iso?: string) {
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short' }) : '—'
}
async function refresh() {
await Promise.all([rS(), rI()])
await Promise.all([rS(), rI(), rP()])
}
</script>
@@ -60,6 +87,10 @@ async function refresh() {
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
<UiButton variant="primary" :disabled="generating" @click="generatePayouts">
<template #leading><UiIcon name="refresh" :size="13" /></template>
{{ generating ? 'Generating…' : 'Generate payouts' }}
</UiButton>
</template>
</PageHeader>
@@ -94,6 +125,28 @@ async function refresh() {
</tbody>
</table>
</Card>
<Card :pad="0">
<div class="head">
<div><Eyebrow>Payouts</Eyebrow><div class="cap">Partner-cut ledger</div></div>
</div>
<table>
<thead>
<tr><th>Partner</th><th>Period</th><th class="num">Gross MRR</th><th class="num">Margin</th><th class="num">Payout</th><th>Status</th></tr>
</thead>
<tbody>
<tr v-for="p in payouts" :key="p._id">
<td class="name">{{ partnerName(p.partnerId) }}</td>
<td><Mono dim>{{ p.periodMonth }}</Mono></td>
<td class="num"><Mono>{{ money(p.grossMrrMinor) }} {{ p.currency }}</Mono></td>
<td class="num"><Mono dim>{{ p.marginPct }}%</Mono></td>
<td class="num"><Mono>{{ money(p.payoutMinor) }} {{ p.currency }}</Mono></td>
<td><Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge></td>
</tr>
<tr v-if="!payouts.length"><td colspan="6" class="empty"><Mono dim>// no payouts generated yet</Mono></td></tr>
</tbody>
</table>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,4 @@
import { platformApi } from '~~/server/utils/platform-api'
// Platform-wide partner-payout ledger. Operator-only.
export default defineEventHandler(async (event) => platformApi(event, '/billing/payouts'))
@@ -0,0 +1,8 @@
import { platformApi } from '~~/server/utils/platform-api'
// Force a payout-ledger generation run. Optional ?period=YYYY-MM. Operator-only.
export default defineEventHandler(async (event) => {
const period = getQuery(event).period
const qs = period ? `?period=${encodeURIComponent(String(period))}` : ''
return platformApi(event, `/billing/payouts/generate${qs}`, { method: 'POST' })
})
@@ -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)}`,
)
}
}
}