Upcoming payouts
What leaves the account next, soonest first — the payroll run, hosting, tools and the live ad accounts — and what that adds up to; reads upcomingPayouts().
Preview
"use client"
import { formatCurrency, formatDate } from "@/lib/format"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Widget } from "@/components/ui/widget"
import { type Payout } from "../costs"
const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
/** How a due date reads on the list — fixed to UTC, so it is the same day for every reader. */
const dueLabel = (due: Date) => formatDate(due, "medium", { timeZone: "UTC" })
/** What leaves the account next, soonest first. */
export function UpcomingPayouts({ payouts }: { payouts: Payout[] }) {
const total = payouts.reduce((sum, payout) => sum + payout.amount, 0)
return (
<Widget
data-widget="widget-finance-overview-upcoming-payouts"
title="Upcoming payouts"
description="Standing runs and the live ad accounts, soonest first"
className="h-full"
footer={`${money(total)} leaving over the next month`}
>
<DataList divided>
{payouts.map((payout) => (
<DataListItem
key={payout.id}
title={payout.title}
description={payout.description}
meta={
<span className="flex flex-col items-end gap-0.5">
<span className="font-medium tabular-nums">{money(payout.amount)}</span>
<span className="text-xs text-muted-foreground">{dueLabel(payout.due)}</span>
</span>
}
/>
))}
</DataList>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-overview-upcoming-payoutsNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatCurrency, formatDate } from "@/lib/format"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Widget } from "@/components/ui/widget"
import { type Payout } from "../costs"
const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
/** How a due date reads on the list — fixed to UTC, so it is the same day for every reader. */
const dueLabel = (due: Date) => formatDate(due, "medium", { timeZone: "UTC" })
/** What leaves the account next, soonest first. */
export function UpcomingPayouts({ payouts }: { payouts: Payout[] }) {
const total = payouts.reduce((sum, payout) => sum + payout.amount, 0)
return (
<Widget
data-widget="widget-finance-overview-upcoming-payouts"
title="Upcoming payouts"
description="Standing runs and the live ad accounts, soonest first"
className="h-full"
footer={`${money(total)} leaving over the next month`}
>
<DataList divided>
{payouts.map((payout) => (
<DataListItem
key={payout.id}
title={payout.title}
description={payout.description}
meta={
<span className="flex flex-col items-end gap-0.5">
<span className="font-medium tabular-nums">{money(payout.amount)}</span>
<span className="text-xs text-muted-foreground">{dueLabel(payout.due)}</span>
</span>
}
/>
))}
</DataList>
</Widget>
)
}/**
* What the month costs, and what it earns. Revenue is the book — the MRR on
* `db.customers` that still bills. Every cost is a rule over rows rather than
* an invented line: payroll is the active `db.members` priced by role,
* marketing is `db.campaigns` spend spread over the days each one ran, hosting
* is per active seat, and tooling is per head. The accounting month is the
* last closed one, and `data.ts` builds the page's figures on top of this.
*/
import { db, REFERENCE_DATE, type Customer, type Member } from "@/lib/sample-data"
export const DAY_MS = 86_400_000
export const dollars = (cents: number): number => cents / 100
/** Midnight UTC on the first of the month `k` months before the current one. */
export function monthStart(k: number): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}
const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** The month the P&L closed on. */
export const CLOSED_MONTH = `${MONTH_NAME.format(LAST_MONTH)} ${LAST_MONTH.getUTCFullYear()}`
// What a seat on the team costs a month, fully loaded — salary, tax and
// benefits. A pay band is a business decision, written down like the pricing
// tiers are, and payroll is that band applied to the members who are active.
const MONTHLY_COST: Record<Member["role"], number> = {
owner: 18_000,
admin: 15_000,
member: 12_500,
viewer: 7_500,
}
// The two unit costs behind the smaller lines: what a seat of product costs to
// host for a month, and what a desk costs in tools.
const HOSTING_PER_SEAT = 2.4
const TOOLING_PER_HEAD = 220
/**
* A campaign's spend inside a window. Spend is a single figure for a run of
* days, so it is spread evenly over them and only the overlap is counted — a
* campaign that ran a fortnight into the month contributes a fortnight of it.
*/
export function marketingBetween(from: Date, to: Date): number {
let total = 0
for (const campaign of db.campaigns.all()) {
const ran = campaign.endedAt ?? REFERENCE_DATE
const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
const overlapFrom = Math.max(from.getTime(), campaign.startedAt.getTime())
const overlapTo = Math.min(to.getTime(), ran.getTime())
if (overlapTo <= overlapFrom) continue
total += dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
}
return total
}
/** The closed month's book and what it cost, as the rows stand. */
export type Ledger = {
/** The book: what every account that still bills pays in a month. */
revenue: number
payroll: number
marketing: number
hosting: number
tooling: number
costs: { label: string; value: number }[]
net: number
/** The members on the payroll, and the accounts the book is made of. */
team: Member[]
billing: Customer[]
}
/**
* The month's revenue and costs, read per call and never held at module
* scope: an account imported or a teammate who leaves since the server
* started moves the next render's figures.
*/
export function ledger(): Ledger {
const team = db.members.all().filter((member) => member.status === "active")
const billing = db.customers.all().filter((customer) => customer.mrrCents > 0)
const revenue = dollars(billing.reduce((total, customer) => total + customer.mrrCents, 0))
const payroll = team.reduce((total, member) => total + MONTHLY_COST[member.role], 0)
const hosting = billing.reduce((total, customer) => total + customer.seats, 0) * HOSTING_PER_SEAT
const tooling = team.length * TOOLING_PER_HEAD
const marketing = marketingBetween(LAST_MONTH, THIS_MONTH)
const costs = [
{ label: "Payroll", value: payroll },
{ label: "Marketing", value: marketing },
{ label: "Hosting", value: hosting },
{ label: "Tooling", value: tooling },
]
const spend = costs.reduce((total, cost) => total + cost.value, 0)
return { revenue, payroll, marketing, hosting, tooling, costs, net: revenue - spend, team, billing }
}
const ROLE_LABELS: Record<Member["role"], string> = {
owner: "Founders",
admin: "Leads",
member: "Team",
viewer: "Contractors",
}
const CHANNEL_LABELS: Record<string, string> = {
search: "Search",
social: "Social",
email: "Email",
referral: "Referral",
organic: "Organic",
}
/** Where the month's money went, payroll and marketing broken out by their parts. */
export function expensesByCategory() {
const { team, payroll, marketing, hosting, tooling } = ledger()
const byRole = new Map<Member["role"], number>()
for (const member of team) {
byRole.set(member.role, (byRole.get(member.role) ?? 0) + MONTHLY_COST[member.role])
}
const byChannel = new Map<string, number>()
for (const campaign of db.campaigns.all()) {
const ran = campaign.endedAt ?? REFERENCE_DATE
const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
const overlapFrom = Math.max(LAST_MONTH.getTime(), campaign.startedAt.getTime())
const overlapTo = Math.min(THIS_MONTH.getTime(), ran.getTime())
if (overlapTo <= overlapFrom) continue
const share = dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
byChannel.set(campaign.channel, (byChannel.get(campaign.channel) ?? 0) + share)
}
return [
{
name: "Payroll",
value: Math.round(payroll),
children: [...byRole]
.map(([role, value]) => ({ name: ROLE_LABELS[role], value: Math.round(value) }))
.sort((a, b) => b.value - a.value),
},
{
name: "Marketing",
value: Math.round(marketing),
children: [...byChannel]
.map(([channel, value]) => ({
name: CHANNEL_LABELS[channel] ?? channel,
value: Math.round(value),
}))
.sort((a, b) => b.value - a.value),
},
{ name: "Hosting", value: Math.round(hosting) },
{ name: "Tooling", value: Math.round(tooling) },
]
}
export type Payout = {
id: string
title: string
description: string
amount: number
due: Date
}
// Day 0 of the next month is the last day of this one.
const MONTH_END = new Date(
Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0)
)
const NEXT_MONTH = monthStart(-1)
/** What leaves the account next: the standing runs, then the live ad accounts. */
export function upcomingPayouts(): Payout[] {
const { team, billing, payroll, hosting, tooling } = ledger()
const live = db.campaigns
.all()
.filter((campaign) => campaign.status === "active")
.map((campaign) => {
const ran = campaign.endedAt ?? REFERENCE_DATE
const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
return {
id: campaign.id,
title: campaign.name,
description: `${CHANNEL_LABELS[campaign.channel] ?? campaign.channel} · one month of spend`,
amount: Math.round((dollars(campaign.spendCents) / days) * 30),
due: new Date(Date.UTC(NEXT_MONTH.getUTCFullYear(), NEXT_MONTH.getUTCMonth(), 15)),
}
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 3)
return [
{
id: "payroll",
title: "Payroll run",
description: `${team.length} people on the books`,
amount: Math.round(payroll),
due: MONTH_END,
},
{
id: "hosting",
title: "Cloud hosting",
description: `${billing.reduce((seats, customer) => seats + customer.seats, 0)} billed seats`,
amount: Math.round(hosting),
due: NEXT_MONTH,
},
{
id: "tooling",
title: "Software and tools",
description: `${team.length} desks`,
amount: Math.round(tooling),
due: NEXT_MONTH,
},
...live,
].sort((a, b) => a.due.getTime() - b.due.getTime())
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Finance dashboard page