Skip to content

BudgetVsActual

Polarity-aware, materiality-filtered, versions tracked.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/budget-vs-actual
npx shadcn@latest add @lopifi/theme @lopifi/budget-vs-actual

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Actual, budget, variance (absolute and relative), and forecast, by period and by dimension. Respects polarity — a favorable expense variance is under budget. Materiality thresholds applied, with the threshold displayed. Reforecast versions are tracked so you can see what was believed when.

The variance is a view, not a column. Actual, Δ and Δ% are the same three figures asked three questions, and putting all of them on screen at once is how a budget review becomes a wall of numbers nobody reads to the end. The comparison is declared rather than positional: the actual column names budget as its base, so hiding the budget column to fit the screen does not silently re-point the variance at whatever ended up to its left.

Two things make this more than a three-column table, and both are here because a budget review goes wrong without them.

The cut. A favourable total is routinely two departments cancelling out. Engineering 1.6M over and sales 1.7M under reads, at the entity line, as 0.1M under — favourable — and says nothing about either. A variance report that can only be read at the entity level is one that will be believed for a quarter longer than it should be. Members are not required to sum to the entity — unallocated corporate spend sits outside them — and a vintage that restates an entity budget says nothing about one department's share, so under a cut the member's own budget stands rather than the component inventing an allocation nobody approved.

The vintage. The original plan and each reforecast are versions of one object, not "budget" plus "reforecasts": model a reforecast as a different kind of thing and "what did we believe in February" stops having a single answer. A vintage states only what it changed. The comparison base is chosen out loud in the toolbar, and the walk between versions sits under the grid, because a reforecast that quietly replaces the plan makes every past month look better than it was called at the time. September beating the version in force while missing the one the board saw in November are both true readings; a component that picked one silently would be picking the flattering one.

Lifted verbatim from Metrics & analysis. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/budget-vs-actual.tsx

Lopifi dependencies · 17
clipboardcncolumn-managerfiscal-periodforecastformat-moneyiconsinfo-tipmoneymoney.typesperiod-changeperiod-gridruletooltipuse-column-widthsuse-grid-navuse-scroll-edges
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/budget-vs-actual.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/budget-vs-actual.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import { budgetForVersion } from "@/lib/ledger/forecast";
import { formatMoney } from "@/lib/ledger/format-money";
import type { Polarity } from "@/lib/ledger/money.types";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import type { PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
import { Rule } from "@/components/ledger/rule";

/** One figure set — a line, or a line cut to one member of a dimension. */
export type BvaFigures = {
  actual: number | null;
  budget: number | null;
  forecast?: number | null;
};

export type BvaLine = BvaFigures & {
  id: string;
  label: string;
  level: number;
  variant: "line" | "subtotal" | "total" | "section";
  /**
   * Which way is good, per line, and the reason this component is not a table.
   *
   * Polarity describes the figure **as stored**, not the concept behind it.
   * This system holds costs as negatives, so an overspent month is a figure
   * moving *down* and the row wants `standard`. It is required rather than
   * defaulted because revenue under budget and payroll under budget are
   * opposite outcomes, and a single favourable/unfavourable rule applied to
   * both gets one of them exactly backwards.
   */
  polarity: Polarity;
  /** The same three figures cut by dimension member id. */
  byMember?: Record<string, BvaFigures>;
};

/**
 * A budget as it stood on a date.
 *
 * The original plan and each reforecast are versions of the same object, held
 * in one list rather than as "budget" plus "reforecasts": the moment you model
 * a reforecast as a different kind of thing from the plan, the question "what
 * did we believe in February" stops having a single answer.
 */
export type BudgetVersion = {
  id: string;
  /** "FY26 Plan v1", "Q2 reforecast". Named, because an unnamed budget is a rumour. */
  name: string;
  approvedOn: Date;
  /**
   * What this version budgeted, per line id. A line absent here falls back to
   * the line's own `budget` — so a vintage only has to state what it changed.
   */
  budget?: Record<string, number | null>;
};

export type BvaDimension = {
  /** "Department", "Region", "Product". Shown as the switch's label. */
  label: string;
  members: { id: string; label: string }[];
};

export interface BudgetVsActualProps {
  className?: string;
  currency: string;
  /** Offering the dimension cut. Omit and the grid is the entity total. */
  dimension?: BvaDimension;
  lines: BvaLine[];
  materiality: { absolute: number; relative: number };
  periodLabel: string;
  storageKey?: string;
  /**
   * Every version of the budget, oldest first. The last is the one in force
   * and the one selected by default.
   */
  versions: BudgetVersion[];
}

const ALL = "__all__";

/**
 * Budget, actual and forecast as one grid rather than five columns.
 *
 * The variance is not a column here; it is a *view*. Actual, Δ and Δ% are the
 * same three figures asked three questions, and putting all of them on screen
 * at once is how a budget review becomes a wall of numbers nobody reads to the
 * end. The switch is in the toolbar, and the hover chip answers the question
 * for one line without leaving the value view at all.
 *
 * The comparison is declared, not positional: the actual column names budget
 * as its base. Hide the budget column to fit the screen and the variance still
 * measures against budget rather than quietly re-pointing at whatever column
 * ended up to its left.
 *
 * Two things make this more than a three-column table, and both are here
 * because a budget review goes wrong without them:
 *
 * 1. **The cut.** A favourable total is routinely two departments cancelling
 *    out, and a variance report that can only be read at the entity level is a
 *    report that will be believed for a quarter longer than it should be.
 * 2. **The vintage.** A reforecast that quietly replaces the plan makes every
 *    past month look better than it was called at the time. The versions are
 *    kept, the comparison base is chosen explicitly, and the walk from the
 *    original plan to the version in force is shown underneath — so "we said
 *    we would land at 56 in November and we are calling 60.4 now" is a
 *    sentence the grid can support.
 */
export function BudgetVsActual({
  periodLabel,
  lines,
  currency,
  materiality,
  versions,
  dimension,
  storageKey,
  className,
}: BudgetVsActualProps) {
  const current = versions[versions.length - 1];
  const [versionId, setVersionId] = React.useState(current?.id ?? "");
  const [member, setMember] = React.useState(ALL);

  const selected = versions.find((v) => v.id === versionId) ?? current;

  /* The figures for the cut in force. A member with nothing posted reads as
     null throughout rather than zero — "this department did not spend here"
     and "this department spent nothing here" are different findings, and only
     one of them is a variance. */
  const figuresFor = React.useCallback(
    (line: BvaLine): BvaFigures =>
      member === ALL
        ? { actual: line.actual, budget: line.budget, forecast: line.forecast }
        : (line.byMember?.[member] ?? { actual: null, budget: null, forecast: null }),
    [member],
  );

  /* The budget in force for a line, under the selected vintage. */
  const budgetFor = React.useCallback(
    (line: BvaLine, figures: BvaFigures): number | null => {
      /* A vintage restates entity-level budgets. Under a dimension cut it has
         nothing to say about one department's share, so the cut's own budget
         stands — inventing a split would be the component asserting an
         allocation nobody approved. */
      if (member !== ALL) return figures.budget;
      return budgetForVersion(figures.budget, selected?.budget, line.id);
    },
    [member, selected],
  );

  const hasForecast = lines.some((l) => figuresFor(l).forecast != null);

  const columns: PeriodColumn[] = [
    {
      hint: selected ? `${selected.name} · approved ${formatDate(selected.approvedOn)}` : undefined,
      id: "budget",
      kind: "budget",
      label: "Budget",
    },
    { compare: "budget", id: "actual", kind: "actual", label: "Actual", locked: true },
    ...(hasForecast
      ? [{ compare: "budget", id: "forecast", kind: "forecast" as const, label: "Forecast" }]
      : []),
  ];

  const rows: PeriodRow[] = lines.map((l) => {
    const figures = figuresFor(l);
    const section = l.variant === "section";
    return {
      id: l.id,
      label: l.variant === "total" ? `${l.label}, grand total` : l.label,
      level: l.level,
      polarity: l.polarity,
      values: [
        section ? null : budgetFor(l, figures),
        section ? null : figures.actual,
        ...(hasForecast ? [section ? null : (figures.forecast ?? null)] : []),
      ],
      variant: l.variant,
    };
  });

  /* The line the vintage walk is told about. The grand total if there is one,
     otherwise the last line — a walk needs one measure, and picking the
     bottom of the statement is the convention every board pack follows. */
  const anchor = React.useMemo(
    () => [...lines].reverse().find((l) => l.variant === "total") ?? lines[lines.length - 1],
    [lines],
  );

  const showWalk = versions.length > 1 && anchor != null && member === ALL;

  return (
    <div className={cn("space-y-4", className)}>
      <PeriodGrid
        caption={`Budget versus actual, ${periodLabel}${
          member === ALL ? "" : ` · ${dimension?.members.find((m) => m.id === member)?.label}`
        }`}
        columns={columns}
        currency={currency}
        footnote={
          <>
            {periodLabel} · measured against{" "}
            <span className="font-medium text-text-secondary">{selected?.name}</span>
            {selected ? <>, approved {formatDate(selected.approvedOn)}</> : null} · variances over{" "}
            <Money size="xs" tone="inherit" value={{ amount: materiality.absolute, currency }} /> <em>and</em>{" "}
            <span className="numeric">{materiality.relative}%</span> are marked ▸
            {member === ALL ? null : (
              <> · one {dimension?.label.toLowerCase()} only, so these lines do not sum to the entity</>
            )}
          </>
        }
        labelHeader="Line"
        labelWidth={260}
        materiality={materiality}
        rows={rows}
        storageKey={storageKey}
        toolbar={
          versions.length > 1 || dimension ? (
            <div className="flex flex-wrap items-center gap-4">
              {versions.length > 1 ? (
                <label className="flex items-center gap-2">
                  <span className="text-label font-medium text-text-secondary">Compare to</span>
                  <select
                    className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
                    onChange={(e) => setVersionId(e.target.value)}
                    value={versionId}
                  >
                    {[...versions].reverse().map((v) => (
                      <option key={v.id} value={v.id}>
                        {v.name}
                        {v.id === current?.id ? " (in force)" : ""}
                      </option>
                    ))}
                  </select>
                </label>
              ) : null}
              {dimension ? (
                <label className="flex items-center gap-2">
                  <span className="text-label font-medium text-text-secondary">{dimension.label}</span>
                  <select
                    className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
                    onChange={(e) => setMember(e.target.value)}
                    value={member}
                  >
                    <option value={ALL}>All — entity total</option>
                    {dimension.members.map((m) => (
                      <option key={m.id} value={m.id}>
                        {m.label}
                      </option>
                    ))}
                  </select>
                </label>
              ) : null}
            </div>
          ) : null
        }
      />

      {showWalk ? (
        <div className="rounded-xl border border-border bg-surface p-6">
          <h3 className="text-title-sm font-medium">
            What was believed, and when
            <InfoTip about="Why every version is kept">
              A reforecast that replaces the plan makes every past month look better than it was called at the
              time. Keeping the versions is what lets someone ask whether the number moved because the
              business did, or because the target did.
            </InfoTip>
          </h3>
          <Rule className="mt-3" variant="subtotal" />
          <p className="mt-3 text-caption text-text-tertiary">
            {anchor.label}, {periodLabel}, as each version had it.
          </p>
          <ul className="mt-3 space-y-2">
            {versions.map((v, i) => {
              const value = budgetForVersion(anchor.budget, v.budget, anchor.id);
              const previous = versions[i - 1];
              const previousValue =
                previous == null ? null : budgetForVersion(anchor.budget, previous.budget, anchor.id);
              /* The move from the version before it, as a plain signed
                 amount.
                 
                 Deliberately *not* run through polarity and not called
                 favourable or unfavourable. Favourability is a statement about
                 an actual measured against a budget; a budget measured against
                 an earlier budget is a decision, and a raised target is not
                 good news to the person who has to hit it. The delta is the
                 fact, and the reader supplies the judgement. */
              const move = value == null || previousValue == null ? null : value - previousValue;
              return (
                <li className="flex items-baseline justify-between gap-4" key={v.id}>
                  <span className="text-body">
                    {v.name}
                    <span className="ml-2 text-caption text-text-tertiary">
                      {formatDate(v.approvedOn)}
                      {v.id === current?.id ? " · in force" : ""}
                    </span>
                  </span>
                  <span className="flex items-baseline gap-3">
                    <Money accounting value={value == null ? null : { amount: value, currency }} />
                    {/* Not coloured. This is a list of decisions, not a
                        series, and there is no direction a component can
                        assert about someone changing their own target. */}
                    <span className="w-32 shrink-0 text-right text-caption numeric text-text-tertiary">
                      {move == null || move === 0 ? (
                        "—"
                      ) : (
                        <>
                          {move > 0 ? "+" : ""}
                          {formatMoney({ amount: move, currency }, { accounting: false })}
                        </>
                      )}
                    </span>
                  </span>
                </li>
              );
            })}
          </ul>
          <p className="mt-4 text-caption text-text-tertiary">
            The actual is one number. Which of these it is measured against is a choice, and the grid above
            says which one it is making.
          </p>
        </div>
      ) : null}
    </div>
  );
}

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Metrics & analysis

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where BudgetVsActual sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Metrics & analysis changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.