6a7802c870
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.
187 lines
7.3 KiB
Vue
187 lines
7.3 KiB
Vue
<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
|
|
// (operator-guarded). Invoices sync from Stripe when live; empty in dev, where
|
|
// the summary still reflects real outstanding/paid across stored invoices.
|
|
interface BillingSummary {
|
|
invoicedMinor: number
|
|
paidMinor: number
|
|
outstandingMinor: number
|
|
openInvoices: number
|
|
pastDueInvoices: number
|
|
stripeLive: boolean
|
|
}
|
|
interface Invoice {
|
|
_id: string
|
|
number?: string
|
|
tenantId: string
|
|
currency: 'DKK' | 'EUR' | 'USD'
|
|
amountDue: number
|
|
amountPaid: number
|
|
status: string
|
|
periodEnd?: string
|
|
createdAt?: string
|
|
hostedInvoiceUrl?: string
|
|
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'
|
|
if (s === 'open') return 'warn'
|
|
return 'neutral'
|
|
}
|
|
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(), rP()])
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Commercial"
|
|
title="Platform billing"
|
|
:subtitle="`${money(summary?.outstandingMinor ?? 0)} DKK outstanding · ${summary?.openInvoices ?? 0} open`"
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="secondary" :disabled="pending" @click="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>
|
|
|
|
<div class="stage">
|
|
<div v-if="!summary?.stripeLive" class="derived-note">
|
|
<UiIcon name="card" :size="13" />
|
|
<Mono dim>Stripe not connected · invoices populate from webhooks once billing is live. Outstanding/paid reflect stored invoices.</Mono>
|
|
</div>
|
|
|
|
<div class="vitals">
|
|
<Card><Stat label="Invoiced" :value="`${money(summary?.invoicedMinor ?? 0)} DKK`" /></Card>
|
|
<Card><Stat label="Paid" :value="`${money(summary?.paidMinor ?? 0)} DKK`" /></Card>
|
|
<Card><Stat label="Outstanding" :value="`${money(summary?.outstandingMinor ?? 0)} DKK`" :delta-tone="(summary?.outstandingMinor ?? 0) > 0 ? 'down' : undefined" /></Card>
|
|
<Card><Stat label="Past due" :value="summary?.pastDueInvoices ?? 0" :delta-tone="(summary?.pastDueInvoices ?? 0) > 0 ? 'down' : undefined" /></Card>
|
|
</div>
|
|
|
|
<Card :pad="0">
|
|
<div class="head"><div><Eyebrow>Invoices</Eyebrow><div class="cap">Platform-wide</div></div></div>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Invoice</th><th>Tenant</th><th>Date</th><th class="num">Amount</th><th>Status</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="inv in invoices" :key="inv._id">
|
|
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
|
|
<td class="name">{{ tenantName(inv.tenantId) }}</td>
|
|
<td><Mono dim>{{ fmtDate(inv.periodEnd || inv.createdAt) }}</Mono></td>
|
|
<td class="num"><Mono>{{ money(inv.amountDue) }} {{ inv.currency }}</Mono></td>
|
|
<td><Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge></td>
|
|
</tr>
|
|
<tr v-if="!invoices.length"><td colspan="5" class="empty"><Mono dim>// no invoices yet</Mono></td></tr>
|
|
</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>
|
|
|
|
<style scoped>
|
|
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
|
.derived-note { display: flex; align-items: center; gap: 8px; }
|
|
.derived-note :deep(svg) { color: var(--text-mute); }
|
|
.vitals {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 1px;
|
|
background: var(--border);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.vitals > * { background: var(--surface); padding: 20px; }
|
|
.head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
|
.cap { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th {
|
|
text-align: left;
|
|
font-family: var(--font-mono);
|
|
font-size: 9px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
padding: 12px 20px;
|
|
font-weight: 500;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
th.num, td.num { text-align: right; }
|
|
td { padding: 10px 20px; font-size: 12px; border-top: 1px solid var(--border); }
|
|
td.name { font-weight: 500; }
|
|
.empty { text-align: center; padding: 32px; }
|
|
</style>
|