Files
dezky/apps/portal/pages/partner/billing.vue
T
Ronni Baslund 0e1d2fb0d1 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.
2026-05-30 08:03:23 +02:00

342 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Partner billing. Real data: aggregate billing across the portfolio (derived
// from active subscriptions + marginPct), customer invoices + payouts (synced
// from Stripe when live; empty in dev). Four tabs: Overview / Customer invoices
// / Margin & revenue / Payouts.
const toast = useToast()
const billingLive = useFeatureFlag('new_billing_engine')
const tab = ref<'overview' | 'invoices' | 'margin' | 'payouts'>('overview')
interface BillingSummary {
marginPct: number
mrr: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; partnerCutMinor: number; netMinor: number }>
customers: number
openInvoices: number
openAmountMinor: number
stripeLive: boolean
}
interface BillingInvoice {
_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 BillingPayout {
_id: string
periodMonth: string
currency: 'DKK' | 'EUR' | 'USD'
payoutMinor: number
status: 'pending' | 'paid'
paidAt?: string
}
const { data: summary } = useFetch<BillingSummary>('/api/partner/billing/summary', {
key: 'partner-billing-summary',
default: () => ({ marginPct: 0, mrr: [], customers: 0, openInvoices: 0, openAmountMinor: 0, stripeLive: false }),
})
const { data: invoices } = useFetch<BillingInvoice[]>('/api/partner/billing/invoices', {
key: 'partner-billing-invoices',
default: () => [],
})
const { data: payouts } = useFetch<BillingPayout[]>('/api/partner/billing/payouts', {
key: 'partner-billing-payouts',
default: () => [],
})
const { tenants } = usePartnerTenants()
const { mrrByTenant } = usePartnerMrr()
const tabs = computed(() => [
{ value: 'overview', label: 'Overview' },
{ value: 'invoices', label: 'Customer invoices', count: invoices.value?.length ?? 0 },
{ value: 'margin', label: 'Margin & revenue' },
{ value: 'payouts', label: 'Payouts', count: payouts.value?.length ?? 0 },
])
const marginPct = computed(() => summary.value?.marginPct ?? 0)
const sumMinor = (sel: (r: BillingSummary['mrr'][number]) => number) =>
Math.round((summary.value?.mrr ?? []).reduce((s, r) => s + sel(r), 0) / 100)
const mrrMajor = computed(() => sumMinor((r) => r.monthlyMinor))
const cutMajor = computed(() => sumMinor((r) => r.partnerCutMinor))
const netMajor = computed(() => sumMinor((r) => r.netMinor))
const openArMajor = computed(() => Math.round((summary.value?.openAmountMinor ?? 0) / 100))
const dkk = (n: number) => n.toLocaleString('da-DK')
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
const tenantName = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.name ?? id
// Per-customer revenue breakdown (overview) from real tenants + MRR.
const breakdown = computed(() =>
(tenants.value ?? [])
.filter((t) => t.status !== 'deleted')
.map((t) => {
const sub = mrrByTenant.value.get(t._id)
const mrr = sub ? Math.round(sub.monthlyMinor / 100) : 0
return {
id: t._id,
name: t.name,
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
brandColor: t.brandColor || '#3F6BFF',
planLabel: PLAN_LABEL[t.plan ?? 'pro'],
seats: t.userCount ?? 0,
mrrDkk: mrr,
currency: sub?.currency ?? 'DKK',
cut: Math.round((mrr * marginPct.value) / 100),
status: t.status,
}
}),
)
function tStatusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'neutral'; label: string } {
if (s === 'active') return { tone: 'ok', label: 'active' }
if (s === 'pending') return { tone: 'warn', label: 'pending' }
if (s === 'suspended') return { tone: 'bad', label: 'suspended' }
return { tone: 'neutral', label: s }
}
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', year: 'numeric' }) : '—'
}
// Decorative trailing-twelve sparkline (no historical MRR store yet).
const revenueSeries = Array.from({ length: 52 }, (_, i) => 8000 + i * 180 + Math.sin(i / 3) * 600)
</script>
<template>
<div>
<PageHeader
eyebrow="Commercial"
title="Partner billing"
subtitle="Aggregate billing across your customer portfolio, margins, and payouts from Dezky."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting', 'PDF compiled')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
</template>
</PageHeader>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div v-if="!summary?.stripeLive" class="derived-note">
<UiIcon name="shield" :size="13" />
<Mono dim>Figures derived from active subscriptions · Stripe not connected{{ billingLive ? ' · billing engine flag on' : '' }}</Mono>
</div>
<!-- OVERVIEW -->
<div v-if="tab === 'overview'" class="content">
<div class="stat-strip">
<Card><Stat label="MRR · portfolio" :value="`${dkk(mrrMajor)} DKK`" hint="across all customers" /></Card>
<Card><Stat :label="`Partner cut · ${marginPct}%`" :value="`${dkk(cutMajor)} DKK`" delta-tone="up" /></Card>
<Card><Stat label="Net to Dezky" :value="`${dkk(netMajor)} DKK`" hint="monthly" /></Card>
<Card><Stat label="Open A/R" :value="`${dkk(openArMajor)} DKK`" :hint="`${summary?.openInvoices ?? 0} open invoice(s)`" :delta-tone="openArMajor > 0 ? 'down' : undefined" /></Card>
</div>
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Per customer · this month</Eyebrow>
<div class="card-title">Revenue breakdown</div>
</div>
</div>
<table class="dtable">
<thead>
<tr>
<th>Customer</th>
<th>Plan</th>
<th>Seats</th>
<th class="num">MRR</th>
<th class="num">Partner cut ({{ marginPct }}%)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="c in breakdown" :key="c.id">
<td>
<div class="cust-cell">
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div>
<div class="cust-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
</div>
</td>
<td><Badge tone="neutral">{{ c.planLabel }}</Badge></td>
<td><Mono>{{ c.seats }}</Mono></td>
<td class="num"><span class="mrr">{{ c.mrrDkk > 0 ? `${dkk(c.mrrDkk)} ${c.currency}` : '—' }}</span></td>
<td class="num"><span class="cut">{{ c.cut > 0 ? `${dkk(c.cut)} ${c.currency}` : '—' }}</span></td>
<td><Badge :tone="tStatusBadge(c.status).tone" dot>{{ tStatusBadge(c.status).label }}</Badge></td>
</tr>
<tr v-if="!breakdown.length"><td colspan="6" class="empty">No customers yet.</td></tr>
</tbody>
</table>
</Card>
</div>
<!-- CUSTOMER INVOICES -->
<div v-else-if="tab === 'invoices'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Date</th>
<th class="num">Amount</th>
<th>Status</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv._id">
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
<td><span class="cust-name">{{ tenantName(inv.tenantId) }}</span></td>
<td><span class="text-13">{{ fmtDate(inv.periodEnd || inv.createdAt) }}</span></td>
<td class="num"><Mono>{{ dkk(Math.round(inv.amountDue / 100)) }} {{ inv.currency }}</Mono></td>
<td><Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge></td>
<td class="action-col">
<a v-if="inv.pdfUrl || inv.hostedInvoiceUrl" :href="inv.pdfUrl || inv.hostedInvoiceUrl" target="_blank" rel="noopener">
<UiButton size="sm" variant="ghost"><template #leading><UiIcon name="download" :size="13" /></template>PDF</UiButton>
</a>
<Mono v-else dim>—</Mono>
</td>
</tr>
<tr v-if="!invoices.length">
<td colspan="6" class="empty">No invoices yet{{ summary?.stripeLive ? '' : ' — invoices appear once Stripe billing is connected' }}.</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- MARGIN & REVENUE -->
<div v-else-if="tab === 'margin'" class="content">
<div class="grid-2">
<Card>
<Eyebrow>Margin</Eyebrow>
<div class="card-title">Your reseller margin</div>
<p class="sub">Per your agreement with Dezky · {{ marginPct }}% gross on customer revenue.</p>
<dl class="def">
<div><dt>Gross margin</dt><dd>{{ marginPct }}% on all plans</dd></div>
<div><dt>Monthly partner cut</dt><dd>{{ dkk(cutMajor) }} DKK</dd></div>
<div><dt>Net to Dezky</dt><dd>{{ dkk(netMajor) }} DKK / mo</dd></div>
<div><dt>Customers</dt><dd>{{ summary?.customers ?? 0 }}</dd></div>
</dl>
</Card>
<Card>
<Eyebrow>Revenue · annualized</Eyebrow>
<div class="card-title">Run-rate</div>
<div class="ttm-chart">
<PartnerSparkline :values="revenueSeries" :width="420" :height="120" stroke="var(--text)" fill="var(--row-hover)" />
</div>
<div class="ttm-foot">
<Mono dim>trend · illustrative</Mono>
<Mono dim>{{ dkk(mrrMajor) }} DKK / mo now</Mono>
</div>
<div class="ttm-total">
<Stat label="Annualized partner cut" :value="`${dkk(cutMajor * 12)} DKK`" hint="current run-rate × 12" />
</div>
</Card>
</div>
</div>
<!-- PAYOUTS -->
<div v-else-if="tab === 'payouts'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Period</th>
<th class="num">Amount</th>
<th>Paid on</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="p in payouts" :key="p._id">
<td><span class="cust-name">{{ p.periodMonth }}</span></td>
<td class="num"><Mono>{{ dkk(Math.round(p.payoutMinor / 100)) }} {{ p.currency }}</Mono></td>
<td><Mono>{{ fmtDate(p.paidAt) }}</Mono></td>
<td><Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge></td>
</tr>
<tr v-if="!payouts.length">
<td colspan="4" class="empty">No payouts recorded yet.</td>
</tr>
</tbody>
</table>
</Card>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.derived-note { padding: 8px 40px 0; display: flex; align-items: center; gap: 8px; }
.derived-note :deep(svg) { color: var(--text-mute); }
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card-head { padding: 20px 24px; border-bottom: 1px solid var(--border); }
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin-top: 4px; }
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.dtable { width: 100%; border-collapse: collapse; }
.dtable th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dtable th.num, .dtable td.num { text-align: right; }
.dtable th.action-col, .dtable td.action-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr:hover { background: var(--row-hover); }
.dtable .empty { text-align: center; color: var(--text-mute); padding: 40px 0; }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.text-13 { font-size: 13px; }
.mrr { font-family: var(--font-mono); font-size: 12px; font-weight: 500; }
.cut { font-family: var(--font-mono); font-size: 12px; color: var(--ok); }
.def { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.ttm-chart { margin-top: 14px; }
.ttm-chart :deep(svg) { width: 100%; height: 120px; }
.ttm-foot { display: flex; justify-content: space-between; margin-top: 16px; }
.ttm-total { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
</style>