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
@@ -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