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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user