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