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