diff --git a/apps/operator/pages/billing.vue b/apps/operator/pages/billing.vue index 60c6932..75e91ff 100644 --- a/apps/operator/pages/billing.vue +++ b/apps/operator/pages/billing.vue @@ -1,4 +1,5 @@ @@ -60,6 +87,10 @@ async function refresh() { Refresh + + + {{ generating ? 'Generating…' : 'Generate payouts' }} + @@ -94,6 +125,28 @@ async function refresh() { + + +
+
Payouts
Partner-cut ledger
+
+ + + + + + + + + + + + + + + +
PartnerPeriodGross MRRMarginPayoutStatus
{{ partnerName(p.partnerId) }}{{ p.periodMonth }}{{ money(p.grossMrrMinor) }} {{ p.currency }}{{ p.marginPct }}%{{ money(p.payoutMinor) }} {{ p.currency }}{{ p.status }}
// no payouts generated yet
+
diff --git a/apps/operator/server/api/billing/payouts.get.ts b/apps/operator/server/api/billing/payouts.get.ts new file mode 100644 index 0000000..a05b901 --- /dev/null +++ b/apps/operator/server/api/billing/payouts.get.ts @@ -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')) diff --git a/apps/operator/server/api/billing/payouts/generate.post.ts b/apps/operator/server/api/billing/payouts/generate.post.ts new file mode 100644 index 0000000..547a6c5 --- /dev/null +++ b/apps/operator/server/api/billing/payouts/generate.post.ts @@ -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' }) +}) diff --git a/services/platform-api/src/billing/billing.module.ts b/services/platform-api/src/billing/billing.module.ts index b17a7e1..3ac7db4 100644 --- a/services/platform-api/src/billing/billing.module.ts +++ b/services/platform-api/src/billing/billing.module.ts @@ -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 {} diff --git a/services/platform-api/src/billing/billing.service.ts b/services/platform-api/src/billing/billing.service.ts index 835da1a..e04dbde 100644 --- a/services/platform-api/src/billing/billing.service.ts +++ b/services/platform-api/src/billing/billing.service.ts @@ -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 { + return this.payoutModel.find().sort({ periodMonth: -1 }).exec() + } + // ── Operator (platform-wide) billing reads ─────────────────────────────── async platformSummary(): Promise<{ invoicedMinor: number diff --git a/services/platform-api/src/billing/operator-billing.controller.ts b/services/platform-api/src/billing/operator-billing.controller.ts index e07df68..14df86a 100644 --- a/services/platform-api/src/billing/operator-billing.controller.ts +++ b/services/platform-api/src/billing/operator-billing.controller.ts @@ -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[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), + }) + } } diff --git a/services/platform-api/src/billing/payout.worker.ts b/services/platform-api/src/billing/payout.worker.ts new file mode 100644 index 0000000..a7f79ee --- /dev/null +++ b/services/platform-api/src/billing/payout.worker.ts @@ -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 { + 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 { + 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)}`, + ) + } + } +}