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:
Ronni Baslund
2026-05-30 08:03:23 +02:00
parent 6370e392cc
commit 0e1d2fb0d1
23 changed files with 1064 additions and 143 deletions
+65 -15
View File
@@ -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 &amp; 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 &amp; 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>