Finance headline
Cash on hand, the month's net burn, the runway that burn leaves and the net margin on monthly revenue; reads financeStats().
Preview
import { CalendarClockIcon, FlameIcon, PercentIcon, WalletIcon } from "lucide-react"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type FinanceStat } from "../data"
// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
cash: <WalletIcon />,
burn: <FlameIcon />,
runway: <CalendarClockIcon />,
margin: <PercentIcon />,
}
function read(stat: FinanceStat): string {
if (stat.format === "percent") return formatPercent(stat.value, { maximumFractionDigits: 1 })
if (stat.format === "months") {
return Number.isFinite(stat.value)
? `${formatNumber(stat.value, { maximumFractionDigits: 0 })} months`
: "No burn"
}
return formatCurrency(stat.value, "USD", { maximumFractionDigits: 0 })
}
/** What is in the bank, what leaves it, how long that lasts, and the margin. */
export function FinanceStats({ stats }: { stats: FinanceStat[] }) {
return (
<StatCardGroup data-widget="widget-finance-overview-finance-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.key}
label={stat.label}
value={read(stat)}
description={stat.description}
icon={ICONS[stat.key]}
/>
))}
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-overview-finance-statsNeeds the @vibra registry in your components.json — set it up once.
Source
import { CalendarClockIcon, FlameIcon, PercentIcon, WalletIcon } from "lucide-react"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type FinanceStat } from "../data"
// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
cash: <WalletIcon />,
burn: <FlameIcon />,
runway: <CalendarClockIcon />,
margin: <PercentIcon />,
}
function read(stat: FinanceStat): string {
if (stat.format === "percent") return formatPercent(stat.value, { maximumFractionDigits: 1 })
if (stat.format === "months") {
return Number.isFinite(stat.value)
? `${formatNumber(stat.value, { maximumFractionDigits: 0 })} months`
: "No burn"
}
return formatCurrency(stat.value, "USD", { maximumFractionDigits: 0 })
}
/** What is in the bank, what leaves it, how long that lasts, and the margin. */
export function FinanceStats({ stats }: { stats: FinanceStat[] }) {
return (
<StatCardGroup data-widget="widget-finance-overview-finance-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.key}
label={stat.label}
value={read(stat)}
description={stat.description}
icon={ICONS[stat.key]}
/>
))}
</StatCardGroup>
)
}/**
* What /finance reads. The revenue and cost model lives in `costs.ts`; this
* file turns it into the page's figures — the P&L, the cash balance and the
* headline stats — and adds the receivables, which are `db.invoices` aged
* against REFERENCE_DATE. The one number no row can give is the cash balance
* the year opened on, so it comes from `seeded("dashboard-finance")` — once,
* at module scope.
*/
import { formatCurrency, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Invoice, type Member } from "@/lib/sample-data"
import { CLOSED_MONTH, DAY_MS, dollars, ledger, marketingBetween, monthStart } from "./costs"
// The page's header copy names the month the P&L closed on, which the cost
// model already worked out.
export { CLOSED_MONTH }
/** The month's profit and loss, revenue first and the net at the close. */
export function profitAndLoss(): { label: string; value: number; isTotal?: boolean }[] {
const { revenue, costs } = ledger()
return [
{ label: "Revenue", value: Math.round(revenue) },
...costs.map((cost) => ({ label: cost.label, value: -Math.round(cost.value) })),
{ label: "Net", value: 0, isTotal: true },
]
}
// The one figure no row can give: what the bank held twelve months ago. It is
// generated once from the block's own seed, so the balance is the same on every
// render and in every test, and every number after it is arithmetic over db.
const OPENING_CASH =
Math.round((400_000 + seeded("dashboard-finance")() * 150_000) / 25_000) * 25_000
const MONTHS_BACK = 12
/**
* Cash today: the opening balance plus twelve months of net. Revenue and the
* standing costs are flat month to month — they are the book, not a history —
* so only marketing moves, which is the one cost the rows actually date.
*/
function cashOnHand(): number {
const { revenue, payroll, hosting, tooling } = ledger()
return Array.from({ length: MONTHS_BACK }, (_, index) => {
const from = monthStart(MONTHS_BACK - index)
const to = monthStart(MONTHS_BACK - index - 1)
return revenue - (payroll + hosting + tooling + marketingBetween(from, to))
}).reduce((balance, net) => balance + net, OPENING_CASH)
}
export type FinanceStat = {
key: string
label: string
value: number
description: string
format: "currency" | "months" | "percent"
}
/** Cash, what it is going out at, how long that lasts, and the margin behind it. */
export function financeStats(): FinanceStat[] {
const { revenue, net } = ledger()
const cash = cashOnHand()
const burn = Math.max(0, -net)
return [
{
key: "cash",
label: "Cash on hand",
value: Math.round(cash),
description: `after ${MONTHS_BACK} months of trading`,
format: "currency",
},
{
key: "burn",
label: "Net burn",
value: Math.round(burn),
description: `a month, at ${CLOSED_MONTH}'s run rate`,
format: "currency",
},
{
key: "runway",
label: "Runway",
value: burn > 0 ? cash / burn : Infinity,
description: "at the same burn, with no new revenue",
format: "months",
},
{
key: "margin",
label: "Net margin",
value: net / revenue,
description: `on ${formatCurrency(revenue, "USD", { maximumFractionDigits: 0 })} of monthly revenue`,
format: "percent",
},
]
}
/** Ageing buckets: how far past its due date an unpaid invoice is. A voided invoice is not owed. */
const BUCKETS = ["Not due", "1–30 days", "31–60 days", "61–90 days", "Over 90 days"] as const
/** The three states an invoice is still money owed in. */
type Owed = "draft" | "open" | "overdue"
const OWED = ["draft", "open", "overdue"] as const
const isOwed = (status: Invoice["status"]): status is Owed =>
(OWED as readonly string[]).includes(status)
function bucketOf(invoice: Invoice): (typeof BUCKETS)[number] {
const overdueDays = (REFERENCE_DATE.getTime() - invoice.dueAt.getTime()) / DAY_MS
if (overdueDays <= 0) return BUCKETS[0]
if (overdueDays <= 30) return BUCKETS[1]
if (overdueDays <= 60) return BUCKETS[2]
if (overdueDays <= 90) return BUCKETS[3]
return BUCKETS[4]
}
export type AgingRow = { bucket: string; draft: number; open: number; overdue: number }
/** Everything still owed, by how late it is and what state the invoice is in. */
export function invoiceAging(): AgingRow[] {
const rows = new Map<string, AgingRow>(
BUCKETS.map((bucket) => [bucket, { bucket, draft: 0, open: 0, overdue: 0 }])
)
for (const invoice of db.invoices.all()) {
const status = invoice.status
if (!isOwed(status)) continue
const row = rows.get(bucketOf(invoice))
if (!row) continue
row[status] += dollars(invoice.amountCents)
}
return [...rows.values()].map((row) => ({
bucket: row.bucket,
draft: Math.round(row.draft),
open: Math.round(row.open),
overdue: Math.round(row.overdue),
}))
}
/** Everything still owed, for the copy beside the aging chart. */
export function owedTotal(): number {
return invoiceAging().reduce((total, row) => total + row.draft + row.open + row.overdue, 0)
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** Which month the page closed on. */
export function lastUpdated(): string {
return `${CLOSED_MONTH} close`
}/**
* 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