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
+130 -7
View File
@@ -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>
+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>
@@ -0,0 +1,4 @@
import { platformApi } from '~~/server/utils/platform-api'
// Platform-wide invoices. Operator-only.
export default defineEventHandler(async (event) => platformApi(event, '/billing/invoices'))
@@ -0,0 +1,4 @@
import { platformApi } from '~~/server/utils/platform-api'
// Platform-wide billing summary. Operator-only.
export default defineEventHandler(async (event) => platformApi(event, '/billing/summary'))
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
// Invoices for a single tenant (tenant-detail billing tab). Operator-only.
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
return platformApi(event, `/billing/tenants/${slug}/invoices`)
})
+157 -118
View File
@@ -1,49 +1,117 @@
<script setup lang="ts">
// Partner billing. Strict port of PartnerBillingScreen in partner-screens.jsx
// (lines 691-838). Four tabs: Overview / Customer invoices / Margin & revenue
// / Payouts. Each tab numbers seeded to match the source.
import { customers, partnerInvoices, partner } from '~/data/customers'
// 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')
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'invoices', label: 'Customer invoices', count: 47 },
{ value: 'margin', label: 'Margin & revenue' },
{ value: 'payouts', label: 'Payouts', count: 12 },
]
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
default: return { tone: 'neutral', label: s }
}
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') return 'bad'
if (s === 'sent') return 'warn'
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' }) : '—'
}
// 52-week revenue series for Margin & revenue tab (deterministic).
// Decorative trailing-twelve sparkline (no historical MRR store yet).
const revenueSeries = Array.from({ length: 52 }, (_, i) => 8000 + i * 180 + Math.sin(i / 3) * 600)
const payouts = [
{ period: 'May 2026', amt: '11.150,00', paid: '—', ref: 'pending', status: 'pending' as const },
{ period: 'April 2026', amt: '10.520,00', paid: '03 May 2026', ref: 'TR-29841', status: 'paid' as const },
{ period: 'March 2026', amt: '9.840,00', paid: '03 Apr 2026', ref: 'TR-29402', status: 'paid' as const },
{ period: 'Feb 2026', amt: '9.180,00', paid: '03 Mar 2026', ref: 'TR-28977', status: 'paid' as const },
]
</script>
<template>
@@ -65,21 +133,18 @@ const payouts = [
<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="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. last month" />
</Card>
<Card>
<Stat :label="`Partner cut · ${partner.marginPct}%`" value="11.150 DKK" delta="+19.0%" delta-tone="up" />
</Card>
<Card>
<Stat label="Net to Dezky" value="44.600 DKK" hint="monthly" />
</Card>
<Card>
<Stat label="Open A/R" value="2.940 DKK" hint="1 customer past-due" delta-tone="down" />
</Card>
<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">
@@ -96,12 +161,12 @@ const payouts = [
<th>Plan</th>
<th>Seats</th>
<th class="num">MRR</th>
<th class="num">Partner cut ({{ partner.marginPct }}%)</th>
<th class="num">Partner cut ({{ marginPct }}%)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<tr v-for="c in breakdown" :key="c.id">
<td>
<div class="cust-cell">
<div class="cust-swatch" :style="{ background: c.brandColor }" />
@@ -112,13 +177,12 @@ const payouts = [
</div>
</td>
<td><Badge tone="neutral">{{ c.planLabel }}</Badge></td>
<td><Mono>{{ c.seats.used }}</Mono></td>
<td class="num"><span class="mrr">{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</span></td>
<td class="num"><span class="cut">{{ Math.round(c.mrrDkk * partner.marginPct / 100).toLocaleString('da-DK') }} DKK</span></td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</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>
@@ -139,21 +203,22 @@ const payouts = [
</tr>
</thead>
<tbody>
<tr v-for="inv in partnerInvoices" :key="inv.id">
<td><Mono>{{ inv.number }}</Mono></td>
<td><span class="cust-name">{{ inv.customer }}</span></td>
<td><span class="text-13">{{ inv.date }}</span></td>
<td class="num"><Mono>{{ inv.amount.toLocaleString('da-DK') }} DKK</Mono></td>
<td>
<Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge>
</td>
<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">
<UiButton size="sm" variant="ghost" @click="toast.info('Downloading PDF', inv.number)">
<template #leading><UiIcon name="download" :size="13" /></template>
PDF
</UiButton>
<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>
@@ -165,28 +230,27 @@ const payouts = [
<Card>
<Eyebrow>Margin</Eyebrow>
<div class="card-title">Your reseller margin</div>
<p class="sub">Per your agreement with Dezky · 20% gross on all customer revenue.</p>
<p class="sub">Per your agreement with Dezky · {{ marginPct }}% gross on customer revenue.</p>
<dl class="def">
<div><dt>Starter plan</dt><dd>20% · 9,80 DKK per seat / mo</dd></div>
<div><dt>Business plan</dt><dd>20% · 25,80 DKK per seat / mo</dd></div>
<div><dt>Enterprise plan</dt><dd>15% · negotiated per customer</dd></div>
<div><dt>Add-ons</dt><dd>Pass-through · 0%</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
<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 · 12 months</Eyebrow>
<div class="card-title">Trailing twelve</div>
<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>Jun 2025 · 8.180 DKK</Mono>
<Mono dim>May 2026 · 11.150 DKK</Mono>
<Mono dim>trend · illustrative</Mono>
<Mono dim>{{ dkk(mrrMajor) }} DKK / mo now</Mono>
</div>
<div class="ttm-total">
<Stat label="Total · 12 months" value="118.940 DKK" delta="+36% YoY" delta-tone="up" />
<Stat label="Annualized partner cut" :value="`${dkk(cutMajor * 12)} DKK`" hint="current run-rate × 12" />
</div>
</Card>
</div>
@@ -201,19 +265,18 @@ const payouts = [
<th>Period</th>
<th class="num">Amount</th>
<th>Paid on</th>
<th>Reference</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="p in payouts" :key="p.period">
<td><span class="cust-name">{{ p.period }}</span></td>
<td class="num"><Mono>{{ p.amt }} DKK</Mono></td>
<td><Mono>{{ p.paid }}</Mono></td>
<td><Mono dim>{{ p.ref }}</Mono></td>
<td>
<Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge>
</td>
<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>
@@ -224,22 +287,16 @@ const payouts = [
<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;
}
.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; }
@@ -263,25 +320,15 @@ const payouts = [
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); }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.cut {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ok);
}
/* TTM chart */
.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; }
@@ -289,14 +336,6 @@ const payouts = [
.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);
}
.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>
@@ -0,0 +1,19 @@
// Partner customer invoices. Forwards to platform-api GET /me/partner/billing/invoices.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/billing/invoices`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,19 @@
// Partner payouts. Forwards to platform-api GET /me/partner/billing/payouts.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/billing/payouts`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,19 @@
// Partner billing summary. Forwards to platform-api GET /me/partner/billing/summary.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/billing/summary`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})