Skip to contentVibraUI

Invoices aging

Everything still owed, stacked as draft, open and overdue by how far past its due date it is, with the outstanding total; reads invoiceAging().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-overview-invoice-aging

Needs the @vibra registry in your components.json — set it up once.

Source

app/finance/components/invoice-aging.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { chartTone } from "@/components/ui/chart-core"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"

import { type AgingRow } from "../data"

// Overdue is the reading the chart exists for, so it takes the danger token
// rather than a palette hue. The two that are not yet late take hues that could
// not be mistaken for it — a second red would read as a second alarm.
const SERIES = [
  { key: "open", label: "Open", color: "chart-1" as const },
  { key: "draft", label: "Draft", color: "chart-3" as const },
  { key: "overdue", label: "Overdue", color: chartTone("negative") },
]

const money = (value: number) =>
  formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })

export type InvoiceAgingProps = {
  rows: AgingRow[]
  owed: number
}

/** Everything still owed, by how late it is. */
export function InvoiceAging({ rows, owed }: InvoiceAgingProps) {
  return (
    <ChartCard
      data-widget="widget-finance-overview-invoice-aging"
      title="Invoices aging"
      description="What is still owed, by how far past its due date it is"
      height={300}
      className="h-full"
      footer={`${formatCurrency(owed, "USD", { maximumFractionDigits: 0 })} outstanding`}
    >
      <StackedBarChart
        data={rows}
        index="bucket"
        series={SERIES}
        height={300}
        showYAxis
        valueFormatter={money}
      />
    </ChartCard>
  )
}
app/finance/data.ts
/**
 * 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`
}
app/finance/costs.ts
/**
 * 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