Skip to contentVibraUI

Monthly recurring revenue

The closing book counted up, its change on the month and on the year, the customer count, and ARPU, gross churn, lifetime value and gross retention beside it; reads revenueHeadline().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-revenue-revenue-hero

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

Source

app/saas/revenue/components/revenue-hero.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { BigNumber } from "@/components/ui/big-number"
import { Card, CardContent } from "@/components/ui/card"
import { MetricDelta } from "@/components/ui/metric-delta"
import { StatList } from "@/components/ui/stat-list"

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

/**
 * The closing book, counted up, and the four ratios beside it. An island for
 * the count; its figures arrive from the server, so the book itself never
 * reaches the browser.
 */
export function RevenueHero({ headline }: { headline: RevenueHeadline }) {
  return (
    <Card data-widget="widget-saas-revenue-revenue-hero">
      <CardContent className="grid gap-8 md:grid-cols-[minmax(0,17rem)_1fr] md:items-center md:gap-10">
        <div className="flex flex-col gap-3">
          <BigNumber
            label="Monthly recurring revenue"
            value={headline.mrr}
            delta={headline.delta}
            format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
          />
          <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
            <MetricDelta value={headline.yoy} size="sm" />
            <span>on this time last year</span>
            <span aria-hidden="true">·</span>
            <span className="tabular-nums">{headline.customers.toLocaleString("en-US")} customers</span>
          </p>
        </div>

        <StatList divided items={headline.stats} />
      </CardContent>
    </Card>
  )
}
app/saas/revenue/book.ts
/**
 * The book: how a subscription becomes revenue, and what the revenue was on any
 * given date. `db.subscriptions` records one rate per account and the two dates
 * it ran between, so a month's book is every subscription that had started by
 * the close and had not been cancelled yet.
 *
 * `data.ts` holds the selectors the page reads; this is the model underneath
 * them, kept apart because it is the part a consumer swaps when the rows come
 * from a real billing system.
 */
import { db, REFERENCE_DATE, type Customer, type Subscription } from "@/lib/sample-data"

/** The plan tiers that carry revenue, largest first — Free never enters the book. */
export type PaidTier = Exclude<Customer["plan"], "free">
export const PAID_TIERS = ["enterprise", "team", "starter"] as const satisfies readonly PaidTier[]

/**
 * Every subscription, read per call and never held at module scope: an account
 * cancelled or started since the server started is in the next book.
 */
export function subscriptions(): Subscription[] {
  return db.subscriptions.all()
}

export const PLAN_NAME_BY_ID = new Map(db.plans.all().map((plan) => [plan.id, plan.name]))
const PLAN_BY_TIER = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan]))

/** The plan a tier names. Customer.plan and Plan.name are the same four tiers. */
export function planFor(tier: PaidTier) {
  return PLAN_BY_TIER.get(tier)!
}

// What a plan charges a seat a month, read off the accounts paying it today:
// every live account on a tier bills the same rate, so one of them states it
// for the accounts that have since stopped paying.
export function seatPrices(): Map<string, number> {
  const prices = new Map<string, number>()
  for (const customer of db.customers.all()) {
    if (customer.mrrCents > 0 && !prices.has(customer.plan)) {
      prices.set(customer.plan, customer.mrrCents / customer.seats)
    }
  }
  return prices
}

/** What a subscription bills a month, in cents. A trial bills nothing. */
export function billedCents(subscription: Subscription, prices = seatPrices()): number {
  if (subscription.status === "trialing") return 0
  const tier = (PLAN_NAME_BY_ID.get(subscription.planId) ?? "").toLowerCase()
  return subscription.seats * (prices.get(tier) ?? 0)
}

/** 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))
}

/** The month that ends at `monthStart(k)`, as "2026-08". */
export function closedMonthKey(k: number): string {
  return monthStart(k + 1).toISOString().slice(0, 7)
}

/** Whether a subscription was billing at `at`. */
export const live = (subscription: Subscription, at: Date): boolean =>
  subscription.startedAt <= at && (!subscription.cancelledAt || subscription.cancelledAt > at)

/** The book at `at`, in dollars, for one plan or for all of them. */
export function bookAt(at: Date, planId?: string): number {
  const prices = seatPrices()
  return (
    subscriptions()
      .filter((row) => live(row, at) && (!planId || row.planId === planId))
      .reduce((total, row) => total + billedCents(row, prices), 0) / 100
  )
}

/** What started billing, and what stopped, between two instants — in dollars. */
export function movement(from: Date, to: Date): { won: number; lost: number } {
  let won = 0
  let lost = 0
  const prices = seatPrices()
  for (const subscription of subscriptions()) {
    if (subscription.startedAt > from && subscription.startedAt <= to) won += billedCents(subscription, prices)
    const { cancelledAt } = subscription
    if (cancelledAt && cancelledAt > from && cancelledAt <= to) lost += billedCents(subscription, prices)
  }
  return { won: won / 100, lost: lost / 100 }
}
app/saas/revenue/data.ts
/**
 * What this page reads. The book is `db.subscriptions` walked month by month by
 * `./book`, so the twelve-month chart, the closing MRR, the movements, the
 * ratios and the quarter tracker all come off the same rows. The page closes
 * each month rather than reading a part-finished one; "now" is `REFERENCE_DATE`.
 */
import { formatCurrency, formatPercent, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Member, type Subscription } from "@/lib/sample-data"

import {
  billedCents,
  bookAt,
  closedMonthKey,
  live,
  monthStart,
  movement,
  PAID_TIERS,
  planFor,
  PLAN_NAME_BY_ID,
  seatPrices,
  subscriptions,
  type PaidTier,
} from "./book"

const MONTHS = 12

/** One month of the book. The keys are the paid tiers, so a typo will not compile. */
export type MonthlyRevenue = { month: string } & Record<PaidTier, number>

/** Twelve closed months of MRR split by plan, oldest first. Each row is that month's book. */
export function revenueByPlan(): MonthlyRevenue[] {
  return Array.from({ length: MONTHS }, (_, index) => {
    const k = MONTHS - 1 - index
    const at = monthStart(k)
    return {
      month: closedMonthKey(k),
      enterprise: Math.round(bookAt(at, planFor("enterprise").id)),
      team: Math.round(bookAt(at, planFor("team").id)),
      starter: Math.round(bookAt(at, planFor("starter").id)),
    }
  })
}

/** A tier a revenue chart stacks: the key its rows carry, and the plan's name. */
export type PlanKey = { key: PaidTier; label: string }

/** The tiers a revenue chart stacks, largest first. */
export function planKeys(): PlanKey[] {
  return PAID_TIERS.map((tier) => ({ key: tier, label: planFor(tier).name }))
}

const CLOSE_AT = monthStart(0)
const OPENING_AT = monthStart(1)
const YEAR_AGO_AT = monthStart(MONTHS)

/** Monthly recurring revenue at the close of the last complete month, read off the book as it stands. */
const closingMrr = () => Math.round(bookAt(CLOSE_AT))

/** The closing split by plan, in the shape PercentageBar takes. */
export function revenueMix(): { label: string; value: number }[] {
  return PAID_TIERS.map((tier) => ({
    label: planFor(tier).name,
    value: Math.round(bookAt(CLOSE_AT, planFor(tier).id)),
  }))
}

/** Enterprise's share of the book now, and twelve months ago. */
export function enterpriseShare(): { now: number; yearAgo: number } {
  const enterprise = planFor("enterprise").id
  return {
    now: bookAt(CLOSE_AT, enterprise) / bookAt(CLOSE_AT),
    yearAgo: bookAt(YEAR_AGO_AT, enterprise) / bookAt(YEAR_AGO_AT),
  }
}

/**
 * What the book won and lost between two month closes, over the same twelve
 * months the chart above draws — a single month of this data is mostly trials
 * that have not started paying. Two movements rather than the usual four: an
 * account moving up or down a plan leaves no trace in the rows, and a made-up
 * expansion would need a made-up contraction to cancel it.
 */
function movements(fromK: number, toK: number) {
  const opening = bookAt(monthStart(fromK))
  const { won, lost } = movement(monthStart(fromK), monthStart(toK))
  return { won, lost, opening, net: bookAt(monthStart(toK)) - opening }
}

/** The book the twelve months of movement below opened against. */
export function mrrYearOpening(): number {
  return Math.round(movements(MONTHS, 0).opening)
}

/** One step of the year's MRR walk, as the waterfall draws it. */
export type MrrMovement = { label: string; value: number; isTotal?: boolean }

/** How the closing book got to where it is over the twelve months behind it. */
export function mrrMovements(): MrrMovement[] {
  const YEAR = movements(MONTHS, 0)
  return [
    { label: "New", value: Math.round(YEAR.won) },
    { label: "Churn", value: -Math.round(YEAR.lost) },
    { label: "Net new", value: 0, isTotal: true },
  ]
}

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

const paying = (at: Date): number => {
  const prices = seatPrices()
  return subscriptions().filter((subscription) => live(subscription, at) && billedCents(subscription, prices) > 0)
    .length
}

const retained = (span: ReturnType<typeof movements>): number => (span.opening - span.lost) / span.opening

/** One of the four ratios beside the headline number: printed, and moved against the year before. */
export type HeadlineStat = { label: string; value: string; delta: number; positiveIsGood?: boolean }

/** The four ratios beside the headline number, each against the year before it. */
export function headlineStats(): HeadlineStat[] {
  const YEAR = movements(MONTHS, 0)
  const YEAR_BEFORE = movements(MONTHS * 2, MONTHS)
  const arpu = closingMrr() / paying(CLOSE_AT)
  const wasArpu = bookAt(YEAR_AGO_AT) / paying(YEAR_AGO_AT)
  const churn = YEAR.lost / YEAR.opening
  const wasChurn = YEAR_BEFORE.lost / YEAR_BEFORE.opening
  const kept = retained(YEAR)
  // What an account is worth at a churn rate: the months it lasts, times what
  // it pays for each of them.
  const months = (rate: number) => (rate > 0 ? 12 / rate : 36)
  const ltv = arpu * months(churn)
  const wasLtv = wasArpu * months(wasChurn)

  return [
    { label: "ARPU", value: money(arpu), delta: arpu / wasArpu - 1 },
    {
      label: "Gross churn",
      // A fall is the win, so the delta is read the other way up.
      value: formatPercent(churn, { maximumFractionDigits: 1 }),
      delta: wasChurn > 0 ? churn / wasChurn - 1 : 0,
      positiveIsGood: false,
    },
    { label: "Lifetime value", value: money(ltv), delta: ltv / wasLtv - 1 },
    {
      label: "Gross retention",
      value: formatPercent(kept, { maximumFractionDigits: 0 }),
      delta: kept / retained(YEAR_BEFORE) - 1,
    },
  ]
}

export type RevenueWindow = {
  key: "1m" | "3m" | "12m"
  /** How the window reads in a sentence, e.g. "last quarter". */
  label: string
  won: number
  lost: number
  net: number
  /** Net new as a share of what the book opened the window at. */
  growth: number
  /** Each against the same length of book immediately before it. */
  wonDelta: number
  lostDelta: number
  netDelta: number
  growthDelta: number
}

// The three windows a revenue conversation actually compares. Month counts,
// not dates: the book is walked month by month, and a part-finished month is
// mostly trials that have not started paying.
const WINDOWS = [
  { key: "1m", label: "last month", months: 1 },
  { key: "3m", label: "last quarter", months: 3 },
  { key: "12m", label: "last year", months: MONTHS },
] as const

/**
 * What the book won and lost over each window, and how that compares with the
 * same length of book immediately before it — the same two movements the
 * waterfall draws, read at three lengths so the page can be asked a different
 * question without a round trip.
 */
export function revenueWindows(): RevenueWindow[] {
  const ratio = (now: number, before: number) => (before > 0 ? now / before - 1 : 0)

  return WINDOWS.map(({ key, label, months }) => {
    const span = movements(months, 0)
    const before = movements(months * 2, months)
    const growth = span.opening > 0 ? span.net / span.opening : 0
    const wasGrowth = before.opening > 0 ? before.net / before.opening : 0

    return {
      key,
      label,
      won: Math.round(span.won),
      lost: Math.round(span.lost),
      net: Math.round(span.net),
      growth,
      wonDelta: ratio(span.won, before.won),
      lostDelta: ratio(span.lost, before.lost),
      netDelta: ratio(span.net, before.net),
      growthDelta: ratio(growth, wasGrowth),
    }
  })
}

const DAY_MS = 86_400_000
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

// The quarter runs to the end of its third month, not to the start of it.
const QUARTER_END = new Date(
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + 3, 1) - 1
)
/** Everything the headline card prints: the closing book, how it moved, who pays it, and the four ratios. */
export type RevenueHeadline = {
  mrr: number
  /** On the month before. */
  delta: number
  /** On the same close a year earlier. */
  yoy: number
  customers: number
  stats: HeadlineStat[]
}

/** The headline card's figures, read here and handed to the card: the card is an island, and never reads the book. */
export function revenueHeadline(): RevenueHeadline {
  const mrr = closingMrr()
  return {
    mrr,
    delta: mrr / Math.round(bookAt(OPENING_AT)) - 1,
    yoy: mrr / bookAt(YEAR_AGO_AT) - 1,
    customers: db.customers.all().length,
    stats: headlineStats(),
  }
}

const QUARTER_LABEL = `Q${Math.floor(REFERENCE_DATE.getUTCMonth() / 3) + 1}`
// Months into the quarter so far, so the opening is the quarter's own start.
const quarterOpening = () => bookAt(monthStart(REFERENCE_DATE.getUTCMonth() % 3))

/** Whole days left in the quarter, counting the rest of today. */
export const QUARTER_DAYS_LEFT = Math.floor((QUARTER_END.getTime() - REFERENCE_DATE.getTime()) / DAY_MS)

/** Net new MRR booked this quarter, against the number the board was given. */
export function quarterGoal() {
  const opening = quarterOpening()
  const current = Math.round(closingMrr() - opening)
  const months = REFERENCE_DATE.getUTCMonth() % 3
  return {
    label: `${QUARTER_LABEL} net new MRR`,
    current,
    // A round number a little beyond the run rate: a target, not a forecast.
    goal: Math.ceil((current * 1.55) / 1_000) * 1_000,
    milestones: Array.from({ length: Math.max(0, months - 1) }, (_, index) => ({
      value: Math.round(bookAt(monthStart(months - 1 - index)) - opening),
      label: MONTH_NAME.format(monthStart(months - index)).slice(0, 3),
    })),
    deadline: `Quarter closes ${QUARTER_END.getUTCDate()} ${MONTH_NAME.format(QUARTER_END)}`,
  }
}

/**
 * The book the quarter has to close on: what it opened at plus the number the
 * board was given. The goal itself is net new, so a chart of the whole book
 * plots this rather than the difference — the same target, read on the scale
 * the chart is already on.
 */
export function quarterTarget(): { value: number; label: string } {
  return { value: Math.round(quarterOpening() + quarterGoal().goal), label: `${QUARTER_LABEL} target` }
}

/** The month the book is read at, e.g. "August 2026". */
export function bookAsOf(): string {
  return `${MONTH_NAME.format(monthStart(1))} ${monthStart(1).getUTCFullYear()}`
}


export type TopCustomer = {
  company: string
  plan: string
  owner: string
  mrr: number
  /** Change in that account's MRR on the month, as a ratio. */
  delta: number
}

const TOP_COUNT = 8
const YEAR_MS = 365 * 86_400_000

// Growth decays with an account's age, and the floor is what it decays past:
// the two meet at about fifteen months, which is where an account stops adding
// seats faster than it loses them.
const GROWTH_AT_NEW = 0.26
const SETTLED_DRIFT = 0.078

/**
 * What an account did on the month. A subscription carries one rate rather than
 * a history of them, so this is a rule over its age: an account still in its
 * first year is adding seats fast, and one past its second is settled and
 * drifting slightly down. A book this size holds both, so the column does too.
 */
function monthOnMonth(subscription: Subscription): number {
  const years = (CLOSE_AT.getTime() - subscription.startedAt.getTime()) / YEAR_MS
  return Math.round((GROWTH_AT_NEW * Math.exp(-years) - SETTLED_DRIFT) * 1000) / 1000
}

/** The largest accounts by MRR, with what each did on the month. */
export function topCustomers(): TopCustomer[] {
  // Read per call, never held at module scope: an account or an owner written
  // since the server started is what the next render shows.
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const prices = seatPrices()
  return subscriptions()
    .filter((subscription) => live(subscription, CLOSE_AT))
    .sort((a, b) => billedCents(b, prices) - billedCents(a, prices))
    .slice(0, TOP_COUNT)
    .map((subscription) => {
      const customer = customers.get(subscription.customerId)
      return {
        company: customer?.company ?? subscription.customerId,
        plan: PLAN_NAME_BY_ID.get(subscription.planId) ?? "—",
        owner: members.get(customer?.owner ?? "")?.name ?? "Unassigned",
        mrr: billedCents(subscription, prices) / 100,
        delta: monthOnMonth(subscription),
      }
    })
}

/** What those accounts add up to, as a share of the whole book. */
export function topCustomersShare(rows: TopCustomer[]): number {
  return rows.reduce((sum, customer) => sum + customer.mrr, 0) / closingMrr()
}

/**
 * How much of what customers pay for they actually use — the expansion signal.
 * Seats are counted off the subscriptions against what their plans include;
 * events and storage have no entity, so they come off `seeded("dashboard-02-usage")`.
 */
export function committedUsage() {
  const rand = seeded("dashboard-02-usage")
  // Seats bought is what the accounts pay for; how many of them are in use is
  // not something a subscription records, and nor are events or storage.
  const prices = seatPrices()
  const seats = subscriptions()
    .filter((subscription) => live(subscription, CLOSE_AT) && billedCents(subscription, prices) > 0)
    .reduce((sum, subscription) => sum + subscription.seats, 0)
  const used = (limit: number, floor: number, spread: number) => limit * (floor + rand() * spread)

  return [
    { label: "Seats", used: Math.round(used(seats, 0.82, 0.1)), limit: seats, unit: "seats", compact: false },
    { label: "Events", used: Math.round(used(12e6, 0.62, 0.12)), limit: 12e6, unit: "events", compact: true },
    { label: "Storage", used: Math.round(used(10, 0.58, 0.14) * 10) / 10, limit: 10, unit: "TB", compact: false },
  ]
}

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 }
}

/** 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 }))
}

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 Revenue dashboard page