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
|
||||
<div>
|
||||
<PageHeader
|
||||
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."
|
||||
/>
|
||||
: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>
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -24,7 +24,8 @@
|
||||
"jose": "^5.9.0",
|
||||
"mongoose": "^8.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0"
|
||||
"rxjs": "^7.8.0",
|
||||
"stripe": "^17.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
|
||||
Generated
+79
@@ -44,6 +44,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.2
|
||||
stripe:
|
||||
specifier: ^17.5.0
|
||||
version: 17.7.0
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.0
|
||||
@@ -652,6 +655,10 @@ packages:
|
||||
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1272,6 +1279,10 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
@@ -1366,6 +1377,10 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
@@ -1469,6 +1484,22 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.0:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sift@17.1.3:
|
||||
resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==}
|
||||
|
||||
@@ -1523,6 +1554,10 @@ packages:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
stripe@17.7.0:
|
||||
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
|
||||
engines: {node: '>=12.*'}
|
||||
|
||||
strnum@2.3.0:
|
||||
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
|
||||
|
||||
@@ -2596,6 +2631,11 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
@@ -3202,6 +3242,8 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
@@ -3293,6 +3335,10 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
@@ -3382,6 +3428,34 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
sift@17.1.3: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
@@ -3433,6 +3507,11 @@ snapshots:
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
stripe@17.7.0:
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
qs: 6.15.2
|
||||
|
||||
strnum@2.3.0: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from './audit/audit.module.js'
|
||||
import { AuthModule } from './auth/auth.module.js'
|
||||
import { BillingModule } from './billing/billing.module.js'
|
||||
import { FlagsModule } from './flags/flags.module.js'
|
||||
import { HealthModule } from './health/health.module.js'
|
||||
import { IngestModule } from './ingest/ingest.module.js'
|
||||
@@ -30,6 +31,7 @@ import { UsersModule } from './users/users.module.js'
|
||||
SubscriptionsModule,
|
||||
PricesModule,
|
||||
FlagsModule,
|
||||
BillingModule,
|
||||
IngestModule,
|
||||
SeedModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { Invoice, InvoiceSchema } from '../schemas/invoice.schema.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { Payout, PayoutSchema } from '../schemas/payout.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
import { OperatorBillingController } from './operator-billing.controller.js'
|
||||
import { PartnerBillingController } from './partner-billing.controller.js'
|
||||
import { StripeWebhookController } from './stripe-webhook.controller.js'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Invoice.name, schema: InvoiceSchema },
|
||||
{ name: Payout.name, schema: PayoutSchema },
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: Partner.name, schema: PartnerSchema },
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
IntegrationsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
|
||||
providers: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import type Stripe from 'stripe'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { StripeClient } from '../integrations/stripe.client.js'
|
||||
import { Invoice, InvoiceDocument } from '../schemas/invoice.schema.js'
|
||||
import { Payout, PayoutDocument } from '../schemas/payout.schema.js'
|
||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { UsersService } from '../users/users.service.js'
|
||||
|
||||
type Currency = 'DKK' | 'EUR' | 'USD'
|
||||
|
||||
function toCurrency(c?: string): Currency {
|
||||
const up = (c ?? 'DKK').toUpperCase()
|
||||
return up === 'EUR' || up === 'USD' ? up : 'DKK'
|
||||
}
|
||||
|
||||
// Billing reads run on DERIVED data (Subscription + Price + marginPct + the
|
||||
// Invoice/Payout collections), so the billing pages show real numbers in dev
|
||||
// even with no live Stripe. The webhook populates Invoices/Subscription status
|
||||
// once Stripe is enabled.
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
|
||||
@InjectModel(Payout.name) private readonly payoutModel: Model<PayoutDocument>,
|
||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
||||
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
private readonly users: UsersService,
|
||||
private readonly stripe: StripeClient,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// ── Partner billing reads ────────────────────────────────────────────────
|
||||
async partnerSummary(partnerId: Types.ObjectId): Promise<{
|
||||
marginPct: number
|
||||
mrr: Array<{ currency: Currency; monthlyMinor: number; partnerCutMinor: number; netMinor: number }>
|
||||
customers: number
|
||||
openInvoices: number
|
||||
openAmountMinor: number
|
||||
stripeLive: boolean
|
||||
}> {
|
||||
const [mrr, partner] = await Promise.all([
|
||||
this.users.partnerMrr(partnerId),
|
||||
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
|
||||
])
|
||||
const marginPct = partner?.marginPct ?? 0
|
||||
const mrrRows = mrr.totals.map((t) => {
|
||||
const partnerCutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
|
||||
return {
|
||||
currency: t.currency,
|
||||
monthlyMinor: t.monthlyMinor,
|
||||
partnerCutMinor,
|
||||
netMinor: t.monthlyMinor - partnerCutMinor,
|
||||
}
|
||||
})
|
||||
const openInv = await this.invoiceModel
|
||||
.find({ partnerId, status: { $in: ['open', 'past_due'] } })
|
||||
.exec()
|
||||
const openAmountMinor = openInv.reduce((s, i) => s + (i.amountDue - i.amountPaid), 0)
|
||||
|
||||
return {
|
||||
marginPct,
|
||||
mrr: mrrRows,
|
||||
customers: mrr.breakdown.length,
|
||||
openInvoices: openInv.length,
|
||||
openAmountMinor,
|
||||
stripeLive: this.stripe.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
async partnerInvoices(partnerId: Types.ObjectId): Promise<InvoiceDocument[]> {
|
||||
return this.invoiceModel.find({ partnerId }).sort({ createdAt: -1 }).limit(100).exec()
|
||||
}
|
||||
|
||||
async partnerPayouts(partnerId: Types.ObjectId): Promise<PayoutDocument[]> {
|
||||
return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec()
|
||||
}
|
||||
|
||||
// ── Operator (platform-wide) billing reads ───────────────────────────────
|
||||
async platformSummary(): Promise<{
|
||||
invoicedMinor: number
|
||||
paidMinor: number
|
||||
outstandingMinor: number
|
||||
openInvoices: number
|
||||
pastDueInvoices: number
|
||||
stripeLive: boolean
|
||||
}> {
|
||||
const invoices = await this.invoiceModel.find().exec()
|
||||
let invoicedMinor = 0
|
||||
let paidMinor = 0
|
||||
let outstandingMinor = 0
|
||||
let openInvoices = 0
|
||||
let pastDueInvoices = 0
|
||||
for (const i of invoices) {
|
||||
invoicedMinor += i.amountDue
|
||||
paidMinor += i.amountPaid
|
||||
if (i.status === 'open' || i.status === 'past_due') {
|
||||
outstandingMinor += i.amountDue - i.amountPaid
|
||||
openInvoices++
|
||||
if (i.status === 'past_due') pastDueInvoices++
|
||||
}
|
||||
}
|
||||
return { invoicedMinor, paidMinor, outstandingMinor, openInvoices, pastDueInvoices, stripeLive: this.stripe.enabled }
|
||||
}
|
||||
|
||||
async platformInvoices(): Promise<InvoiceDocument[]> {
|
||||
return this.invoiceModel.find().sort({ createdAt: -1 }).limit(200).exec()
|
||||
}
|
||||
|
||||
async tenantInvoices(tenantId: Types.ObjectId): Promise<InvoiceDocument[]> {
|
||||
return this.invoiceModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
|
||||
}
|
||||
|
||||
async tenantInvoicesBySlug(slug: string): Promise<InvoiceDocument[]> {
|
||||
const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
|
||||
if (!tenant) return []
|
||||
return this.tenantInvoices(tenant._id)
|
||||
}
|
||||
|
||||
// ── Stripe webhook ────────────────────────────────────────────────────────
|
||||
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
|
||||
// when Stripe is enabled; no-op acknowledgement otherwise.
|
||||
async handleWebhookEvent(event: Stripe.Event, actor?: AuditActor): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'invoice.paid':
|
||||
case 'invoice.payment_failed':
|
||||
case 'invoice.finalized': {
|
||||
await this.upsertInvoiceFromStripe(event.data.object as Stripe.Invoice, event.type)
|
||||
break
|
||||
}
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted': {
|
||||
await this.applySubscriptionEvent(event.data.object as Stripe.Subscription, event.type)
|
||||
break
|
||||
}
|
||||
default:
|
||||
// Ignore unhandled event types.
|
||||
return
|
||||
}
|
||||
void this.audit.record(
|
||||
{
|
||||
action: `billing.${event.type.replace(/\./g, '_')}`,
|
||||
resourceType: 'subscription',
|
||||
resourceId: event.id,
|
||||
source: 'platform-api',
|
||||
externalId: event.id,
|
||||
},
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
private async upsertInvoiceFromStripe(inv: Stripe.Invoice, eventType: string): Promise<void> {
|
||||
const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id
|
||||
if (!customerId) return
|
||||
const sub = await this.subModel.findOne({ stripeCustomerId: customerId }).exec()
|
||||
if (!sub) {
|
||||
this.logger.warn(`Stripe invoice ${inv.id} for unknown customer ${customerId} — skipping`)
|
||||
return
|
||||
}
|
||||
const tenant = await this.tenantModel.findById(sub.tenantId, { partnerId: 1 }).exec()
|
||||
const status: InvoiceDocument['status'] =
|
||||
eventType === 'invoice.payment_failed'
|
||||
? 'past_due'
|
||||
: inv.status === 'paid'
|
||||
? 'paid'
|
||||
: ((inv.status as InvoiceDocument['status']) ?? 'open')
|
||||
|
||||
await this.invoiceModel
|
||||
.findOneAndUpdate(
|
||||
{ stripeInvoiceId: inv.id },
|
||||
{
|
||||
$set: {
|
||||
tenantId: sub.tenantId,
|
||||
partnerId: tenant?.partnerId,
|
||||
subscriptionId: sub._id,
|
||||
number: inv.number ?? undefined,
|
||||
currency: toCurrency(inv.currency),
|
||||
amountDue: inv.amount_due ?? 0,
|
||||
amountPaid: inv.amount_paid ?? 0,
|
||||
status,
|
||||
periodStart: inv.period_start ? new Date(inv.period_start * 1000) : undefined,
|
||||
periodEnd: inv.period_end ? new Date(inv.period_end * 1000) : undefined,
|
||||
hostedInvoiceUrl: inv.hosted_invoice_url ?? undefined,
|
||||
pdfUrl: inv.invoice_pdf ?? undefined,
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true },
|
||||
)
|
||||
.exec()
|
||||
}
|
||||
|
||||
private async applySubscriptionEvent(sub: Stripe.Subscription, eventType: string): Promise<void> {
|
||||
const set: Record<string, unknown> = {}
|
||||
if (eventType === 'customer.subscription.deleted') {
|
||||
set.status = 'canceled'
|
||||
set.canceledAt = new Date()
|
||||
} else {
|
||||
set.status = sub.status
|
||||
}
|
||||
const cpe = (sub as { current_period_end?: number }).current_period_end
|
||||
if (cpe) set.currentPeriodEnd = new Date(cpe * 1000)
|
||||
await this.subModel.updateOne({ stripeSubscriptionId: sub.id }, { $set: set }).exec()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Platform-wide billing reads. Operator-only.
|
||||
@Controller('billing')
|
||||
@UseGuards(JwtAuthGuard, OperatorGuard)
|
||||
export class OperatorBillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.billing.platformSummary()
|
||||
}
|
||||
|
||||
@Get('invoices')
|
||||
invoices() {
|
||||
return this.billing.platformInvoices()
|
||||
}
|
||||
|
||||
@Get('tenants/:slug/invoices')
|
||||
tenantInvoices(@Param('slug') slug: string) {
|
||||
return this.billing.tenantInvoicesBySlug(slug)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common'
|
||||
import { ActorService } from '../auth/actor.service.js'
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Partner-facing billing reads. Scoped to the caller's partnerId.
|
||||
@Controller('me/partner/billing')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerBillingController {
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly actor: ActorService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
async summary(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerSummary(actor.partnerId)
|
||||
}
|
||||
|
||||
@Get('invoices')
|
||||
async invoices(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerInvoices(actor.partnerId)
|
||||
}
|
||||
|
||||
@Get('payouts')
|
||||
async payouts(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerPayouts(actor.partnerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
type RawBodyRequest,
|
||||
} from '@nestjs/common'
|
||||
import { StripeClient } from '../integrations/stripe.client.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Stripe webhook receiver. NOT guarded (Stripe calls it) — authenticity comes
|
||||
// from signature verification against the raw body. No-ops when Stripe is
|
||||
// disabled so the route is safe to expose in dev.
|
||||
@Controller('stripe')
|
||||
export class StripeWebhookController {
|
||||
constructor(
|
||||
private readonly stripe: StripeClient,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
@Post('webhook')
|
||||
@HttpCode(200)
|
||||
async webhook(
|
||||
// Only req.rawBody is needed; the signature comes via @Headers. Typed
|
||||
// minimally to avoid a direct 'fastify' type import (transitive dep).
|
||||
@Req() req: RawBodyRequest<Record<string, unknown>>,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
if (!this.stripe.enabled || !this.stripe.hasWebhookSecret) {
|
||||
return { received: true, ignored: 'stripe disabled' }
|
||||
}
|
||||
if (!req.rawBody || !signature) {
|
||||
throw new BadRequestException('Missing raw body or stripe-signature header')
|
||||
}
|
||||
let event
|
||||
try {
|
||||
event = this.stripe.constructWebhookEvent(req.rawBody, signature)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid Stripe signature')
|
||||
}
|
||||
await this.billing.handleWebhookEvent(event)
|
||||
return { received: true }
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'
|
||||
import { AuthentikClient } from './authentik.client.js'
|
||||
import { OcisClient } from './ocis.client.js'
|
||||
import { StalwartClient } from './stalwart.client.js'
|
||||
import { StripeClient } from './stripe.client.js'
|
||||
|
||||
@Module({
|
||||
providers: [AuthentikClient, StalwartClient, OcisClient],
|
||||
exports: [AuthentikClient, StalwartClient, OcisClient],
|
||||
providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
|
||||
exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
// Thin wrapper around the Stripe SDK. Dark-launch friendly: the SDK is only
|
||||
// constructed lazily on first use, so the app boots fine in dev with no Stripe
|
||||
// keys. `enabled` gates the live money-moving paths (subscription create on
|
||||
// tenant create, webhook processing). When disabled, billing reads run on
|
||||
// DERIVED data (Subscription + Price + marginPct) instead of live Stripe.
|
||||
@Injectable()
|
||||
export class StripeClient {
|
||||
private readonly logger = new Logger(StripeClient.name)
|
||||
private readonly secretKey?: string
|
||||
private readonly webhookSecret?: string
|
||||
private _stripe?: Stripe
|
||||
readonly enabled: boolean
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
|
||||
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
|
||||
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
|
||||
if (!this.enabled) {
|
||||
this.logger.log(
|
||||
'Stripe billing disabled (BILLING_STRIPE_ENABLED != true or no key) — billing uses derived data.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
get hasWebhookSecret(): boolean {
|
||||
return !!this.webhookSecret
|
||||
}
|
||||
|
||||
// Lazy SDK accessor — throws only if actually used while unconfigured.
|
||||
private get stripe(): Stripe {
|
||||
if (!this.secretKey) {
|
||||
throw new Error('Stripe is not configured (STRIPE_SECRET_KEY missing)')
|
||||
}
|
||||
if (!this._stripe) this._stripe = new Stripe(this.secretKey)
|
||||
return this._stripe
|
||||
}
|
||||
|
||||
async createCustomer(input: {
|
||||
name: string
|
||||
email?: string
|
||||
metadata?: Record<string, string>
|
||||
}): Promise<string> {
|
||||
const c = await this.stripe.customers.create({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
return c.id
|
||||
}
|
||||
|
||||
async createSubscription(input: {
|
||||
customerId: string
|
||||
priceId: string
|
||||
quantity: number
|
||||
}): Promise<{ id: string; currentPeriodEnd?: number }> {
|
||||
const s = await this.stripe.subscriptions.create({
|
||||
customer: input.customerId,
|
||||
items: [{ price: input.priceId, quantity: input.quantity }],
|
||||
})
|
||||
return { id: s.id, currentPeriodEnd: (s as { current_period_end?: number }).current_period_end }
|
||||
}
|
||||
|
||||
async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise<void> {
|
||||
const sub = await this.stripe.subscriptions.retrieve(subscriptionId)
|
||||
const itemId = sub.items.data[0]?.id
|
||||
if (!itemId) throw new Error(`Stripe subscription ${subscriptionId} has no line items`)
|
||||
await this.stripe.subscriptions.update(subscriptionId, { items: [{ id: itemId, quantity }] })
|
||||
}
|
||||
|
||||
async cancelSubscription(subscriptionId: string): Promise<void> {
|
||||
await this.stripe.subscriptions.cancel(subscriptionId)
|
||||
}
|
||||
|
||||
async listInvoices(customerId: string, limit = 50): Promise<Stripe.Invoice[]> {
|
||||
const res = await this.stripe.invoices.list({ customer: customerId, limit })
|
||||
return res.data
|
||||
}
|
||||
|
||||
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
|
||||
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
|
||||
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ logger: true }),
|
||||
// rawBody exposes req.rawBody (Buffer) — needed for Stripe webhook
|
||||
// signature verification, which must hash the exact bytes Stripe sent.
|
||||
{ rawBody: true },
|
||||
)
|
||||
|
||||
app.enableCors({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type InvoiceDocument = HydratedDocument<Invoice>
|
||||
|
||||
// One customer invoice, synced from Stripe webhooks (invoice.paid /
|
||||
// payment_failed). `partnerId` is denormalized so the partner billing list is
|
||||
// one query. Amounts are in minor units. Empty in dev until Stripe is live.
|
||||
@Schema({ collection: 'invoices', timestamps: true })
|
||||
export class Invoice {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
|
||||
partnerId?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Subscription', sparse: true })
|
||||
subscriptionId?: Types.ObjectId
|
||||
|
||||
// Dedup key for webhook idempotency.
|
||||
@Prop({ unique: true, sparse: true, index: true })
|
||||
stripeInvoiceId?: string
|
||||
|
||||
@Prop({ trim: true })
|
||||
number?: string
|
||||
|
||||
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK' })
|
||||
currency!: 'DKK' | 'EUR' | 'USD'
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
amountDue!: number
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
amountPaid!: number
|
||||
|
||||
@Prop({
|
||||
enum: ['draft', 'open', 'paid', 'void', 'uncollectible', 'past_due'],
|
||||
default: 'open',
|
||||
index: true,
|
||||
})
|
||||
status!: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible' | 'past_due'
|
||||
|
||||
@Prop()
|
||||
periodStart?: Date
|
||||
|
||||
@Prop()
|
||||
periodEnd?: Date
|
||||
|
||||
@Prop()
|
||||
hostedInvoiceUrl?: string
|
||||
|
||||
@Prop()
|
||||
pdfUrl?: string
|
||||
}
|
||||
|
||||
export const InvoiceSchema = SchemaFactory.createForClass(Invoice)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type PayoutDocument = HydratedDocument<Payout>
|
||||
|
||||
// Partner-cut payout ledger. v1 is a COMPUTED ledger (not Stripe Connect):
|
||||
// Dezky bills customers, the partner earns marginPct; a job aggregates paid
|
||||
// invoices per partner per month into these records. Disbursement is out-of-band
|
||||
// in v1 — these rows are the ledger of what's owed/paid.
|
||||
@Schema({ collection: 'payouts', timestamps: true })
|
||||
export class Payout {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true })
|
||||
partnerId!: Types.ObjectId
|
||||
|
||||
// YYYY-MM the payout covers.
|
||||
@Prop({ required: true, index: true })
|
||||
periodMonth!: string
|
||||
|
||||
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK' })
|
||||
currency!: 'DKK' | 'EUR' | 'USD'
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
grossMrrMinor!: number
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
marginPct!: number
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
payoutMinor!: number
|
||||
|
||||
@Prop({ enum: ['pending', 'paid'], default: 'pending', index: true })
|
||||
status!: 'pending' | 'paid'
|
||||
|
||||
@Prop()
|
||||
paidAt?: Date
|
||||
}
|
||||
|
||||
export const PayoutSchema = SchemaFactory.createForClass(Payout)
|
||||
@@ -38,6 +38,22 @@ export class Price {
|
||||
USD?: number
|
||||
}
|
||||
|
||||
// Per-currency Stripe Price IDs, mapping this catalog row to Stripe. Seeded
|
||||
// when Stripe billing is enabled; absent in dev (derived billing).
|
||||
@Prop({
|
||||
type: {
|
||||
DKK: { type: String },
|
||||
EUR: { type: String },
|
||||
USD: { type: String },
|
||||
},
|
||||
default: () => ({}),
|
||||
})
|
||||
stripePriceIds?: {
|
||||
DKK?: string
|
||||
EUR?: string
|
||||
USD?: string
|
||||
}
|
||||
|
||||
// Soft-active flag. When the operator changes a row's amounts we mutate
|
||||
// the row in place; deactivation is for cases like end-of-life'd plans
|
||||
// where we want to preserve subs' priceId references without making the
|
||||
|
||||
Reference in New Issue
Block a user