@@ -1,49 +1,117 @@
< script setup lang = "ts" >
// Partner billing. Strict port of PartnerBillingScreen in pa rtner-screens.jsx
// (lines 691-838). Four tabs: Overview / Customer invoices / Margin & revenue
// / Payouts. Each tab numbers seeded to match the sour ce.
import { customers , partnerInvoices , partner } from '~/data/customers'
// Partner billing. Real data: aggregate billing across the po rtfolio (derived
// from active subscriptions + marginPct), customer invoices + payouts (synced
// from Stripe when live; empty in dev). Four tabs: Overview / Customer invoi ces
// / 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 === 's ent ' ) return 'warn'
if ( s === 'past_due' || s === 'uncollectible' ) return 'bad'
if ( s === 'op en' ) 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 40 px ; margin - top : 16 px ; }
. derived - note { padding : 8 px 40 px 0 ; display : flex ; align - items : center ; gap : 8 px ; }
. derived - note : deep ( svg ) { color : var ( -- text - mute ) ; }
. content { padding : 24 px 40 px 64 px ; display : flex ; flex - direction : column ; gap : 16 px ; }
. stat - strip { display : grid ; grid - template - columns : repeat ( 4 , 1 fr ) ; gap : 12 px ; }
. grid - 2 { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 16 px ; }
. card - head {
padding : 20 px 24 px ;
border - bottom : 1 px solid var ( -- border ) ;
}
. card - title {
font - family : var ( -- font - display ) ;
font - weight : 600 ;
font - size : 18 px ;
margin - top : 4 px ;
}
. card - head { padding : 20 px 24 px ; border - bottom : 1 px solid var ( -- border ) ; }
. card - title { font - family : var ( -- font - display ) ; font - weight : 600 ; font - size : 18 px ; margin - top : 4 px ; }
. sub { font - size : 13 px ; color : var ( -- text - mute ) ; margin : 6 px 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 : 40 px 0 ; }
. cust - cell { display : flex ; align - items : center ; gap : 12 px ; }
. cust - swatch { width : 24 px ; height : 24 px ; border - radius : 4 px ; flex - shrink : 0 ; }
. cust - name { font - size : 13 px ; font - weight : 500 ; }
. text - 13 { font - size : 13 px ; }
. mrr { font - family : var ( -- font - mono ) ; font - size : 12 px ; font - weight : 500 ; }
. cut { font - family : var ( -- font - mono ) ; font - size : 12 px ; color : var ( -- ok ) ; }
. mrr {
font - family : var ( -- font - mono ) ;
font - size : 12 px ;
font - weight : 500 ;
}
. cut {
font - family : var ( -- font - mono ) ;
font - size : 12 px ;
color : var ( -- ok ) ;
}
/* TTM chart */
. def { display : flex ; flex - direction : column ; gap : 10 px ; margin : 14 px 0 0 ; padding : 0 ; }
. def div { display : grid ; grid - template - columns : 160 px 1 fr ; gap : 12 px ; font - size : 13 px ; }
. def dt { color : var ( -- text - mute ) ; font - family : var ( -- font - mono ) ; font - size : 11 px ; }
@@ -289,14 +336,6 @@ const payouts = [
. ttm - chart { margin - top : 14 px ; }
. ttm - chart : deep ( svg ) { width : 100 % ; height : 120 px ; }
. ttm - foot {
display : flex ;
justify - content : space - between ;
margin - top : 16 px ;
}
. ttm - total {
margin - top : 20 px ;
padding - top : 20 px ;
border - top : 1 px solid var ( -- border ) ;
}
. ttm - foot { display : flex ; justify - content : space - between ; margin - top : 16 px ; }
. ttm - total { margin - top : 20 px ; padding - top : 20 px ; border - top : 1 px solid var ( -- border ) ; }
< / style >