feat(billing): Stripe-backed billing engine (dark-launched)
Add a lazy/guarded Stripe client (boots without keys), Invoice/Payout schemas, per-currency Price.stripePriceIds, and a BillingService deriving partner/platform summaries, invoices and a partner-cut payout ledger. Partner and operator billing controllers plus a signature-verified Stripe webhook (Fastify raw body). Frontend: partner and operator billing pages and the operator tenant billing/audit tabs on real data. Gated behind new_billing_engine and BILLING_STRIPE_ENABLED; live money paths stay off until keys are set.
This commit is contained in:
@@ -1,10 +1,133 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
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: tenants } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
||||
|
||||
const tenantName = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.name ?? id
|
||||
const money = (m: number) => Math.round(m / 100).toLocaleString('da-DK')
|
||||
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()])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OpPlaceholder
|
||||
eyebrow="Commercial"
|
||||
title="Platform billing"
|
||||
icon="card"
|
||||
body="MRR, invoice runs, dunning state, Stripe sync. Will populate once Subscription gains real pricing and the Stripe integration ships."
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Tenant, TenantUser } from '~/types/tenant'
|
||||
import type { AuditEvent } from '~/types/audit'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
@@ -17,8 +18,44 @@ const { data: users, refresh: refreshUsers } = useLazyFetch<TenantUser[]>(
|
||||
() => `/api/tenants/${slug.value}/users`,
|
||||
{ immediate: false, default: () => [] },
|
||||
)
|
||||
// Lazy-fetch this tenant's audit trail only when the Audit tab is opened.
|
||||
const { data: auditEvents, refresh: refreshAudit } = useLazyFetch<AuditEvent[]>(
|
||||
() => `/api/audit?tenantSlug=${slug.value}&limit=50`,
|
||||
{ immediate: false, default: () => [] },
|
||||
)
|
||||
function fmtAudit(iso: string) {
|
||||
return new Date(iso).toLocaleString('da-DK', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
interface TenantInvoice {
|
||||
_id: string
|
||||
number?: string
|
||||
currency: 'DKK' | 'EUR' | 'USD'
|
||||
amountDue: number
|
||||
status: string
|
||||
periodEnd?: string
|
||||
createdAt?: string
|
||||
}
|
||||
const { data: billingInvoices, refresh: refreshBilling } = useLazyFetch<TenantInvoice[]>(
|
||||
() => `/api/billing/tenants/${slug.value}/invoices`,
|
||||
{ immediate: false, default: () => [] },
|
||||
)
|
||||
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'
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'users' && (users.value?.length ?? 0) === 0) refreshUsers()
|
||||
if (t === 'audit' && (auditEvents.value?.length ?? 0) === 0) refreshAudit()
|
||||
if (t === 'billing' && (billingInvoices.value?.length ?? 0) === 0) refreshBilling()
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
@@ -218,30 +255,43 @@ async function reconcile() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- BILLING (mock) -->
|
||||
<!-- BILLING (real · /api/billing/tenants/:slug/invoices) -->
|
||||
<div v-else-if="activeTab === 'billing'">
|
||||
<Card>
|
||||
<div class="card-head"><h2>Subscriptions & invoices</h2><Badge tone="warn">mock</Badge></div>
|
||||
<p class="hint">Stripe integration ships in a later phase. Right now we show fixtures.</p>
|
||||
<dl class="mt">
|
||||
<div class="dl-row"><dt>Plan</dt><dd><Badge tone="neutral">{{ tenant.plan }}</Badge></dd></div>
|
||||
<div class="dl-row"><dt>MRR</dt><dd><Mono>4 840 DKK</Mono></dd></div>
|
||||
<div class="dl-row"><dt>Next invoice</dt><dd><Mono dim>2026-06-01</Mono></dd></div>
|
||||
<div class="dl-row"><dt>Stripe customer</dt><dd><Mono dim>cus_mock_…</Mono></dd></div>
|
||||
</dl>
|
||||
<Card :pad="0">
|
||||
<div class="card-head padded"><h2>Subscription & invoices</h2></div>
|
||||
<table class="rich">
|
||||
<thead><tr><th>Invoice</th><th>Period</th><th>Amount</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in billingInvoices" :key="inv._id">
|
||||
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
|
||||
<td><Mono dim>{{ inv.periodEnd || inv.createdAt ? fmtAudit(inv.periodEnd || inv.createdAt || '') : '—' }}</Mono></td>
|
||||
<td><Mono>{{ Math.round(inv.amountDue / 100).toLocaleString('da-DK') }} {{ inv.currency }}</Mono></td>
|
||||
<td><Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge></td>
|
||||
</tr>
|
||||
<tr v-if="!billingInvoices.length">
|
||||
<td colspan="4" class="empty-cell"><Mono dim>// plan {{ tenant.plan }} · no invoices yet</Mono></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- AUDIT (mock) -->
|
||||
<!-- AUDIT (real · /api/audit?tenantSlug) -->
|
||||
<div v-else-if="activeTab === 'audit'">
|
||||
<Card :pad="0">
|
||||
<div class="card-head padded"><h2>Tenant-scoped audit</h2><Badge tone="warn">mock</Badge></div>
|
||||
<div class="card-head padded"><h2>Tenant-scoped audit</h2></div>
|
||||
<table class="rich">
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><Mono dim>15:02</Mono></td><td>Anne Baslund</td><td><Mono>tenant.plan_changed</Mono></td><td><Mono dim>{{ tenant.plan }} → enterprise</Mono></td></tr>
|
||||
<tr><td><Mono dim>14:18</Mono></td><td>system</td><td><Mono>alert.triggered</Mono></td><td><Mono dim>authentik p95 spike</Mono></td></tr>
|
||||
<tr><td><Mono dim>10:55</Mono></td><td>system</td><td><Mono>invoice.past_due</Mono></td><td><Mono dim>INV-0522 · 21 d</Mono></td></tr>
|
||||
<tr v-for="a in auditEvents" :key="a._id">
|
||||
<td><Mono dim>{{ fmtAudit(a.at) }}</Mono></td>
|
||||
<td>{{ a.actorEmail || 'system' }}</td>
|
||||
<td><Mono>{{ a.action }}</Mono></td>
|
||||
<td><Mono dim>{{ a.resourceName || a.resourceId || '—' }}</Mono></td>
|
||||
</tr>
|
||||
<tr v-if="!auditEvents.length">
|
||||
<td colspan="4" class="empty-cell"><Mono dim>// no audit events for this tenant yet</Mono></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user