# Lopifi — US GAAP books on autopilot, reviewed by a CPA > Lopifi is AI bookkeeping for early-stage US startups on Xero or QuickBooks Online: agents categorize, reconcile, keep a lead sheet behind every balance and close your books on US GAAP as transactions happen, then wait for your sign-off. A CPA reviews it all every quarter. $200 a month. Now taking a waitlist. Vibe accounting is the practice of building accounting and finance software by describing intent to an AI coding agent, using components that already encode double-entry rules, money formatting, period logic and audit controls — so the generated interface is correct by construction rather than merely plausible. Canonical: https://lopifi.com · Registry: https://lopifi.com/r/{name}.json · MCP: https://lopifi.com/api/mcp --- # Brand identity The Lopifi mark, wordmark, colour, voice and motion. This is the prose behind [`/brand`](/brand), and the source for the `brand` block in [`DESIGN.md`](/design.md). If the two disagree, this document wins. Everything on this page is derived from something the system already believes. The brand does not add a decoration the product does not have. --- ## The idea **Accounting is a few old marks, made exactly.** A rule across the top of the page. Two columns. The full stop at the end of an entry. A single line over a subtotal and a double line under the total. Four centuries of accountants agreed on these, and nobody has improved them. The mark is made from four of them and is a **π with a full stop: `π.`** The π is the letters in the middle of the name (lo‑π‑fi) and, read as a ledger, a head rule over two columns. The stop is the period a bookkeeper writes at the end of an entry: nothing after it changes the figure. *The figure is final* is the one thing the product promises, and the mark says it in one character. | Part | Reads as | Drawn as | |---|---|---| | The bar | The head rule, the line the page hangs from | 38 × 7, flush top | | The left column | **Debit.** Straight down, ends where it ends | 8 × 37 | | The right column | **Credit.** Turns at the foot toward the edge of the page and ends flush with the rule | 8 × 23, then a quarter circle (inner 6, outer 14) | | The stop | **Final.** The period after the entry | 8 × 8, on the baseline | It is deliberately not a ledger icon, a coin, an arrow, a graph or a bracket. Nor is it two columns under a lintel, which is a bank. The curve is what keeps it from being a temple; the stop is what keeps it from being a Greek letter. Nobody has to be told what it is; the people who would recognise it will. ## Construction The mark is drawn on a **64 grid with an 8‑unit stem**. The wordmark, set at the same scale in Inter 600, has a stem of 8.1, so the mark and the letters share one stroke. - Extents: x 8–57, y 10–54. The stop is light, so the box sits half a unit right of centre and the weight sits on it. The 4‑unit margin inside a 56 viewBox is the artboard. - Every edge is on a whole unit. The one curve is a quarter circle, inner radius 6, outer 14, about (46, 40). - **One shared edge.** The head rule ends at x 46 and so does the curve's terminal: the right side of the π is one vertical, and the stop stands three units past it, alone. - **The rule is 7, the stems 8.** A horizontal reads heavier than a vertical of the same measure, so the rule is drawn a unit lighter to look the same. Every typeface makes this correction in its crossbars. - **The 32 tile is its own cut** (`app/icon.svg`): the rule at 4 so it stays a crisp line, the stop a whole unit clear of the curve so the gap survives a 16px tab on a 2× screen. - The mark is **four filled paths**, not strokes, so it takes `currentColor`, scales without a stroke to recompute, and each part can be drawn on its own. One geometry module, `src/brand/mark.ts`, feeds the header, the favicon, the PNG icons, the Open Graph card and every downloadable file. No surface redraws it. ## Wordmark `lopifi`, lowercase, set in **Inter 600**, tracking −0.025em, outlined to paths so it is identical in the header, an OG card and a downloaded SVG with no dependency on a loaded font. Inter is the type contract's only text face; the wordmark is not a third family. Lowercase because the name is a word you say, not an acronym, and because l‑i‑f‑i gives it four verticals in six letters, the rhythm of a column of figures. Two details are drawn rather than set: - **One square, three times.** The two i's are dotless and carry a square tittle the width of the stem, the same square as the mark's stop. The tittles hang from the ascender, so the top of the l, the top of the f and both dots are one ruled line. - **The f is the π turned over.** Its hook is the credit column's curve rotated half a turn: the same outer radius, cut square, and it ends flush with the f's crossbar exactly as the mark's curve ends flush with its head rule. In the lockup the mark is scaled so its height equals the ascender. That makes its stems 8.5 against the letters' 8.1. The half‑unit of extra weight is deliberate, because a solid mark beside thin type reads lighter than it measures. The gap between them is 18 units, and the mark's stop lands on the baseline as the sentence's full stop: **in a lockup the stop takes the wordmark's ink**, so the π is the only blue. Alone, the mark is one colour. ## The Total The brand's one device is the footing: **a single rule over a double rule**, the statement convention that means *this total is final*. It is used to close a lockup, to underline a figure that has been proven, and as the rule between sections of a printed document. It is never decorative, never coloured, never used under a figure that has not been proved. ## Clear space and minimum size - **Clear space:** two stems (16 units on the 64 grid, a quarter of the artboard) on every side of the mark or lockup. Nothing enters it: no text, no edge, no other logo. - **Minimum size:** the mark alone **16 px** tall; the lockup **72 px** wide. Below 16 px use the tile (`icon.svg`), which is drawn for it. - **Tile corner radius:** 7/32 of the side. A full‑bleed square for platforms that apply their own mask. ## Colour The mark is the system's single accent, `--accent`, `oklch(0.5 0.155 258)`, written **`#2060b9`** wherever oklch cannot be read (SVG downloads, OG cards, the PNG icons). The wordmark is `--text-primary`. That is the whole palette. | Use | Mark | Wordmark | Ground | |---|---|---|---| | Primary | `--accent` | `--text-primary` | `--canvas` / `--surface` | | Mono (one colour, for print, stamps, embossing) | `currentColor` | `currentColor` | any | | Inverse | `#fff` | `#fff` | `--accent` | | Dark mode | `--accent` (its dark value, `oklch(0.65 0.15 258)`) | `--text-primary` | `--surface` | One accent per view applies to the logo: a page that already has its accent in play uses the mono mark. **No gradients. No glow. No second blue. No purple.** These are the same rules as the product, not a separate brand layer. ## Type Two families, and no third. **Inter** for every word. **Geist Mono** for every figure, identifier and code sample. Display is Inter at display sizes, set at 600 with tracking −0.03em; the italic is the one accent word in a headline. See [typography](../foundations/05-typography.md) and [numerics](../foundations/06-numerics.md). ## Voice The voice is an accountant who respects your time: **specific, calm, exact**. It states the figure and the basis. It does not reassure and it does not hype. - Say what happened, to what, as of when. *"14 transactions categorised. 2 need your call."* - Prefer the noun an accountant would use (*accrual*, *reversal*, *tie‑out*) and define it once, in a link, rather than avoiding it. - Never "supercharge", "unlock", "effortless", "AI‑powered magic". Never an exclamation mark. Never an emoji. - Never promise what a CPA must sign. *"A CPA reviews it every quarter"* is a fact; *"audit‑proof"* is a claim. - Negative numbers are in parentheses, in prose as in tables. Money is never abbreviated where it can be read exactly. ## Motion The logo is still by default, because a header mark is seen dozens of times a session. It moves only on surfaces that are *about* the brand, and then once: 1. The head rule is drawn left to right (240 ms). 2. The debit column is drawn top to bottom, then the credit column, 40 ms apart (320 ms each). 3. The six letters rise 4 units into place on the same 40 ms stagger, 240 ms in all. 4. The full stop is set last: faded in and set down 3 units onto the baseline (180 ms). It is what makes the figure final, so nothing about the mark is finished until it lands. 5. In the Total lockup, the single rule and then the double rule close the line, as they do on a statement. Only `clip-path`, `opacity` and `transform`; `--ease-out`; nothing loops, nothing bounces, nothing is scaled from zero. Reduced motion shows the finished mark. A downloadable animated SVG follows the same sequence. The one hover: in the header, the credit column and its stop carry a 1.5px step forward, together. A figure being carried, not a logo spinning. ## Misuse Do not: recolour the mark off‑palette · place it on a busy photograph · stretch, skew or rotate it · outline it · add a shadow, glow or gradient · set the wordmark in another face · put the mark inside a circle · use the π as a literal letter in running text · drop the stop, or colour it apart from both the π and the word · animate it in a loop · redraw it from memory. Export it. ## Files Served at `/brand/assets/{name}.svg`: `mark`, `mark-white`, `mark-mono`, `lockup`, `lockup-mono`, `lockup-inverse`, `lockup-total`, `app-icon`, `mark-animated`, `lockup-animated`. All are outlined, with no live text and no external references. --- # Manifesto ## The problem Financial software is where design quality has the highest stakes and the lowest average. The reason is structural: fintech UI is usually built by teams optimizing for correctness, and correctness is satisfied by a table. So you get a table. Then a chart library gets bolted on, then a card, then a modal, and the result works and feels like nothing. Meanwhile every generic component kit fails the first real financial requirement: - It renders `-1234.5` where an accountant expects `(1,234.50)`. - It stores money in a float and loses a cent at scale. - It centers a column of numbers, so no two decimal points align. - It has no idea that JPY has zero decimals and KWD has three. - It treats `null` and `0` as the same thing, which in finance is the difference between "we don't know" and "it's zero". - It animates a balance counting up, which is charming exactly once and misleading forever. And past the numbers, the model itself is missing. Generic kits have no concept of a debit, a period that can be closed, an entry that must be reversed rather than deleted, an approval that binds to a version, or a balance that has to be supported by evidence. Those aren't features layered on top of a data model — **they are the data model**, and they're what makes accounting software different from a spreadsheet with permissions. Lopifi exists because **money has a typography, a grammar, and a set of conventions four hundred years older than the web**, and almost no component library respects any of them. ## The thesis > In a category where everyone's software is functionally adequate, taste is the differentiator. That's Emil Kowalski's framing and it's the whole bet. If two accounting products both compute the right number, the one that *feels* considered wins — not because feel beats function, but because feel is the only remaining signal of care, and users read care as trustworthiness. In finance, perceived trustworthiness is the product. ## Principles ### 1. Unseen details compound Nobody will consciously notice that the parentheses around a negative number are case-shifted to align with lining figures. They will notice that the statement looks *right*. A thousand barely audible voices singing in tune. ### 1b. The ledger is the model, not a detail Double-entry, normal balances, period states, and immutability are load-bearing. A component that renders a signed amount where an accountant expects a debit and a credit hasn't simplified anything — it has removed the control that makes the number checkable. Every design decision in this system defers to the accounting model when the two conflict. ### 2. The numbers are the interface Everything else — the card, the shadow, the radius — exists to frame a figure. When a decision trades off between chrome and legibility of a number, the number wins. Every time. ### 3. Restraint is the aesthetic One accent per view. No gradients. No glow. No purple. No decorative motion in a data surface. Financial confidence reads as *quiet*, and quiet is achieved by subtraction. The most expensive-looking interfaces are the ones that removed the most. ### 4. Motion must justify itself Every animation answers "why does this move?" with one of: spatial consistency, state indication, explanation, feedback, or preventing a jarring change. "It looks cool" is not on the list. And frequency governs everything — a thing seen 200 times a day gets zero animation, a thing seen twice a month can have delight. ### 5. The layout is a grid, not a vibe 8px, all the way down, in four deliberate layers. Spacing is not eyeballed. This is what makes a system extensible: any new component drops into a structure that already exists. ### 6. Two typefaces, three sizes, three weights Hierarchy comes from *contrast within a small set*, not from a large set. A component that needs a fourth size is a component that needs to be two components. ### 7. Density is a first-class responsive dimension Most systems responsively reflow. Finance UI must responsively *re-rank* — decide which of eleven columns matter on a 390px screen, and design that view rather than letting it scroll sideways into uselessness. ### 8. Own the code Copy-paste distribution, not a dependency. A finance team will need to change the categorization rules, the fiscal calendar, the rounding policy. A black-box npm package is the wrong shape for that. shadcn was right. ## Non-goals | Not doing | Why | |---|---| | A charting library | We wrap one (Recharts/visx). Charting is solved; *money-aware axes and negative-aware baselines* are not. | | A data layer | No fetching, no cache, no ORM. Components take data and render it. | | Every currency's cultural formatting | We use `Intl` correctly and expose escape hatches. We don't hand-roll locale tables. | | A theme marketplace | One system, one point of view, two modes (light/dark) plus one accessibility variant. Taste is not a config option. | | Crypto-maximalist visuals | Swap and FX components are included. Neon gradients and glow are not. | | Server components as a constraint | Components are RSC-safe where possible, `"use client"` where interaction demands it. We don't contort for purity. | ## Naming `Lopifi`. One word, short, easy to say and to type. Registry namespace `@lopifi`, so installs read as sentences: ```bash npx shadcn@latest add @lopifi/profit-and-loss ``` Naming creates identity; memorability beats discoverability. `react-finance-ui` would be more searchable and worse. **Alternates considered:** `Tender` (good, ambiguous), `Numeraire` (precise, unpronounceable), `Denom` (crypto-coded), `Tally` (taken, and reads as counting rather than accounting). ## Influences, credited - **Emil Kowalski** — the animation decision framework, easing philosophy, the Sonner principles, interruptibility, the blur-masking trick. Encoded throughout [`docs/motion/`](motion/). - **`ibelick/ui-skills`** — the baseline-ui constraint set, motion-performance rules, accessibility priority ladder, the DESIGN.md evidence discipline, and the three-proof gate that governs [the audit pass](craft/03-quality-bar.md#the-audit-pass). Encoded in [`docs/craft/03-quality-bar.md`](craft/03-quality-bar.md) and [`DESIGN.md`](/design.md). - **The wider [ui-skills registry](https://www.ui-skills.com/)** — Core Web Vitals thresholds from `addyosmani/web-quality-skills`, presence and icon-morphing discipline from `raphaelsalaja/skill`, the Base UI migration path from `shadcn-ui/ui`. Taken narrowly: most of that registry is out of stack or out of scope, and [prior art](03-prior-art.md#explicitly-rejected) names what we turned down and why. - **Refactoring UI** — grayscale-first, constrained scales, shadow as elevation not decoration. - **Mercury, Ramp, Stripe, Linear, Wise, Monzo** — read closely in [`docs/02-moodboard.md`](02-moodboard.md), taken from selectively. - **Ramp, Mercury, Brex, Slash, Rho, Bill, Wise, Stripe** — the spend and banking layer, read closely in [`docs/04-product-teardowns.md`](04-product-teardowns.md). - **Numeric, FloQast, Puzzle, Digits, Campfire, Xero, NetSuite** — the accounting layer, same document. Between them they define the workflow we're designing for, and none of them has both the model and the interface. - **Four centuries of accountants** — the rule lines, the parentheses, the em dash for nil, the double-entry itself. We didn't invent any of it. We just stopped ignoring it. --- # Moodboard Not a Pinterest board. A set of read-closely references, each with a specific extraction — *what we take*, and just as importantly *what we refuse*. A moodboard that only says "I like this" produces pastiche. A moodboard that says "I take this one decision and reject that one" produces a system. --- ## The reference set ### 1. Mercury — the observatory **What it is:** Business banking. Near-black canvas (`#171721`), content floating as marginally lighter graphite surfaces, ivory text, a single cobalt (`#5266eb`) reserved exclusively for the primary action. Proprietary Arcadia / Arcadia Display, body at 400 and display at an unusual 480 — confident without shouting. 18 color tokens, of which only 2 are brand. **Take:** - **Accent scarcity as a discipline.** One chromatic event per view, spent on the single most important action. Everything else is neutral. This is the highest-leverage decision in the entire moodboard. - **The two-brand-tokens ratio.** 16 structural tokens to 2 brand tokens is the correct proportion for financial UI. - **Decorative blues that never touch a button.** Mercury's pale blues (`#9cb4e8`, `#cdddff`) live in washes and mockups. They are scenery, not affordance. We adopt the hard separation: *a color used for decoration may never also signal action.* - **Lead with one trusted number.** The account balance is the page. **Refuse:** - Dark-first. Lopifi is **light-first**, because the primary users are accountants and finance teams reading dense numeric tables for eight hours, often next to a spreadsheet and often printing. Dark mode is a peer, not the default. - A proprietary display face. We can't ship one, and the taste has to survive on Inter + Geist Mono. ### 2. Ramp — the anomaly surface **What it is:** Spend management. Leads with total spend and *deviations*. Dense tables treated as the hero rather than as a lesser page behind the dashboard. **Take:** - **The table is a first-class design surface**, not a fallback. Ramp invests real craft in row density, column hierarchy, hover affordances, and bulk selection. Our `TransactionTable` gets the same investment as our `BalanceCard`. - **Surface the exception, not the aggregate.** A finance dashboard's job is to tell you what changed and what's wrong. That's why `AnomalyCard` is in the core catalog and a generic "welcome back" hero is not. **Refuse:** - Marketing-grade motion inside the product. Spend review is a high-frequency task; per Emil's frequency law it should be near-motionless. ### 3. Stripe — the documentation standard **What it is:** Payments infrastructure. The most-copied design language in developer fintech. **Take:** - **Currency symbol set lighter than the value.** `$` at `text-tertiary`, digits at `text-primary`. Stripe and Mercury independently converged here; it's correct because the symbol is a unit label, not data. - **Consistent decimal places within a column, always.** `$1,200.00`, never `$1,200`. - **The docs site is the product.** Interactive, touchable, copy-paste-ready. Sonner's adoption came from the same insight. Our docs site is a milestone, not an afterthought. **Refuse:** - The gradient-mesh marketing aesthetic. It belongs on stripe.com and nowhere near a ledger. ### 4. Linear — the keyboard-first tempo **Take:** - **Speed as an aesthetic.** Keyboard-initiated actions get *zero* animation. The command palette opens instantly. This is the single most common failure in AI-generated UI — animating a thing the user triggers 200 times a day. - **Hairline-defined surfaces.** Linear separates regions with a 1px line, not a shadow. Shadows are reserved for genuinely floating things. - **A restrained, near-monochrome palette with high-contrast type.** **Refuse:** - The purple. And more generally the "developer tool dark" register — Lopifi's users are not all engineers. ### 5. Wise — the FX honesty pattern **Take:** - **The conversion breakdown as a component.** Amount → fee (itemized) → rate (with timestamp) → recipient gets. Every line visible, no "estimated total" hand-wave. This shape *is* our `ConversionBreakdown`. - **The rate is a perishable fact.** Show it with an as-of time and a lock countdown. A rate without a timestamp is a lie. **Refuse:** - The consumer-brand green as a system color. We keep green strictly semantic (positive value), never brand — otherwise "green" stops meaning "favourable". ### 6. Monzo / Revolut — the card object **Take:** - **The payment card rendered as a physical object.** Real proportions (ISO/IEC 7810 ID-1, 85.60 × 53.98 mm → aspect ratio **1.586**), real corner radius (3.18mm → ~3.7% of width, ~5.9% of height), a card-flip for the CVV that uses actual 3D (`rotateY` + `preserve-3d`), and a subtle spring tilt on pointer move. - **Consumer warmth in a business product, used sparingly.** The card is one of the few places in Lopifi where delight is not just allowed but required — you see it rarely, it's emotionally loaded, and it's the physical anchor of the whole product. **Refuse:** - Gradient card faces as the default. Ours are flat, tinted by a brand color the consumer sets, with a single specular sweep on hover. ### 7. Bloomberg Terminal — the tick **Take:** - **The value flash.** When a number updates live, its *cell background* flashes (green up / red down) and decays to transparent over ~640ms. Forty years of traders reading it proves it works, and it solves the exact problem a count-up animation creates: it draws the eye to a change **without ever rendering an intermediate false value**. - **Density is respect.** Power users want more on screen, not more whitespace. Hence three density modes. **Refuse:** - Everything else. Chromatic chaos, 11px type, no hierarchy. We take one idea. ### 8. Apple (Wallet, Stocks, Numbers) — the physics **Take:** - **Interruptible, velocity-preserving motion.** Springs for anything a finger can grab. A sheet you flick away mid-animation must reverse from where it is, not restart. - **Momentum dismissal over distance thresholds.** A quick flick should be enough (velocity > ~0.11 px/ms). - **Damping at boundaries** rather than hard walls. - **`bounce: 0.2` maximum.** Financial UI does not wobble. ### 9. Swiss / accounting print tradition — the rules **Take:** - **Rule lines carry meaning.** A single hairline above a subtotal. A **double rule** beneath a grand total. This is centuries-old typographic grammar, it's still what accountants look for, and essentially zero web products implement it. It costs us two CSS declarations and buys enormous credibility. - **The em dash for nil.** `—` for a true zero in a statement. Blank for not-applicable. `n/a` for unknown. Three states, three glyphs. - **Right-aligned figures, left-aligned labels, leaders optional.** ### 10. Isometric illustration (overflow.design/isometric register) **Take:** A flat-shaded, 30° axonometric illustration language for empty states, onboarding and flow diagrams — volume without photorealism, built from our own palette. Full system in [`foundations/08-imagery-and-isometric.md`](foundations/08-imagery-and-isometric.md). **Refuse:** Isometric art anywhere inside a data-dense surface. It's a *narrative* device — it appears where there is no data yet, or where a concept must be explained. Never as decoration on a working screen. --- ## Synthesis: the resulting character If you had to describe Lopifi in one line: **an accountant's ledger, typeset properly, that responds instantly.** | Axis | Where Lopifi sits | | |---|---|---| | Warm ↔ Cool | | Cool-neutral ink (hue 265, near-zero chroma) with a warm-white canvas | | Dense ↔ Airy | | Dense by default, three density modes, generous only in the 24px card inset | | Flat ↔ Deep | | Flat at rest, real depth only for floating surfaces | | Playful ↔ Serious | | Serious, with four sanctioned moments of delight (the card flip and tilt, the payment success, the reconciliation match) | | Loud ↔ Quiet | | Quiet. One accent. Silence is the brand. | | Static ↔ Alive | | Alive at the edges: ticks, draws, presence — never in the middle of a number | ### The four sanctioned "moments" Everything else is quiet, so these land: 1. **The card flip** (`PaymentMethodCard`) — 3D, spring, rare, emotional. 2. **The pointer tilt** (`PaymentMethodCard`) — desktop hover only, acceptable because the card is decorative. 3. **The payment success** (`PaymentSuccess`) — stroke-drawn check, one scale, seen rarely. 4. **The reconciliation match** (`ReconciliationView`) — two rows fly together and merge. The single most satisfying thing in accounting software, and it happens maybe forty times a close. ### The palette in one sentence Near-white canvas, pure-white surfaces, ink-black text, hairline borders, one ink-blue accent, and a green/red pair that is *never* the only signal. --- # Prior art You asked whether something like this already exists. Short answer: **finance *templates* exist; a finance *primitive system* does not.** ## What exists | Project | What it is | Why it isn't this | |---|---|---| | [shadcn/ui](https://ui.shadcn.com/) | The distribution model and base primitives | Deliberately domain-agnostic. It's our foundation, not our competitor — we consume it via `registryDependencies`. | | [Shadcn Fintech](https://shadcn-fintech.vercel.app/dashboard) | Free open-source fintech dashboard template, ~13 pages, crypto candlesticks, drag-drop layout | A **template**: a finished app you fork and gut. No money primitive, no statement components, no accounting conventions. Fork-and-diverge, not install-and-compose. | | [Shadcnblocks](https://www.shadcnblocks.com/) — Zippay, Metafi | Paid consumer-fintech and B2B payments blocks, $79/stack | Marketing-page blocks (hero, pricing, feature grid) with a fintech skin. Almost no product surface. Not free. | | [ReUI](https://reui.io/) | 1,000+ free shadcn components, 60+ categories, incl. data grids | Breadth over depth. Generic data grid, generic cards. Nothing knows what a currency is. | | [Shadcn UI Kit — Finance](https://shadcnuikit.com/dashboard/finance) | Admin dashboard template | Same category as above. Template, not system. | | [cameronking4/shadcn-openai-plaid-dashboard](https://github.com/cameronking4/shadcn-openai-plaid-dashboard) | Personal finance dashboard w/ Plaid + OpenAI | An **application**. Tightly coupled to Plaid. Not extractable. | | [Tremor](https://tremor.so/) | React dashboard components, charts + KPI blocks | Closest in spirit, and genuinely good. But it's *dashboard*-native, not *finance*-native: no accounting negatives, no statement rules, no multi-currency, no ledger semantics. | | [Actual Budget](https://actualbudget.org/), [Firefly III](https://www.firefly-iii.org/) | Full open-source personal finance apps | Real financial correctness, zero reusability. Their UI is welded to their domain model. | | [Apache Fineract](https://fineract.apache.org/) | Core banking backend | Backend. No UI story. | | [Recharts](https://recharts.org/) / [visx](https://airbnb.io/visx/) | Charting | We wrap them. Neither knows about negative-aware baselines, money axes, or fiscal periods. | ## The gap, stated precisely Nothing in the list above ships: 1. **A money primitive.** Minor-unit storage, per-currency decimals, accounting negatives, `null`/`0`/redacted as distinct states. 2. **Financial statement components.** P&L, balance sheet, cash flow — with hierarchy, expandable rollups, period columns, variance, drill-down, and correct rule lines. This is the single largest hole. Not one library has a ``. 3. **Reconciliation UI.** Two-pane matching, confidence scoring, bulk match. Every accounting product rebuilds it from scratch. 4. **FX / swap primitives** that treat a rate as a perishable, timestamped fact. 5. **Typographic accounting conventions.** Case-shifted parentheses, single/double rules, em-dash nil. 6. **A documented motion system** where the frequency of an interaction determines whether it animates. 7. **Designed responsive collapse for dense financial tables** — not `overflow-x: auto`. ## Our position > Lopifi is to fintech what shadcn/ui is to general UI: not a template you fork, a **set of owned primitives you compose** — with a domain model where a template has only a layout. Concretely, we differentiate on: - **Correctness as design.** The formatting rules are part of the component, not the consumer's problem. - **Statements.** The thing nobody has built. - **Motion doctrine.** A written law about when *not* to animate, which is rarer and more valuable than a library of animations. - **Free to install.** Shadcnblocks charges $79/stack for marketing sections. We ship product surfaces for nothing, under the site's terms rather than an open-source licence. ## Explicitly borrowed - **Distribution model** from shadcn/ui — `registry.json`, namespaced installs, copy-paste ownership. Don't reinvent it; it's correct. - **Charting engine** — wrap, never rebuild. - **Accessible primitives** — Base UI (preferred) or Radix for anything with keyboard/focus behavior. Never hand-roll a combobox. - **Toasts** — Sonner. **Drawers** — Vaul. Both are the reference implementations of our own motion doctrine; there is nothing to improve. ## Explicitly rejected Named, because a system is defined as much by what it turned down. All three are respectable work; none of them survives contact with a money surface. | Rejected | Source | Why not | |---|---|---| | **Disney's 12 principles** | `raphaelsalaja/12-principles-of-animation` | Anticipation, squash-and-stretch, follow-through and overshoot exist to make a character read as alive. A balance is not alive. Each one adds time before the number is legible, and three of them distort the glyph while the eye is on it. We take exactly one — *slow in, slow out* — and we call it easing. | | **Glassmorphism** | `iart-ai/glassmorphism` | Wants `backdrop-filter` on large surfaces, which we forbid outright ([elevation](foundations/04-elevation-borders-shadows.md#backdrops)). It also places a variable, uncontrolled background behind text whose contrast we claim at 7:1. Not a stylistic objection — a correctness one. | | **Scroll-driven narrative motion** | `MengTo/animation-on-scroll`, `gsap-scrolltrigger-storytelling` | Built for content-led pages where scrolling *is* the interaction. In a ledger, scrolling is how you reach row 4,000, and anything attached to it is friction between the user and a number. | The [registry](https://www.ui-skills.com/) these come from is worth reading in full. The point is only that "a good motion skill" and "a good motion skill *for this*" are different claims, and the second one is the one that matters. ## Re-check cadence This landscape moves. Re-audit before each major milestone, and specifically check whether shadcn's own registry has absorbed any of the gap above. --- # Product teardowns The [moodboard](02-moodboard.md) establishes the visual register. This document is the operating layer: **how the products in this category actually behave**, product by product, with a specific extraction from each. Two cohorts, and Lopifi has to serve both: - **The spend/banking layer** — Ramp, Mercury, Brex, Slash, Rho, Navan, Bill. Where money moves. Consumer-grade polish, high-frequency use, strong opinions about the primary action. - **The accounting layer** — Puzzle, Digits, Numeric, FloQast, Campfire, Rillet, Xero, QBO, NetSuite. Where money is *recorded*. Dense, workflow-heavy, historically ugly, and where the biggest design opportunity sits. > The pattern that recurs across every good product here: **make the core action obvious for one specific role, show the numbers competitors hide (fees, rates, limits), and treat deliberate friction in high-stakes flows as a feature.** That sentence is close to a summary of this entire design system. --- # Cohort 1 — Spend & banking ## Ramp The category leader on product design, and the clearest model for how a finance UI should *think*. **What it does that matters:** - **Leads with the exception, not the aggregate.** The dashboard's job isn't "here's your spend", it's "here are the four things that are wrong". Anomalies, policy violations, duplicate vendors, unused subscriptions. - **The table is the hero surface.** Ramp invests the same craft in row density, column hierarchy, and bulk selection that most products spend on a marketing hero. Transaction review is the product. - **Bulk operations are first-class.** Select 40 transactions, categorize them, create a rule from the pattern. Not a power-user escape hatch — the main path. - **Savings framed as a product outcome.** Every screen quantifies what it saved you. - **AI suggestions with a visible basis** — "coded to Software because the last 6 charges from this vendor were", not an opaque confidence score. **Lopifi takes:** - `AnomalyCard` in the core catalog, and no generic "welcome back" hero anywhere. - `BulkActionBar` and `CategoryPicker`, with rule creation inline from a single transaction and a match-count preview. - **Suggestions always show their basis.** Encoded as a rule in [icons & microcopy](foundations/07-icons-and-microcopy.md) and in `ReconciliationView`'s match suggestions. - The transaction table gets the same design budget as the balance card. **Lopifi refuses:** - Marketing-grade motion inside the product. Spend review is a hundreds-of-times-a-day task; the [frequency law](motion/01-principles.md) makes it near-motionless. - Framing every number as a saving. Accounting has to present neutral facts, including bad ones. ## Mercury The design benchmark. Recognizable without a logo — which is the highest compliment a design system gets. **What it does that matters:** - **Accent scarcity as a discipline.** 16 structural tokens, 2 brand tokens. One cobalt, spent only on the primary action. - **Decorative blues that never touch a button.** Scenery and affordance are strictly separated. - **A custom typeface at an unusual weight (480)** — confident without shouting. - **Leads with one trusted number.** The balance is the page. - **Onboarding that finishes.** Account opening completes in one sitting, and the product doesn't nag afterwards. **Lopifi takes:** the one-accent law · the 16:2 structural-to-brand token ratio · the decoration/affordance separation · one trusted number per surface. All of it is in [color](foundations/03-color.md). **Lopifi refuses:** dark-first. Our users read dense numeric tables for eight hours next to a spreadsheet, and often print. Light-first, dark as a full peer. Also refused: depending on a proprietary display face. The taste has to survive on Inter + Geist Mono, which is a harder constraint and a better one. ## Brex **What it does that matters:** - **A brand color so committed it's recognizable without a logo.** The orange is the product. - **Policy encoded into the card itself** — limits, categories, and vendor restrictions enforced at the point of spend rather than caught at review. Prevention over detection. - **Travel, expense, and card unified** in one flow instead of three products. **Lopifi takes:** - **Prevention over detection**, as a general rule. `BillForm` runs duplicate detection *before* save. `ExpenseCapture` shows policy violations at entry, not at approval. A control that fires at the point of action costs a second; the same control at review costs a week. - The willingness to commit hard to one accent. Ours is ink blue rather than orange, but the commitment is the same idea. **Lopifi refuses:** brand color as a functional color. Brex's orange appears in states as well as branding; we keep semantic and brand strictly separate, so "green" always means "favourable" and never means "us". ## Slash The vertical-specific take: purpose-built accounts and cards for particular business models, with working capital and credit lines layered in. **What it does that matters:** - **Purpose-scoped accounts.** Money is organized by what it's *for* — a campaign, a client, a product line — rather than one pooled balance. This is closer to how businesses actually think than a single account is. - **Credit and cash presented in one position view.** Available-to-spend includes the line, and it says so. **Lopifi takes:** - **The dimension model is the generalization of this.** Purpose-scoped accounts are just a dimension on the ledger — see [the accounting model](domain/01-accounting-model.md#5-dimensions). Lopifi supports scoping every balance, statement, and chart by dimension, with the scope always stated in the header. - **Available-to-spend must state its composition** when it includes credit: cash and available credit as separate lines that sum, never as one blended figure. Blending them is how a company thinks it has runway it doesn't. **Lopifi refuses:** presenting a credit line inside a cash balance. It's a liability, and the balance sheet is unambiguous about that. ## Rho **What it does that matters:** banking, cards, AP, and expense in one platform with the **accounting integration treated as a first-class surface** rather than a settings page — sync status, mapping, and errors are visible product, not configuration. **Lopifi takes:** bank-connection status (`AccountCard`) and integration health are **product surfaces, not settings**. A stale or broken sync silently showing old numbers is the most dangerous state in a finance product, and it gets a prominent component ([`StaleIndicator`](catalog/08-system-states.md)) rather than a grey dot in an admin panel. ## Bill.com / Navan **What they do that matters:** - **Bill** — approval workflows as the entire product. The approval inbox, the mobile approve, the audit trail. Unglamorous and correct. - **Navan** — policy at the point of booking, so the violation never happens. **Lopifi takes:** `ApprovalInbox` is **mobile-first**, with the source document rendered inline. Approvals happen on phones between meetings, and an approver who has to click through to see what they're approving stops clicking through. See [payables](catalog/11-payables.md#129-approvalinbox). ## Stripe & Wise (restated) - **Stripe** owns financial tables and documentation. The currency symbol lighter than the value; consistent decimal places; the docs site *is* the product. - **Wise** owns FX honesty. Every fee itemized, the rate timestamped, "recipient gets" stated. That shape is `ConversionBreakdown` more or less verbatim. --- # Cohort 2 — The accounting layer This is where Lopifi's real opportunity is. The spend layer is beautifully designed and crowded. The accounting layer is where the workflows are hard, the incumbents are twenty years old, and the AI-native entrants are still figuring out what the UI should be. ## Puzzle AI-native GL for startups and the firms serving them. Founder-friendly setup, partner model. **Takes:** **accountant and operator are two different users of the same data, and the UI should say which it's addressing.** Lopifi handles this with density modes and role-scoped surfaces rather than two products — the same `ProfitAndLoss` is `comfortable` for a founder and `compact` for a controller closing books. ## Digits Positions as an "autonomous general ledger" — AI agents in place of bookkeeping labor. **Takes:** the ambition to make the ledger *self-maintaining* is right, and the UI consequence is that **every automated action must be inspectable and reversible**. Lopifi's rule: an auto-matched item is reviewable and reversible, and auto-categorization rules default to `autoPost: false`. An automation you cannot inspect is one you cannot defend at audit. **Refuses:** full autonomy as the default posture. The accountant is accountable for the numbers; the UI's job is to make their review fast, not to remove them from it. Every suggestion in Lopifi shows its basis and requires a human disposition on anything material. ## Numeric, FloQast, BlackLine The close-management lane: task checklists, account reconciliations, flux review, sign-offs, team status. **Takes — and this is the largest structural borrow in the document:** - **The close is a first-class object with a pipeline, owners, gates, and sign-off.** `CloseStatus`, `CloseChecklist`, `AccountTieOut`, `FluxAnalysis` are core catalog, not add-ons. See [periods & close](domain/02-periods-and-close.md#4-the-close-pipeline). - **Flux review with required explanations** on material variances, and unexplained material variances as the reviewer's worklist. - **Sign-off is irreversible, audited, and bound to a version of the numbers.** - **Every balance sheet account needs supporting evidence**, and an account without any is itself a finding. **Refuses:** living beside the ledger as a separate system. In Lopifi the close operates on the same components that render the statements, so a tie-out failure is visible on the balance sheet itself rather than in a parallel checklist tool. ## Campfire / Rillet AI-native ERP for mid-market: multi-entity consolidation, complex close, revenue recognition at scale. **Takes:** **multi-entity is not an enterprise upsell, it's table stakes**, and retrofitting it is brutal. Lopifi makes `entityId` a required dimension on every transaction from day one, and `EntitySelector`, `ConsolidationView`, and `IntercompanyMatrix` are core — see [multi-entity & FX](domain/09-multi-entity-and-fx.md). ## Xero & QuickBooks The incumbents. Enormous installed base, and the products every user is comparing you against whether you like it or not. **Takes:** - **Bank reconciliation as the daily habit loop.** Xero's rec screen is the most-used screen in small-business accounting, and its "suggest a match, one click to accept" loop is genuinely good. `ReconciliationView` inherits it. - **The chart of accounts is a user-facing object**, not schema. People customize it constantly. - **Integration breadth is a moat**, so components must tolerate imported data being messy, partial, and read-only. Hence `source: "integration"` entries being read-only with a link back. **Refuses:** - Twenty years of accumulated modal-inside-modal navigation. - Treating mobile as a cut-down viewer. Approvals, receipt capture, and balance checks are genuinely mobile-primary tasks — see [responsive & input](craft/04-responsive-and-input.md). - The single-signed-amount column. Both incumbents blur debit/credit in places, and it costs users the ability to verify. ## NetSuite **Takes:** the depth of the data model — dimensions, subsidiaries, revenue arrangements, allocation schedules — is genuinely what mid-market needs, and it's why the domain layer in these docs is as detailed as it is. **Refuses:** everything about the interface. NetSuite is the standing proof that a correct data model with a hostile UI is a business opportunity, and it is the single biggest reason to build this. --- # Synthesis | Layer | Best-in-class | What Lopifi takes | |---|---|---| | Visual system | **Mercury** | Accent scarcity, token ratio, one trusted number | | Table craft | **Ramp**, Stripe | The table is a hero surface; bulk ops are the main path | | Exception surfacing | **Ramp** | Show what's wrong, not what's normal | | Prevention over detection | **Brex**, Navan | Controls fire at entry, not at review | | Fee & rate honesty | **Wise**, Stripe | Itemize everything; timestamp every rate | | Approval workflow | **Bill**, Rho | Mobile-first inbox with the document inline | | Close management | **Numeric**, FloQast | The close as a first-class gated pipeline | | Reconciliation loop | **Xero** | Suggest, one-click accept, inspect and reverse | | Multi-entity depth | **Campfire**, NetSuite | Entity as a required dimension from day one | | Automation posture | **Digits** (partially) | Inspectable and reversible; never silently autonomous | | Dimension thinking | **Slash** | Purpose-scoped money, generalized as dimensions | | Speed as aesthetic | **Linear** | Keyboard actions are motionless | | Documentation | **Stripe**, Sonner | The docs site is the product | ### The gap nobody has filled Every product above is either **beautifully designed and shallow on accounting** (the spend layer) or **deep on accounting and visually neglected** (the accounting layer). Numeric and FloQast manage the close but don't own the ledger. Ramp and Mercury own the experience but stop at the bank feed. **Lopifi's position is the intersection**: Mercury's restraint and Ramp's table craft, applied to Numeric's close and NetSuite's data model, distributed as components you own. That's the whole thesis, and it's why the [domain layer](domain/) in these docs is as long as the design layer. --- # Standards and sources What this system implements is mostly not its own invention. These are the standards the rules come from, what is taken from each, and where a claim here is an opinion rather than a citation. Stated for two reasons. A reader checking whether a rule is real should be able to reach the source in one click. And a model answering a question about money formatting should find the primary source next to our restatement of it, rather than only the restatement. --- ## Currency and number formatting | Source | What we take | |---|---| | [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) | Currency codes and minor-unit exponents. JPY 0, USD 2, KWD 3. Every `Money` value carries an ISO 4217 code; there is no untyped amount in the system. | | [Unicode CLDR](https://cldr.unicode.org/) | Locale formatting — separators, symbol position, accounting parentheses. Reached through `Intl.NumberFormat` rather than reimplemented. | | [ECMA-402](https://tc39.es/ecma402/) | The `Intl` API itself, including `currencySign: "accounting"` and `currencyDisplay: "narrowSymbol"`, which do most of the work in [formatting money](guides/01-format-money-in-react.md). | | [Unicode U+2212](https://www.compart.com/en/unicode/U+2212) | The real minus sign. Not U+002D, which is a different width and breaks a column of deltas. | | [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) | Dates on the wire. Displayed dates are localised; transmitted dates never are. | ## Accounting Component behaviour follows these; component *design* does not claim to be a compliance product, and none of this is accounting advice. | Source | Where it shows up | |---|---| | [FASB Accounting Standards Codification](https://asc.fasb.org/) | The US GAAP frame behind [statement semantics](domain/03-statement-semantics.md) and [schedules](domain/04-schedules-and-accruals.md). | | ASC 606 / [IFRS 15](https://www.ifrs.org/issued-standards/list-of-standards/ifrs-15-revenue-from-contracts-with-customers/) | Revenue recognition, deferred revenue and the revenue waterfall. | | ASC 842 / [IFRS 16](https://www.ifrs.org/issued-standards/list-of-standards/ifrs-16-leases/) | The lease liability unwind and ROU amortisation behind [`LeaseSchedule`](/components/lease-schedule) and the [lease tool](/tools/lease). | | ASC 830 / [IAS 21](https://www.ifrs.org/issued-standards/list-of-standards/ias-21-the-effects-of-changes-in-foreign-exchange-rates/) | Functional versus presentation currency, translation versus remeasurement, and CTA — see [multi-entity and FX](domain/09-multi-entity-and-fx.md). | | [COSO internal control framework](https://www.coso.org/guidance-on-ic) | Segregation of duties, approval and evidence, in [controls and audit](domain/08-controls-and-audit.md). | | [ISO 20022](https://www.iso20022.org/) | Payment message vocabulary, where our payment states have to map onto a rail. | ## Interface | Source | What we hold ourselves to | |---|---| | [WCAG 2.2 AA](https://www.w3.org/TR/WCAG22/) | Contrast, focus visibility, target size, and the rule that no state is signalled by colour alone. Financial value colours are checked at greyscale as well as at ratio. | | [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) | Grid, listbox, dialog and disclosure behaviour — including correct `aria-rowcount` under virtualisation, which is where most data grids quietly fail. | | [`prefers-reduced-motion`](https://www.w3.org/TR/mediaqueries-5/#prefers-reduced-motion) | Honoured throughout, and testable live from the switch in the [showcase](/showcase) header. | | [OpenType `tnum` and `case`](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt#tnum) | Tabular figures and case-shifted punctuation, on every numeral. See [aligning numbers](guides/03-align-numbers-in-a-table.md). | ## Where we are opinionated rather than compliant These are ours, and they are arguments rather than citations: - **Never animating a value a reader might take as the truth.** No standard requires this. The reasoning is in [motion principles](motion/01-principles.md). - **Debit and credit as two columns, with no single-column variant.** Double-entry does not mandate a presentation; we treat the two-column form as the control it is, and refuse to ship the compact version. - **Three sizes and three weights per component, one accent per view.** A house rule, stated so it can be argued with. - **The words we refuse.** The [lexicon](domain/11-lexicon.md) names one term per concept and lists the synonyms we do not use, which is a stronger claim than any standard makes. Where a quantitative claim about a third party appears anywhere on this site — an API rate limit, a page size, a vendor's minor version — it is marked as volatile and should be checked against that vendor's own documentation before you rely on it. --- # Roadmap Build order is deliberate. Each milestone proves something the next depends on. --- ## M0 — Foundations (docs complete, tokens shipped) **Deliverable:** `@lopifi/theme` installable, and every claim in the design docs demonstrable. - [ ] `tokens.css` — the complete set from `foundations/`, both themes, plus the signal palette - [ ] Tailwind v4 `@theme` + v3 preset - [ ] `scripts/check-tokens.ts` — contrast ratios computed from tokens, grid conformance on every spacing value, **failing the build** on violation - [ ] Font subsetting + `size-adjust` fallback metrics, CLS measured at zero - [ ] Docs site skeleton with theme / density / signal / reduced-motion toggles **Exit gate:** a contrast-failing token change fails CI. --- ## M1 — The money proof **Deliverable:** correctness, provable. The thing that makes this a finance library. - [ ] `lib/money.types.ts`, `format-money.ts`, `allocate.ts`, `fiscal-period.ts` - [ ] `Money` · `Delta` · `Figure` · `Percent` · `Rule` - [ ] **Property-based tests** (`fast-check`): `format(parse(x)) === x` over all valid inputs; `allocate(total, w).sum() === total` over all inputs - [ ] Every currency edge case: JPY 0dp, KWD 3dp, `bigint`, negative zero - [ ] `tnum` + `case` verified visually — a rendered column of mixed-sign values with proven decimal alignment - [ ] The greyscale test automated as a visual-regression assertion **Exit gate:** a public "Why most money components are wrong" doc page, with live side-by-side comparisons. This is the marketing artifact and it writes itself if M1 is done properly. **Why first:** every component above this inherits its correctness. And the `case`/paren-alignment craft is the hardest thing to retrofit. --- ## M2 — Surfaces - [ ] `Card` base · `BalanceCard` · `KpiCard` · `StatCard` · `KpiRow` - [ ] `Sparkline` · `Trend` · `StatusPill` · `AsOf` · `Redacted` · `Reference` · `EntityMark` - [ ] `EmptyState` (3 variants) · `ErrorState` · `Skeleton` · `StaleIndicator` - [ ] The isometric object library + the first five scenario illustrations - [ ] Elevation, borders, and dark-mode top-highlight validated across every surface **Exit gate:** a dashboard built entirely from these that looks finished, at 390px and 1280px. --- ## M3 — The table **Deliverable:** the workhorse, at every width. - [ ] `TransactionTable` · `TransactionRow` · `TransactionCard` · `TransactionDetail` - [ ] Virtualization with correct `aria-rowcount`/`aria-rowindex` - [ ] Full grid keyboard model — arrows navigate, one tab stop - [ ] All four responsive presentations, **including the 768–1024 sticky-both-edges pattern** - [ ] `FilterBar` (URL-backed) · `BulkActionBar` · `ColumnManager` · `ExportMenu` - [ ] `use-density` + `DensityToggle` **Exit gate:** 10,000 rows at 60fps **and INP under 200ms** on a mid-range Android at 4× CPU throttle; full keyboard-only operation; the 390px card list is genuinely usable, not a compromise. --- ## M3.5 — The ledger core **Deliverable:** the general ledger the statements are a view over. Built before the statements, because a statement that can't drill down to journal entries is a picture of a statement. - [ ] `JournalEntry` · `JournalEntryForm` · `JournalEntryTable` — **two columns, always** - [ ] `AccountPicker` · `ChartOfAccounts` · `AccountRegister` · `DimensionPicker` - [ ] `AdjustingEntryForm` with auto-reverse defaulting on for accruals - [ ] `EntryReversalDialog` — and **no delete path anywhere on a posted entry** - [ ] `PeriodStatus` · `CutoffWarning` — the period state machine - [ ] `RollForward` · `ScheduleTable` · `AccountTieOut` · `SchedulePreview` - [ ] `AuditTrail` · `EvidencePanel` - [ ] `IntegrityCheckPanel` with all eleven assertions wired **Exit gate:** post an entry, reverse it, and prove from the UI alone that history was never rewritten. Every balance sheet account has a tie-out, and a failing tie-out owns the screen. --- ## M4 — Statements **The differentiator.** Nothing else in the ecosystem has this. - [ ] `StatementTable` · `StatementRow` · `Rule` in anger - [ ] `ProfitAndLoss` · `BalanceSheet` · `CashFlowStatement` - [ ] `StatementToolbar` · `PeriodPicker` (fiscal-aware) · `VarianceCell` - [ ] `DrilldownSheet` · `FluxAnalysis` - [ ] Full statement semantics for screen readers - [ ] **Print stylesheet** — multi-page, repeating headers, no orphaned totals - [ ] Phone presentation: one period at a time, swipeable, fixed label column **Exit gate:** an accountant who closes books reviews a rendered P&L and balance sheet and finds nothing wrong. That's the real test, and it should happen before the code is called stable. **Why here:** it's the hardest thing to retrofit, the biggest differentiator, and it stress-tests the primitives harder than anything else. --- ## M4.5 — The close **Deliverable:** the workflow the statements exist for. This is the Numeric/FloQast lane, operating on the same components that render the statements rather than beside them. - [ ] `CloseStatus` — the seven-section gated pipeline - [ ] `CloseChecklist` with owners, blockers, weighted progress, and audited sign-off - [ ] `ReconciliationView` with basis-stated match suggestions + `CategoryPicker` with inline rule creation - [ ] `FluxAnalysis` — required explanations on material variances, with drivers - [ ] `MaterialityPolicy` · `RestatementBanner` · `SubsequentEventLog` - [ ] `ApprovalChain` · `ApprovalInbox` (mobile-first) · `RoleMatrix` **Exit gate:** a full month-end close run end to end in the product, by someone who closes books for a living, with no spreadsheet. --- ## M5 — Receivables (AR) - [ ] `InvoiceForm` with the per-line revenue-recognition preview · `InvoiceDetail` · `InvoiceRegister` - [ ] `AgingTable` tied to the AR control account - [ ] `CashApplication` + `PaymentSuggestion` — **every case**, not the happy path - [ ] `UnappliedCashPanel` · `CreditNoteForm` · `CustomerStatement` - [ ] `CollectionsWorklist` with promise-to-pay · `DunningTimeline` - [ ] `RevenueWaterfall` from performance obligations **Exit gate:** a lump-sum payment covering seven invoices with a short pay and a bank fee applies correctly, with every difference given an explicit disposition. --- ## M6 — Payables (AP) - [ ] `BillForm` + `DuplicateBillAlert` (blocking) · `BillDetail` · `BillRegister` - [ ] `ThreeWayMatch` with line-level variance · `MatchExceptionQueue` - [ ] `ApprovalChain` version-binding — editing a bill voids its approvals - [ ] `PaymentRunBuilder` · `PaymentRunPreview` with hold-to-confirm and resulting cash - [ ] `VendorMaster` with versioned bank details · `BankDetailChangeAlert` - [ ] `ExpenseCapture` (camera-first) · `CardTransactionMatch` - [ ] `UninvoicedReceiptsReport` driving the GR/IR accrual **Exit gate:** segregation of duties cannot be bypassed by any path in the UI, and a vendor bank-detail change before a payment run is impossible to miss. --- ## M7 — Payments - [ ] `AmountInput` — with the full parse/format/caret test suite - [ ] `PaymentForm` · `CurrencySelect` · `PayeeSelect` · `PaymentMethodList` - [ ] `ConfirmPaymentDialog` · `HoldToConfirm` **with all accessible fallbacks** - [ ] `PaymentStatusTimeline` · `PaymentSuccess` · `CheckoutSummary` - [ ] `Sheet` (Vaul) · `Toast` (Sonner) configured - [ ] Full gesture layer: velocity dismissal, boundary damping, pointer capture, multi-touch protection **Exit gate:** the whole payment flow completed one-handed on a real iPhone SE, over a throttled network, with VoiceOver on. --- ## M8 — Swaps & FX - [ ] `SwapWidget` + the flip · `RateLine` · `RateLock` · `ConversionBreakdown` - [ ] `CurrencyPairChart` · `RateTicker` · `SlippageControl` · `FxExposure` - [ ] Live-value infrastructure: `use-live-value`, the value flash, throttled `aria-live` **Exit gate:** a rate expiring mid-transaction is handled correctly — the action blocks until the user acknowledges the new rate. Never executes at an unseen rate. --- ## M9 — Charts, dashboard & metrics - [ ] `MoneyChart` · `MoneyBarChart` · `WaterfallChart` · `CategoryBreakdown` - [ ] `ChartLegend` · `ChartTooltip` · `DataTableToggle` - [ ] `DashboardShell` · `AnomalyCard` · `RunwayCard` · `CashCalendar` - [ ] Money Law 2 enforced in tests: assert **no animation on data change** - [ ] `MetricDefinition` — and **no metric card ships without one** - [ ] `NonGaapReconciliation` · `MrrBridge` · `BudgetVsActual` · `CovenantMonitor` **Exit gate:** every chart has a working table equivalent; every metric shows its formula with values substituted; the greyscale test passes on all of them. --- ## M10 — Multi-entity & consolidation - [ ] `EntitySelector` — standalone vs. consolidated visually distinct - [ ] `ConsolidationView` · `NciPanel` · `EliminationEntry` - [ ] `IntercompanyMatrix` — must net to zero, break types named - [ ] `TranslationSummary` · `CtaRollForward` · `FxRevaluation` · `FxGainLossPanel` · `RateTable` **Exit gate:** a three-entity group with two functional currencies consolidates, eliminates, translates, and ties — with the CTA visible as its own equity line. --- ## M11 — Completeness & polish - [ ] Remaining specified components - [ ] `CommandPalette` (motionless), the reconciliation match animation - [ ] `LedgerTable` · `TrialBalance` · `AuditorMode` · `SamplingTool` · `EvidenceBundle` - [ ] `LeaseSchedule` · `AllowanceRollForward` · `ForecastTable` - [ ] Full visual regression matrix: every component × {light, dark, forced-colors, signal} × {compact, default, comfortable} × {320, 768, 1280} - [ ] Motion review pass — every animation at 4× slowdown, frame-stepped - [ ] An [audit pass](craft/03-quality-bar.md#the-audit-pass) per shipped surface — three findings maximum, plans written to `design-plans/` - [ ] The next-day review on every component --- ## Deferred | Item | Gate | |---|---| | `corner-shape: squircle` | Baseline Widely Available. Progressive enhancement, no layout change. | | View Transitions | Not for in-app finance navigation. Revisit only if interruption handling improves. | | Figma library | After M4, so it's generated from settled components rather than guiding them. | | Additional themes | Never as a marketplace. A consumer overrides `--accent` and `--font-display`; anything more is a fork. | | Vue / Svelte ports | After the React system is stable. The tokens and the docs port; the components don't. | --- ## Definition of done A component is `stable` only when **all** of these are true: 1. The four-layer spec exists, was reviewed **before** the code, and ships in its sibling `README.md` 2. The full [quality bar](craft/03-quality-bar.md) passes — every section, 4b included 3. Every edge case in the checklist has a test or a story 4. `axe` clean; keyboard-only pass; VoiceOver **and** NVDA pass 5. Visual regression across the full matrix 6. Motion reviewed at 4× slowdown on a throttled real device 7. Financial review by someone who has actually closed a month — and for anything in families 9–13, a review against the [domain docs](domain/) clause by clause 8. **The next-day review is done** Merged is not stable. Step 8 is not optional — it catches more real problems than every automated check combined. --- # How to format money in React Currency formatting looks like a solved problem until the first JPY amount, the first negative, and the first value you do not have. This is the whole method, in the order the bugs arrive. --- ## The short version ```tsx new Intl.NumberFormat(locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", currencySign: "accounting", }).format(amount / 10 ** scale); ``` Four decisions are already made for you by those options, and each one is a bug you now do not have. The rest of this page is what the snippet does not cover. --- ## 1. Do not store money in a float ```ts 0.1 + 0.2 === 0.30000000000000004 ``` In a ledger that is a reconciliation which never balances and a support ticket nobody can reproduce. Store an integer of minor units, with the currency travelling alongside it: ```ts type Money = { /** Integer minor units. 1234567 = $12,345.67 */ amount: bigint | number; /** ISO 4217 */ currency: string; /** Minor-unit exponent. Defaults from the ISO table. */ scale?: number; }; ``` The currency code is not decoration. An amount without one is a number that renders differently in two places and cannot be summed with anything. The full argument is in [numerics](../foundations/06-numerics.md#1-storage-minor-units-always). **Components never do arithmetic.** They format and lay out. Pass totals in, already computed by whoever owns the rounding policy — a component that sums its own rows will eventually disagree with the backend, and the backend is right. ## 2. Decimal places are per-currency `JPY` has none. `USD`, `EUR` and `GBP` have two. `KWD`, `BHD`, `JOD` and `TND` have three. Crypto goes to eighteen. `toFixed(2)` is wrong in every one of those cases except the second. ```ts const digits = new Intl.NumberFormat(locale, { style: "currency", currency }) .resolvedOptions().maximumFractionDigits; ``` Let `Intl` answer for most currencies, backed by the [CLDR](https://cldr.unicode.org/) data your runtime already ships — but CLDR's digits are *display* precision, and for about seventeen currencies they differ from the [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) minor unit your bank and processor store amounts in. In V8 (Chrome, Edge, Node), Intl says 0 for HUF, IDR, COP, PKR and IQD; ISO says 2 (3 for IQD). Keep a small override table for those, or HUF 12,345 minor units renders as 12,345 Ft instead of 123.45 Ft. ## 3. Locale is not currency They are two different questions and they get two different answers. `en-US` renders a EUR amount as `€1,234.56`; `de-DE` renders it `1.234,56 €`. Both are correct. > Format in the **viewer's** locale, in the **value's** currency. A German accountant reviewing a US subsidiary sees `1.234,56 $`, and that is right. Deriving the locale from the currency — a common shortcut — gives that same accountant American separators in a German interface. ## 4. Negatives are parenthesised, in the right places `currencySign: "accounting"` gives you `(1,234.00)` natively and locale-correctly. Use it in statements, ledgers and tables. Use an explicit `−` for deltas and KPIs, where a value stands alone rather than in a scanned column. The alignment problem this creates has its own page: [negative numbers in a financial table](02-negative-numbers-in-financial-tables.md). ## 5. `null` is not `0` The most common correctness bug in financial UI, and the cheapest to fix: | State | Renders | Means | |---|---|---| | True zero | `—` in statements, `$0.00` in tables and inputs | We know it, and it is zero | | Unknown | `n/a`, tertiary | We do not have it | | Redacted | `••••`, tertiary | We have it, you may not see it yet | | Loading | a structural skeleton | In flight | `value ?? 0` collapses four states into one and prints a number nobody computed. Type the value as `Money | null` and branch explicitly — three render paths, no fallback. ## 6. Abbreviate only where nobody relies on it `$1.2M` belongs on a chart axis and a KPI tile. It is forbidden anywhere a person might treat the figure as a record: a statement line, a ledger row, an invoice total, an input. See [abbreviation](../foundations/06-numerics.md#abbreviation). --- ## What this looks like as a component ```tsx // $1,240.56 // ($1,240.56) // n/a // — ``` Every rule on this page is enforced inside one primitive rather than repeated at every call site. Install it, and the rest of the system inherits the behaviour: ```bash npx shadcn@latest add @lopifi/theme @lopifi/money ``` - [`Money`](/components/money) — the primitive, with its four-layer spec and full source - [`Percent`](/components/percent) — because percent and percentage points are different numbers - [`Delta`](/components/delta) — a signed change, with polarity stated rather than assumed - [See them rendering](/showcase/primitives) — live, with theme and density switches - [Number cleaner](/tools/clean) — paste `(1,234.56)`, `1.234,56` or `1,234.56-` and get signed values back - [Amount in words](/tools/words) — for the cheque, the note and the contract schedule --- # Negative numbers in a financial table Parentheses instead of a minus sign, the real minus where a sign is right, and the column that stays aligned when the sign changes. Three rules, one of which almost nobody implements. --- ## Which convention, where | Context | Format | Why | |---|---|---| | Statements, ledgers, tables | `(1,234.00)` | The accounting standard. The reader is scanning a column, and parentheses are unmissable in peripheral vision in a way a leading hyphen is not. | | Deltas, KPIs, inline prose | `−$1,234.00` | A delta is a change, not a balance. In isolation an explicit sign reads faster than a bracket. | Both are coloured `--negative`, and **neither depends on colour** — the parens and the sign are the primary signal, which is what makes the interface survive a greyscale print and a red-green colour deficiency. You can check that claim yourself: the [showcase](/showcase/statements) has a greyscale toggle in its header. Do not mix the two conventions inside one table. A column where some negatives are bracketed and some are signed reads as two different measurements. ## Use the real minus `−` is U+2212 MINUS SIGN. `-` is U+002D HYPHEN-MINUS, and it is a different glyph: shorter, lower, and a different width from `+`. In a column of deltas, that width difference means `+12.4%` and `-12.4%` do not line up. `Intl` does not save you here: `en-US` and most locales emit U+002D, and only a few (Swedish, Finnish and others) emit U+2212. Swap the sign after formatting, or use `currencySign: "accounting"` and parentheses. The same bug arrives when someone hand-builds the string: ```ts `-${format(Math.abs(value))}` // hyphen-minus. Wrong width, breaks the column. ``` ## The alignment problem This is the one that gets skipped. In a right-aligned column mixing `1,234.00` and `(1,234.00)`, the closing paren pushes the digits about 4px left, and the decimal points stop lining up down the column. The effect is subtle enough to survive review and obvious enough to look amateur next to a printed statement. Three fixes, in order of preference: **1. Reserve the paren column.** Render an invisible `)` on positive values. ```tsx ) ``` Cheapest, works everywhere, and keeps copy-paste clean as long as you strip `aria-hidden` content on copy. **2. Grid the cell.** `display: grid; grid-template-columns: auto 1fr auto;` — open paren, figure, close paren. The most robust option for mixed content. **3. Pad the cell.** `padding-right: 1ch` on positives. Only correct under `font-variant-numeric: tabular-nums`, where `1ch` is a real fixed unit. Give the sign glyph in a delta column the same treatment: reserve the slot, so a list of changes stays aligned even when some rows are unchanged. ## Sign is not sentiment A negative number is not bad news. A `−4.2%` change in burn is good; a `−4.2%` change in revenue is not. Colouring by sign is how a dashboard reports cheerful green on a cost overrun. So polarity is a prop, and it is explicit: ```tsx // ↑ +$8,200.00 — coloured negative, because up is bad for this metric ``` `standard | inverted | neutral`, stated per metric. There is no defensible default, so the component does not pick one for you. ## Zero is not a small negative A true zero in a statement renders as an em dash — `—` — which says *computed, and nil*. A blank cell says *not applicable here*. `n/a` says *we do not have this*. Three states, three glyphs, and none of them is `$0.00`. (In transactional tables and inputs, where a figure is typed rather than read as a total, `$0.00` is fine — see [formatting money](01-format-money-in-react.md).) --- ## Where this is implemented - [`Money`](/components/money) — `accounting` and `statement` modes, and the reserved paren column - [`Delta`](/components/delta) — the polarity contract, and the reserved sign slot - [`StatementRow`](/components/statement-row) and [`StatementTable`](/components/statement-table) — negatives and rule lines together - [`VarianceCell`](/components/variance-cell) — where a signed number and a polarity meet a budget - [Statements, running](/showcase/statements) — flip a value negative and watch the column hold - [Numerics](../foundations/06-numerics.md#negative-alignment) — the full rule, with the three fixes ranked - [Colour](../foundations/03-color.md) — why the sign never depends on hue alone --- # How to align numbers in a table Right-aligned is not aligned. Getting a column of money to read as a column is four settings and one rule line, and it is the difference between a finance product and an admin template with a dollar sign. --- ## 1. Turn on tabular figures By default most typefaces set numerals proportionally — a `1` is narrower than a `0`. That is correct for prose and wrong for a column, where it means the digits in row four do not sit above the digits in row five. ```css .numeric { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; } ``` Inter has `tnum`. So does almost every UI typeface worth using. Switch it on for every numeral a reader might compare with another numeral — which in a finance interface is all of them, including dates, reference numbers and quantities. ## 2. Turn on case-shifted parentheses `case` (`cv##` in some fonts) shifts brackets, dashes and other punctuation up to sit optically centred against lining figures rather than lowercase text. Without it, `(1,234.00)` has parentheses that hang slightly low against the digits. ```css font-feature-settings: "tnum" 1, "case" 1; ``` Both features, always, on anything numeric. This pairing is the single cheapest credibility gain in a financial interface. ## 3. Align on the decimal point, not the right edge Right-alignment gets you most of the way only because fixed decimal places make the two equivalent. Which is the actual rule: > Fixed decimals throughout. `$1,200.00`, never `$1,200`. `Intl` does this by default. The bug is introduced by someone trimming trailing zeros to "clean up" the column. Once one value in a column has two decimals and another has none, right-alignment stops aligning anything. Where a column genuinely mixes scales — a rate at three decimals beside an amount at two — align on the decimal separator explicitly with a grid, rather than hoping padding holds. ## 4. Reserve the sign column A column mixing `1,234.00` and `(1,234.00)` loses its alignment to the closing paren. Reserve the slot on both sides. The three ways to do it are ranked in [negative numbers in a financial table](02-negative-numbers-in-financial-tables.md#the-alignment-problem). ## 5. Rule lines carry the arithmetic A single rule above a subtotal, a double rule under a total. This is four centuries of convention and two CSS declarations: ```tsx // border-top: 1px solid // border-bottom: 3px double ``` The rule **spans the numeric column only**, not the label column. That is what makes it read as accounting rather than as a divider. In a multi-period statement it spans each period column independently, with the gutters left unruled. Never combine a rule with a background fill on the same row — pick one, and it should be the rule. ## 6. Do not stripe the rows Zebra striping is what you reach for when the numbers are not aligned. Fix the alignment and the stripes become noise: the tabular figures do the work, and a hairline row separator handles the rest at any density. ## 7. Never animate a value the reader might trust A number counting up from zero is unreadable for the duration of the animation, and a reader who glances mid-tween takes away a figure that was never true. Crossfade a changed value if you must signal the change; never interpolate it. --- ## Where this is implemented - [`Rule`](/components/rule) — ten lines of code, the highest credibility-per-byte component here - [`StatementTable`](/components/statement-table) — hierarchy, period columns and rules together - [`LedgerTable`](/components/ledger-table) — debit and credit as two aligned columns - [`TransactionTable`](/components/transaction-table) — virtualised, and still aligned at 240 rows - [Tables, running](/showcase/tables) — with a density toggle, which is the real test of an alignment claim - [Typography](../foundations/05-typography.md) — the type system these features live in - [Numerics](../foundations/06-numerics.md#rule-lines) — rule lines, in full --- # How to build a general ledger table in React The data grid is the easy part. What makes a ledger table a ledger table is two amount columns, a visible balance check, and an entry you can reverse but never edit. --- ## 1. Debit and credit are two columns This is the single most important rendering rule in the accounting family, and the one a generic data grid gets wrong by default. ```ts type JournalLine = { accountId: string; /** Exactly one of these is non-zero. Never a single signed amount. */ debit: Money; credit: Money; dimensions: Dimensions; memo?: string; }; ``` A single signed column — `-1,240.00` — collapses two facts into one. Double-entry exists so that a person can verify the arithmetic by eye; collapse the columns and you have deleted the control while keeping the data. There is no single-column variant of a ledger table, and offering one as a "compact mode" is offering to remove the audit trail to save 90 pixels. ## 2. Show the balance check Not "validate on save". Show it, at the foot of the entry, with the totals: ``` Debit Credit 6200 · Rent expense 4,500.00 1010 · Operating cash 4,500.00 ───────── ───────── 4,500.00 4,500.00 ✓ balanced ``` An unbalanced entry is a prominent error state **with the difference stated** — never a silently saved draft, and never a discrepancy rounded away. When the difference is divisible by nine, a transposition or a slide is the likely cause, and saying so turns a ten-minute hunt into a five-second fix. That diagnosis is a [tool of its own](/tools/difference). ## 3. Show the normal balance Five account types, each with a side that increases it: | Type | Normal | Statement | Typical range | |---|---|---|---| | Asset | Debit | Balance sheet | 1000–1999 | | Liability | Credit | Balance sheet | 2000–2999 | | Equity | Credit | Balance sheet | 3000–3999 | | Revenue | Credit | P&L | 4000–4999 | | Expense | Debit | P&L | 5000–8999 | An account picker that shows `Dr` or `Cr` next to the account prevents a whole class of posting error for one glyph of ink. Contra accounts — accumulated depreciation, allowance for doubtful accounts — carry the opposite normal balance to their type and present as a deduction from their parent, so the picker has to state theirs rather than infer it from the type. ## 4. Posted entries are reversed, never edited Immutability is not a database preference here, it is the product. A posted entry gets a reversing entry with its own number and date; the original stays exactly as it was. Which means the interface has three states to render, and the affordances differ: - **draft** — editable, deletable, not in any report - **posted** — immutable; the only actions are *reverse* and *copy to new* - **reversed** — immutable, and linked in both directions to the entry that reversed it The period state gates all of it. A posted entry in a closed period cannot be reversed into that period, only into an open one — which is a different transaction with different consequences, and the UI has to say so before the click, not after. ## 5. The ledger and the statement disagree about signs The same fact renders differently depending on which representation you are in. Revenue is a credit in the ledger and a positive number in the P&L. A component that does not know which representation it is rendering will get revenue backwards — and it will look plausible, which is worse than looking broken. So the representation is a property of the component, not an inference from the value. The full argument is in [the accounting model](../domain/01-accounting-model.md). ## 6. Virtualise, but keep the keyboard A register with four thousand rows has to virtualise. It also has to stay one tab stop for the whole grid, with arrows to navigate, Space to select, Enter to open and Escape to clear — and correct `aria-rowcount` and `aria-rowindex`, which virtualisation breaks by default because the DOM no longer contains the rows the numbers refer to. Sticky header, sticky total, no zebra striping. The [tabular figures do the aligning](03-align-numbers-in-a-table.md). --- ## Where this is implemented - [`LedgerTable`](/components/ledger-table) — two amount columns, and no single-column variant - [`JournalEntry`](/components/journal-entry) and [`JournalEntryForm`](/components/journal-entry-form) — the balance check as you type - [`AccountPicker`](/components/account-picker) — grouped by type, with the normal balance shown - [`EntryReversalDialog`](/components/entry-reversal-dialog) — the only way a posted entry changes - [`TrialBalance`](/components/trial-balance) — where the whole thing has to add up - [The accounting core, running](/showcase/gl) — nineteen components, live - [Journal entry balancer](/tools/journal) — paste an entry, see the out-of-balance, copy an import-ready CSV - [Trial balance checker](/tools/tb) — balances, and then whether anything sits on the wrong side of its normal - [Journals and the general ledger](/apis/journals) — whether Xero, QBO and NetSuite will even give you the GL --- # Choosing a React component library for finance software There are good dashboard kits, good data grids and good fintech templates. There is very little that knows what a currency is. This is the honest comparison, including where you should not use this one. --- ## The question to ask first Not "does it look like finance". Ask it to render this: ```tsx ``` A dashboard kit prints `$0.00`, or `NaN`, or nothing. Those are three different bugs and all three ship. A finance-native component prints `n/a`, because it knows the difference between a number it has and a number it does not — and that distinction is the one that costs money in an accounting product. Four more, in the same spirit: 1. Render `JPY 1200`. Does it print `¥1,200` or `¥1,200.00`? 2. Put `(1,234.00)` and `9,876.00` in one right-aligned column. Do the decimal points line up? 3. Render a `−4.2%` change in **burn**. Is it green? 4. Render a journal entry. One signed column, or two? A library that passes all five is not necessarily better designed. It has just been used against a general ledger before. ## What is out there | Project | What it is | Where it stops | |---|---|---| | [shadcn/ui](https://ui.shadcn.com/) | The distribution model and base primitives | Deliberately domain-agnostic. It is the foundation here, not the competitor — this registry consumes it through `registryDependencies`. | | [Tremor](https://tremor.so/) | React dashboard components, charts and KPI blocks | Closest in spirit, and genuinely good. But dashboard-native, not finance-native: no accounting negatives, no statement rules, no multi-currency, no ledger semantics. | | [ReUI](https://reui.io/) | 1,000+ free shadcn components across 60+ categories | Breadth over depth. Generic data grid, generic cards. Nothing knows what a currency is. | | Fintech dashboard templates | Finished apps you fork and gut | A template, not a system. Fork-and-diverge rather than install-and-compose; no money primitive and no statements underneath the layout. | | [Shadcnblocks](https://www.shadcnblocks.com/) | Paid fintech and payments blocks | Marketing-page sections — hero, pricing, feature grid — with a fintech skin. Almost no product surface. | | [Recharts](https://recharts.org/) / [visx](https://airbnb.io/visx/) | Charting engines | Correct choice, wrap them. Neither knows about negative-aware baselines, money axes or fiscal periods. | | [Actual Budget](https://actualbudget.org/), [Firefly III](https://www.firefly-iii.org/) | Full open-source finance apps | Real financial correctness, zero reusability — the UI is welded to their own domain model. | The pattern is consistent: finance **templates** exist in quantity; a finance **primitive system** mostly does not. ## The gap, stated precisely Across the whole list, seven things are missing: 1. **A money primitive** — minor-unit storage, per-currency decimals, accounting negatives, `null`/`0`/redacted as distinct states. 2. **Financial statement components** — P&L, balance sheet, cash flow, with hierarchy, rollups, period columns, variance, drill-down and correct rule lines. The largest hole: not one library ships a ``. 3. **Reconciliation UI** — two-pane matching, confidence scoring, bulk match. Every accounting product rebuilds it from scratch. 4. **FX primitives** that treat a rate as a perishable, timestamped fact rather than a number. 5. **Typographic accounting conventions** — case-shifted parentheses, single and double rules, em-dash nil. 6. **A motion doctrine** where the frequency of an interaction decides whether it animates at all. 7. **Designed responsive collapse for dense tables** — not `overflow-x: auto`. ## When you should not use this Stated plainly, because a comparison that recommends itself in every case is an advertisement. - **You are building a marketing site for a fintech.** These are product surfaces. Use a blocks library. - **You need a generic admin CRUD app** and money is one field among forty. A general kit will be faster and you will not miss the ledger semantics. - **You want a finished application to fork.** This is a set of primitives you compose. A template will get you a demo sooner. - **You are locked to a component framework** — MUI, Mantine, Chakra. These are Tailwind and shadcn-shaped, and porting them costs more than the correctness is worth to you today. ## When you should - The interface will show a general ledger, a statement, a close, an AR or AP workflow, or multi-currency anything. - An accountant will look at it and form a judgement in the first four seconds. - You are prompting an AI agent to build accounting screens and need it to compose primitives that already encode the rules rather than invent formatting per screen — which is the whole argument of [vibe accounting](/vibe-accounting). ## What this borrows rather than rebuilds Distribution from shadcn/ui — `registry.json`, namespaced installs, copy-paste ownership. Charting from Recharts. Accessible behaviour from Base UI or Radix; nobody should hand-roll a combobox. Toasts from Sonner, drawers from Vaul, both already reference implementations of the motion doctrine here. The differentiation is not "more components". It is that the formatting rules are inside the component instead of being the consumer's problem, and that the statements exist at all. --- ## Judge it yourself - [Every implemented component, rendering](/showcase) — with theme, density, greyscale and reduced-motion switches, which is where a correctness claim is actually testable - [The catalog](/components) — 179 components, each with its spec, dependencies and full source - [Prior art](../03-prior-art.md) — this comparison as an internal document, with the re-check cadence - [Product teardowns](../04-product-teardowns.md) — Ramp, Mercury, Brex, Rho, Numeric, Digits, Xero, NetSuite, at the level of behaviour - [The manifesto](../01-manifesto.md) — the non-goals, which are the useful half --- # Grid & layout Every component in Lopifi is designed in **four layers, in order**. You do not skip ahead. Layer 4 (the actual type) is the *last* decision, not the first — which is why the system stays coherent when a new component is added by someone who wasn't in the original room. --- ## The four-layer method ``` Layer 1 ▸ BASE GRID 8px lattice. Nothing else exists yet. Layer 2 ▸ SAFE SPACE Padding and grouping. Where content may live, and what belongs with what. Layer 3 ▸ STRUCTURE Text slots, media placeholders, controls. Grey boxes only. Layer 4 ▸ TYPE Actual sizes and weights. Max 3 of each. ``` Each layer is a separate artboard in the design file and a separate commit in the build. A component review can reject at any layer without touching the ones after it. --- ## Layer 1 — the base grid **8px base unit.** Set as tightly as possible to preserve flexibility later — a coarse grid (12, 16) makes small controls impossible; a fine grid (2, 4) stops being a grid at all. 8 is the tightest unit that still constrains. ``` --space-0: 0px --space-0-5: 4px /* half-step — restricted, see below */ --space-1: 8px --space-2: 16px --space-3: 24px --space-4: 32px --space-5: 40px --space-6: 48px --space-8: 64px --space-10: 80px --space-12: 96px --space-16: 128px ``` ### The half-step rule `4px` exists but is **restricted to the interior of controls ≤ 32px tall** — badge padding, pill insets, icon-to-label gaps inside a small button, checkbox-to-label. It may never appear in layout: no 4px gaps between cards, no 12px card padding, no 20px section spacing. Rationale: a 32px control cannot be padded on an 8px lattice without becoming visually wrong (8px vertical padding + 16px line-height = 32px works; but a 24px control needs 4px). Everything above control scale stays on 8. **Forbidden values:** 6, 10, 12, 14, 18, 20, 28, 36, 44. If a design wants 12px, the answer is 8 or 16, and the question is which grouping was wrong. ### Heights are on the grid too ``` Control heights 24 (xs) · 32 (sm) · 40 (md, default) · 48 (lg) Table row heights 32 (compact) · 40 (default) · 48 (comfortable) Header / toolbar 56 App top bar 64 Mobile tab bar 56 + safe-area-inset-bottom ``` `md` (40px) is the default control height because it is the smallest height that comfortably clears a 44px touch target once its 4px of ambient hit-slop is counted — see [responsive & input](../craft/04-responsive-and-input.md#touch-targets). ### Layout grid - **Desktop / laptop:** 12 columns, 24px gutter, 24px page margin, max content width 1440. Below 1440 the grid is fluid; above it, centered with the canvas continuing. - **Tablet:** 8 columns, 24px gutter, 24px margin. - **Phone:** 4 columns, 16px gutter, 16px margin. Note the margin drops to 16 — this is the one place the page margin leaves 24, because 24px margins on a 390px screen cost 12% of the viewport. --- ## Layer 2 — safe space and grouping Safe space is the padding *and* the proof that things belong together. Two elements 8px apart are one thing; 24px apart they are two things. That is the entire hierarchy mechanism at this layer. ### The inset scale ``` Card / panel inset 24px ← the system default Compact card inset 16px (dense mode, or cards < 240px wide) Dialog inset 24px Sheet inset 24px (16px on phone) Table cell inset 12px v / 16px h — the one sanctioned 12, see note Toolbar inset 16px Popover / menu inset 8px (items get 8v/12h) ``` > **Table cell vertical padding is 12px.** This is the sole layout exception to the half-step rule and it is deliberate: a 40px row with 16px line-height needs 12px above and below. Documented as an exception rather than silently violated. Everything else obeys. ### The proximity ladder Distance encodes relationship. Use only these five: | Gap | Meaning | Example | |---|---|---| | **4px** | Same object | Currency symbol → figure; icon → its label | | **8px** | Same group | Label → value; two stacked metadata lines | | **16px** | Related, separate | Two fields in a form; card header → card body | | **24px** | Distinct groups | Two cards in a grid; form section → form section | | **32–48px** | Page sections | Statement section → next section; hero → content | If you need a sixth, one of your groupings is wrong. ### Safe space is not negotiable at the edge The 24px card inset is a **hard boundary**. Nothing bleeds into it except three sanctioned cases: 1. **Full-bleed dividers** — a `1px` rule that spans the full card width, used between rows in a list card. Achieved with negative margin, not reduced padding. 2. **Full-bleed media** — a chart or image that touches three edges (see media rules below). 3. **Hover/selection backgrounds** on list rows, which extend to the card edge minus the radius clearance. Everything else respects 24. --- ## Layer 3 — structure Grey boxes. No type, no color, no icons. You are deciding **how many things there are and where they sit** — and this is where most components are silently over-designed, because it's very hard to justify a fourth text slot when it's an unlabeled grey rectangle. Every Lopifi component's structure spec names its slots explicitly: ``` BalanceCard ├── eyebrow ▸ optional · 1 line · truncate ├── figure ▸ required · 1 line · never truncates, shrinks instead ├── delta ▸ optional · 1 line ├── media ▸ optional · sparkline, full-bleed bottom └── actions ▸ optional · max 2, one primary ``` ### Structural rules - **Max 3 text slots per component.** A fourth is a sub-component. - **Every slot declares its overflow behavior** — `truncate`, `line-clamp-2`, `shrink`, or `never overflows` (with the constraint that guarantees it). - **Media placeholders declare aspect ratio, never fixed height.** `aspect-[16/9]`, `aspect-[1.586]` for cards, `aspect-[3/1]` for sparkline strips. - **Controls declare their count ceiling.** "Max 2 actions, at most 1 primary." Enforce it in the type signature where you can. - **The figure never truncates.** In a finance component, a truncated number is a bug. It shrinks (via a size step-down) or the container grows. See [numerics](06-numerics.md#overflow). --- ## Layer 4 — type Covered fully in [typography](05-typography.md). The rule that lives here: > **Max 3 font sizes and 3 font weights per component.** Not per page — per *component*. This is the constraint that generates hierarchy, because with only three sizes available you are forced to spend them on the three things that actually matter. A component that genuinely needs four sizes is two components that should be composed. --- ## Optical alignment The grid gets you 95% there. These fix the rest, and they are the difference between "aligned" and "looks aligned". ### Icons align to cap height, not line box A 16px icon next to 14px/20px text sits 2px above the text's optical center if you align boxes. Align the icon's optical center to the text's **cap height** center: ```css .icon-inline { transform: translateY(-0.5px); } /* 16px icon, 14px/20 Inter */ ``` Measure per size pair; document the offset. Lopifi's values: | Icon | Text | Offset | |---|---|---| | 16px | 13px/16 | `-0.5px` | | 16px | 14px/20 | `-0.5px` | | 20px | 16px/24 | `-1px` | | 24px | 20px/28 | `-1px` | ### Right-aligned numbers hug the inset The last column of any numeric table aligns its **right edge to the card inset**, not to a cell padding inside it. A number that stops 16px short of the card edge while the header above it touches the edge is the most common table-craft failure. ### Currency symbols are optically offset `$` is taller than lining figures and sits differently from `€` and `£`. When the symbol is set at a lighter weight/color (which it always is — see [typography](05-typography.md#the-currency-symbol)), give it a `4px` gap and let it baseline-align; do not attempt vertical centering. ### Trailing punctuation and parentheses hang In a right-aligned column containing both `1,234.00` and `(1,234.00)`, the closing paren pushes the digits left by ~4px and destroys decimal alignment. Fix: reserve the paren column always — render a transparent `)` for positive values, or use a fixed-width paren cell. Detail in [numerics](06-numerics.md#negative-alignment). ### Optical centering in circles An icon inside a circular avatar or a play/chevron glyph needs `+0.5px` to `+1px` toward the direction it points. Chevrons in `>` accordions get `translateX(0.5px)`. --- ## Density modes Three modes, set by a `data-density` attribute on any container, cascading to descendants. | | `compact` | `default` | `comfortable` | |---|---|---|---| | Row height | 32 | 40 | 48 | | Card inset | 16 | 24 | 24 | | Body size | 13/16 | 14/20 | 14/20 | | Cell h-padding | 12 | 16 | 16 | | Gap ladder | ×0.75 (rounded to 4) | ×1 | ×1.25 | `compact` is for power users on large displays reviewing hundreds of rows. `comfortable` is the phone default. **Density is not a visual preference — it's an information-throughput setting**, and it must be persisted per user and respected by every component that renders a list. --- ## Layout composition rules - **Cards in a grid are equal-height by default.** Use `grid` with `auto-rows-fr`, not flex-with-stretch, so the figure baselines align across the row. - **Never nest more than two card levels.** A card inside a card inside a card is a hierarchy failure; the inner one should be a section with a rule line. - **Sticky elements are on the grid too.** A sticky table header is exactly one row height. A sticky total footer is exactly one row height + 1px top rule. - **`h-dvh`, never `h-screen`.** Mobile browser chrome makes `100vh` wrong. - **Fixed elements respect `env(safe-area-inset-*)`.** Bottom bars, floating action buttons, sticky totals. - **Scroll containers own their own padding.** Padding on a scroll parent clips the shadow of scrolled content; put it on the inner track. --- # Shape & radius **24px radius. 24px inset.** That pairing is the signature of the system, and everything else derives from it. --- ## The scale Radii are 8px multiples, because the nesting formula requires them to be (below). ``` --radius-none: 0px /* rule lines, full-bleed dividers, table cells */ --radius-xs: 4px /* badges, tags, checkbox */ --radius-sm: 8px /* inputs, small buttons, list-row hover */ --radius-md: 12px /* medium controls, inner panels */ --radius-lg: 16px /* nested cards, sheets on mobile */ --radius-xl: 24px /* CARD DEFAULT · dialogs · panels */ --radius-2xl: 32px /* hero surfaces, marketing */ --radius-full: 9999px /* avatars, pills, presence dots */ ``` `--radius-xl: 24px` is the base. Every other radius in a composition should be *derived* from it, not chosen. --- ## The nesting formula > **`r_inner = r_outer − gap`** Where `gap` is the distance between the outer border and the inner element's border. This is not a stylistic preference — it is the geometry that makes two concentric rounded rectangles concentric. Get it wrong and the corners visibly diverge, which reads as sloppy even to people who can't name why. ### Worked examples | Outer | Gap (inset) | Inner radius | Where | |---|---|---|---| | 24 | 24 | **0** | An element flush against the full card inset is **square**. Correct and intentional — this is why full-bleed table rows and dividers inside a card have no radius. | | 24 | 8 | 16 | A nested panel inset 8px from the card edge | | 24 | 12 | 12 | An inner card at 12px inset | | 24 | 16 | 8 | An input inset 16px | | 16 | 8 | 8 | A row inside a nested card | | 32 | 8 | 24 | A card inside a hero surface | ### The consequence people miss **24 outer + 24 inset = 0 inner.** An element that fills the entire content box of a 24px-radius card must have square corners. This is why: - Table rows inside a card are square, and their hover background is square with the *card's* corner clipped by `overflow-hidden` on the card. - A full-bleed chart at the bottom of a card inherits the card's bottom corners via `overflow-hidden`, not its own radius. - A list-row hover background that is inset 8px from the card edge gets `radius-lg` (16), not `radius-sm`. ### When you can't satisfy the formula If the required inner radius comes out negative, the inner element must be square (`0`) and you should question the composition — it usually means the inner element should be full-bleed rather than inset. --- ## Squircle vs. circular corners At 24px, the difference between a true circular arc and a superellipse (`squircle`) is visible. Apple uses continuous curvature; CSS `border-radius` does not. **Decision: circular arcs.** We ship standard `border-radius`. Rationale: squircles require either an SVG mask, `corner-shape: squircle` (Chrome 139+, not yet baseline as of this writing), or a `paint()` worklet. Each breaks something — mask kills subpixel text antialiasing on the clipped edge, the worklet doesn't run in Safari, and `corner-shape` isn't universally available. A perfect circular corner beats a broken squircle. **Revisit gate:** when `corner-shape: squircle` reaches Baseline Widely Available, adopt it *progressively*: ```css .card { border-radius: 24px; } @supports (corner-shape: squircle) { .card { corner-shape: squircle; } } ``` No layout changes required. Add it to [`roadmap.md`](../roadmap.md) as a deferred item. --- ## Radius by component class | Class | Radius | Note | |---|---|---| | Card, panel, dialog | 24 | The default | | Sheet / drawer (mobile) | 16 top corners only | Bottom corners are off-screen; rounding them costs nothing and gains nothing | | Popover, dropdown, menu | 12 | Smaller because they're smaller — a 24px radius on a 160px-wide menu eats the corners | | Tooltip | 8 | | | Input, select, textarea | 8 | | | Button (md, 40px) | 8 | | | Button (lg, 48px) | 12 | Scales with height | | Button (sm/xs) | 8 / 4 | | | Pill / badge / status | full | | | Avatar | full | Except **bank and merchant logos**, which are `8` — see [imagery](08-imagery-and-isometric.md#logo-treatment) | | Payment card visual | 5.9% of width | ISO 7810 physical spec, ~16px at 272px wide | | Table row hover | 0 (full-bleed) or 8 (inset) | Per the nesting formula | | Chart bar | 4 top corners | Only when bar width > 12px; below that, square | | Skeleton | matches the element it replaces | A rounded skeleton for a square element is a tell | --- ## Corner logic Rules that prevent the most common shape mistakes. 1. **Corners that touch a viewport edge are square.** A sheet at the bottom of the screen, a sidebar flush left, a full-width mobile card — round only the corners that are actually visible. Rounding an off-screen corner is a wasted 24px of content width. 2. **Adjacent segmented controls share a radius envelope.** In a button group, the first child rounds left, the last rounds right, the middles are square. Never round every child. 3. **Clip, don't double-round.** When content must follow a card's corner (an image, a chart, a colored bar), put `overflow: hidden` on the card and give the child `radius: 0`. Two separately-rounded elements at the same corner never align perfectly. 4. **`overflow: hidden` has a cost.** It creates a new stacking/clipping context, so a shadow, focus ring, or tooltip inside a clipped card will be cut off. If you need both clipping *and* an escaping child, clip a dedicated inner wrapper, not the card. 5. **Radius does not scale with density.** A `compact` table still lives in a 24px card. Density changes spacing and type, not shape — otherwise the product's identity changes when a user flips a setting. 6. **Focus rings sit outside the radius.** `outline: 2px solid var(--focus); outline-offset: 2px;` follows `border-radius` automatically and never gets clipped. Never build a focus ring with `box-shadow` on a clipped element. --- ## Borders and shape Radius and border are one system. A 1px border on a 24px radius must be drawn *on* the shape, not inside it. Practical consequences: - Use `border` (which follows radius) over `box-shadow: 0 0 0 1px` (which also follows radius but stacks differently with other shadows). Prefer `border` for resting state, `box-shadow` ring for transient/focus states. - On a nested element, `border` adds 1px to the outside; the nesting formula uses the *border box*. Two 24px cards separated by an 8px gap where both have borders: inner radius is still `24 − 8 = 16`. Full elevation treatment in [elevation](04-elevation-borders-shadows.md). --- # Color Grayscale first. Color is added only where it carries meaning, and in a financial interface almost nothing does. The whole palette is **one neutral ramp, one accent, and a semantic value pair**. That's it. If a design needs a sixth hue, it needs a rethink instead. --- ## Why OKLCH All tokens are authored in `oklch()`. - **Perceptual uniformity.** `oklch(0.55 …)` is the same apparent lightness at every hue. In HSL it is not, which is why hand-tuned HSL ramps always have one step that looks wrong. - **Predictable ramps.** Hold hue and chroma, vary lightness, get a ramp that reads as one family. - **Contrast is inspectable.** The `L` value approximates perceived lightness, so contrast failures are visible in the token file. - **Wide gamut.** P3 displays get more saturated accents for free, with sRGB fallback. Fallback: ship `oklch()` directly. Baseline since 2023; for legacy targets a PostCSS plugin emits an sRGB fallback declaration above each. --- ## The neutral ramp **Hue 265, chroma 0.002–0.012.** A cool ink, not a pure grey. Pure `#000`/`#fff`/`#808080` in a data-dense UI reads as unfinished; a whisper of blue reads as considered and matches how paper-white and ink actually look. Chroma *increases* slightly as lightness decreases — dark neutrals need more chroma to avoid looking flat. ### Light mode ```css --canvas: oklch(0.985 0.002 265); /* page background — NOT white */ --surface: oklch(1.000 0.000 000); /* cards — pure white, lifted above canvas */ --surface-sunken: oklch(0.970 0.003 265); /* wells, code, inactive tabs, table zebra */ --surface-hover: oklch(0.975 0.003 265); --surface-selected: oklch(0.960 0.010 258); /* accent-tinted */ --border-subtle: oklch(0.935 0.004 265); /* dividers INSIDE a surface */ --border: oklch(0.900 0.005 265); /* surface edges */ --border-strong: oklch(0.820 0.006 265); /* emphasis, selected, input hover */ --text-primary: oklch(0.210 0.012 265); /* 15.8:1 on surface */ --text-secondary: oklch(0.480 0.010 265); /* 7.4:1 — labels, metadata */ --text-tertiary: oklch(0.580 0.008 265); /* 4.6:1 — THE FLOOR for meaningful text */ --text-disabled: oklch(0.720 0.006 265); /* 2.6:1 — never used for meaning */ --text-inverse: oklch(0.985 0.002 265); ``` ### Dark mode Not an inversion — a separate design. Dark surfaces get **lighter as they rise**, which is the opposite of light mode, where surfaces get lighter as they rise too. (Both modes: elevation = more light. That's physically consistent and it's why naive inversion feels wrong.) ```css --canvas: oklch(0.170 0.008 265); --surface: oklch(0.205 0.009 265); --surface-raised: oklch(0.240 0.010 265); /* popovers, dialogs */ --surface-sunken: oklch(0.150 0.008 265); --surface-hover: oklch(0.235 0.010 265); --surface-selected: oklch(0.265 0.020 258); --border-subtle: oklch(0.265 0.010 265); --border: oklch(0.310 0.011 265); --border-strong: oklch(0.400 0.012 265); --text-primary: oklch(0.970 0.003 265); --text-secondary: oklch(0.720 0.008 265); --text-tertiary: oklch(0.600 0.008 265); /* floor: 4.6:1 on --surface */ --text-disabled: oklch(0.450 0.008 265); ``` > **Dark mode is never pure black.** `oklch(0.17)` ≈ `#1c1c22`. Pure black canvas with white text causes halation on OLED and makes 14px tabular figures shimmer. Ask any trader who's stared at a terminal. --- ## The accent **One.** `--accent`. Ink blue. ```css --accent: oklch(0.500 0.155 258); /* ≈ #2b5fd9 */ --accent-hover: oklch(0.450 0.155 258); --accent-active: oklch(0.410 0.150 258); --accent-subtle: oklch(0.960 0.025 258); /* tinted backgrounds */ --accent-border: oklch(0.880 0.060 258); --accent-fg: oklch(0.995 0.000 000); /* text ON accent */ /* dark mode */ --accent: oklch(0.650 0.150 258); --accent-subtle: oklch(0.270 0.045 258); --accent-fg: oklch(0.170 0.010 258); ``` ### The one-accent law > **One accent event per view.** Spend it on the single most important action. If a screen has a primary "Send payment" button, then the active nav item, the selected tab, the focused input ring, and the chart series may not *also* be accent-filled. They get neutral treatments: a border, a weight change, a background tint, an underline. The exceptions — the only ones: - **Focus rings.** Accessibility overrides aesthetics. The focus ring is always `--accent` and it does not count toward the budget. - **Links in prose.** Body copy links are accent-colored. They're not competing for the same attention. ### Accent is never a data color Charts, category tags, and account colors do not use `--accent`. If everything can be blue, blue stops meaning "press this". --- ## Semantic value colors The most consequential decision in a finance palette. ```css --positive: oklch(0.520 0.130 155); /* deep teal-green */ --positive-fg: oklch(0.995 0 0); --positive-subtle: oklch(0.950 0.035 155); --positive-flash: oklch(0.900 0.070 155); /* live tick background */ --negative: oklch(0.520 0.190 025); /* deep rose-red */ --negative-fg: oklch(0.995 0 0); --negative-subtle: oklch(0.955 0.030 025); --negative-flash: oklch(0.905 0.070 025); --warning: oklch(0.680 0.150 075); /* amber — pending, review, expiring: fills and borders */ --warning-subtle: oklch(0.960 0.045 075); --warning-text: oklch(0.520 0.115 070); /* amber as text and glyphs: ≥ 4.96:1, --warning-subtle included */ --info: var(--accent); --neutral-value: var(--text-secondary); /* unchanged / zero delta */ ``` Both `--positive` and `--negative` sit at `L 0.52` — they are *peers*, neither shouts louder. Amber sits higher because it must be distinguishable from red at a glance — which is also why it cannot be text: `--warning` is 2.95:1 on white. Text and glyphs in amber use `--warning-text`, and the preset maps every `text-warning` utility to it, so `bg-warning` and `border-warning` keep the fill while no call site can set the fill as copy. In dark mode `--warning-text` is `--warning` itself (≥ 6.28:1), and `--negative` steps up to `L 0.69` so it reads ≥ 4.8:1 on `--negative-subtle`. ### The red/green problem, and the actual fix Roughly 8% of men have a red-green deficiency. Two colors at the same lightness with red/green hues converge to the same grey for a deuteranope. So: > **Color is never the only signal for a value's sign.** Never. Not once, anywhere in this system. Every signed value ships with a redundant, non-chromatic cue: | Cue | Where | |---|---| | **Explicit sign glyph** — `+` prefix on positive deltas, `−` (U+2212, not a hyphen) on negatives | `Delta`, all KPI changes | | **Accounting parentheses** — `(1,234.00)` | Statements, ledgers, tables | | **Directional arrow** — `↑` / `↓` at 12px | `Delta`, `Trend` | | **Position** — above/below a zero baseline | Charts | | **Pattern** — 45° hatch fill on negative bars | Charts, waterfalls | Test: **turn the palette to greyscale and confirm every sign is still readable.** That's the ship gate, and it's in the [quality bar](../craft/03-quality-bar.md). ### The Signal theme (accessibility variant) An opt-in alternate mapping for users who tell us they need it, plus a system-level respect for `prefers-contrast`. Blue/amber is the canonical colorblind-safe divergent pair — different in hue *and* lightness. ```css [data-value-palette="signal"] { --positive: oklch(0.500 0.130 250); /* blue */ --negative: oklch(0.520 0.130 055); /* amber-orange, deep enough to set figures: ≥ 4.98:1 */ --positive-subtle: oklch(0.955 0.030 250); --negative-subtle: oklch(0.960 0.045 060); } ``` The convention "green = up" is strong enough that we don't make this the default. It is a setting, surfaced in preferences next to density, and it must be honored by every chart as well as every number. ### Sign is a domain decision, not a color decision `--negative` means **"the number is below zero"**, not "this is bad". A negative *expense* variance is good news. Components take a `polarity` prop: ```ts polarity?: "standard" | "inverted" | "neutral" // standard → up is positive (revenue, cash, margin) // inverted → up is negative (burn, COGS, churn, DSO) // neutral → no color at all (headcount, invoice count) ``` Defaulting every metric to `standard` is how dashboards end up cheerfully green about a cost overrun. --- ## Data-visualization palette Categorical series need to be distinguishable from each other *and* from the semantic pair. So they live in a different lightness band and avoid hues 155 and 025. ```css --viz-1: oklch(0.550 0.140 258); /* blue — always series 1 */ --viz-2: oklch(0.650 0.120 195); /* cyan */ --viz-3: oklch(0.620 0.130 090); /* olive */ --viz-4: oklch(0.580 0.140 320); /* magenta */ --viz-5: oklch(0.680 0.110 045); /* sand */ --viz-6: oklch(0.480 0.100 230); /* deep blue */ --viz-7: oklch(0.720 0.090 140); /* sage */ --viz-8: oklch(0.420 0.080 300); /* plum */ ``` Rules: - **Max 6 categorical series.** Beyond that, group into "Other" — this is a data problem, not a palette problem. - **Sequential scales** (heatmaps, calendar) interpolate a single hue's lightness in OKLCH: `oklch(0.97 0.02 258)` → `oklch(0.40 0.16 258)`. - **Diverging scales** (variance) use blue→neutral→amber, never red→green, because a diverging scale has no redundant sign cue available. - **Series order is stable across renders.** A category that's `viz-3` on the dashboard is `viz-3` in the drill-down. Assign by a stable key hash, never by array index. --- ## Application rules 1. **Grayscale first.** Build every component with zero color. Add color only where removing it loses information. Most components ship with none. 2. **Never color as decoration on a data surface.** Colored card headers, tinted table stripes, category-colored borders on every row — all forbidden. Color is bandwidth; spend it on the exception. 3. **Backgrounds are tint, not fill.** Status pills use `*-subtle` backgrounds with `*` -colored text, not saturated fills with white text. Saturated fills are reserved for the single primary action. 4. **No gradients.** None. Not on buttons, not on cards, not on charts. One exception: an area chart's fill may fade a single hue to transparent, because that's a legibility device (it keeps the line readable over gridlines), not decoration. 5. **No glow as affordance.** A glow may never be the thing that says "this is interactive" or "this is focused". 6. **Borders carry the structure, not color.** See [elevation](04-elevation-borders-shadows.md). 7. **Every token is defined for both modes at declaration.** No token exists that's only valid in light mode. --- ## Contrast requirements | Content | Minimum | Standard | |---|---|---| | Body text, all figures | 4.5:1 | WCAG AA | | Text ≥ 18.66px bold or ≥ 24px | 3:1 | AA large | | **Any figure representing money** | **7:1** | AAA — *our own rule* | | Icons conveying meaning | 3:1 | AA non-text | | Borders that define an input | 3:1 | AA non-text | | Focus ring vs. adjacent | 3:1 | AA non-text | | Chart series vs. background | 3:1 | | | Decorative borders/dividers | none | | The 7:1 rule for money is deliberate and stricter than the standard. A misread figure in a finance product is a material error, and `--text-primary` at 15.8:1 costs nothing. **Never** use `--text-tertiary` for a monetary value. It's for labels, timestamps, and units — which is why the currency symbol may use it. The one sanctioned exception inside a figure is the cents of a `figure-hero` / `figure-lg` display value, where the glyphs are large text and the integer part carries the amount at full contrast ([typography](05-typography.md#the-currency-symbol)); in tables the cents stay at full colour. --- ## Theming for consumers The system ships one point of view. Consumers may override exactly two things without breaking it: ```css :root { --accent: oklch(0.50 0.16 25); /* your brand hue */ --font-display: "Your Serif"; /* your editorial face */ } ``` The neutral ramp, semantic values, spacing, radius and motion tokens are **not** theming surface. If you change those you have forked the system, which is fine — you own the code — but you're on your own for coherence. --- # Elevation, borders & shadows The single most opinionated decision in Lopifi: > **At rest, a surface is defined by a hairline. A shadow means the thing is actually floating.** Almost every dashboard gets this backwards — cards get `shadow-md` for decoration, and then a dropdown that genuinely floats has nowhere to go. Once everything is elevated, nothing is. --- ## The two-tier model ### Tier 1 — Structural (no shadow) Anything that scrolls with the page and occupies a fixed spot in the document: - Cards, panels, sections - Tables, table headers, table footers - Sidebars, toolbars, tab bars - Inputs, buttons at rest - Every statement, every chart container These get a **1px border**, full stop. Depth comes from the surface being lighter than the canvas (light mode: white on `#fafafb`), not from a shadow. ### Tier 2 — Floating (shadow) Anything that is temporarily above the document and would be occluded if it weren't: - Popovers, dropdowns, menus, comboboxes, date pickers - Dialogs, sheets, drawers - Toasts - Command palette - Elements being dragged - Sticky headers **only once content has scrolled beneath them** These get a shadow from the scale below, sized by how far above the page they are. --- ## Shadow construction Real shadows are two lights: a broad ambient occlusion and a tighter directional cast. One-layer shadows look like a Photoshop default because they are one. Every Lopifi shadow is **≥ 2 layers**, obeying: ``` blur ≈ 2 × y-offset spread ≈ −0.5 × blur (keeps the shadow tucked under the element) ambient layer: larger blur, lower alpha, larger negative spread direct layer: tighter blur, higher alpha ``` And critically: > **Shadows are tinted, never pure black.** `oklch(0.21 0.02 265 / α)` — the neutral ink hue at low chroma. A pure-black shadow over a cool-neutral surface renders muddy brown-grey; a hue-matched shadow renders as shade. ### The scale ```css --shadow-color: 0.21 0.02 265; /* L C H of the ink, alpha applied per layer */ /* e0 — resting. Not a shadow. */ --elevation-0: none; /* surfaces use: border: 1px solid var(--border) */ /* e1 — lifted: hover on an interactive card, a selected row */ --elevation-1: 0 1px 2px -1px oklch(var(--shadow-color) / 0.10), 0 1px 1px 0px oklch(var(--shadow-color) / 0.04); /* e2 — popover, dropdown, menu, tooltip, combobox */ --elevation-2: 0 4px 8px -2px oklch(var(--shadow-color) / 0.10), 0 2px 4px -2px oklch(var(--shadow-color) / 0.06), 0 0 0 1px oklch(var(--shadow-color) / 0.05); /* e3 — dialog, sheet, drawer */ --elevation-3: 0 16px 32px -8px oklch(var(--shadow-color) / 0.14), 0 6px 12px -6px oklch(var(--shadow-color) / 0.10), 0 0 0 1px oklch(var(--shadow-color) / 0.05); /* e4 — dragging, command palette. The only thing this high. */ --elevation-4: 0 32px 64px -16px oklch(var(--shadow-color) / 0.24), 0 12px 24px -12px oklch(var(--shadow-color) / 0.14), 0 0 0 1px oklch(var(--shadow-color) / 0.06); ``` Note the trailing `0 0 0 1px` ring on e2–e4: floating surfaces need a hairline **and** a shadow, because a shadow alone doesn't define the edge against a light background. --- ## Dark mode elevation Shadows barely read on a dark canvas — a black shadow on a near-black background is invisible. Dark mode uses a different physical model: **light from above**. ```css [data-theme="dark"] { --shadow-color: 0 0 0; /* true black; there's nothing to tint against */ /* Every raised surface gets a top highlight instead of relying on shadow */ --highlight-top: inset 0 1px 0 0 oklch(1 0 0 / 0.06); --elevation-1: var(--highlight-top), 0 1px 2px -1px oklch(0 0 0 / 0.40); --elevation-2: var(--highlight-top), 0 4px 12px -2px oklch(0 0 0 / 0.50), 0 0 0 1px oklch(1 0 0 / 0.06); --elevation-3: var(--highlight-top), 0 16px 40px -8px oklch(0 0 0 / 0.60), 0 0 0 1px oklch(1 0 0 / 0.08); --elevation-4: var(--highlight-top), 0 32px 72px -16px oklch(0 0 0 / 0.70), 0 0 0 1px oklch(1 0 0 / 0.10); } ``` **The top highlight is the whole trick.** A 1px `rgba(255,255,255,0.06)` inset line along the top edge simulates a light source above, and it does more work in dark mode than any shadow can. It is also why dark surfaces get *lighter* as they rise — `--surface` `0.205` → `--surface-raised` `0.240`. Both mechanisms say "closer to the light". --- ## Borders Three weights of meaning, one weight of pixels. ```css --border-subtle /* dividers WITHIN a surface: table rows, list items, sections */ --border /* the edge OF a surface: cards, inputs, panels */ --border-strong /* emphasis: hovered input, selected item, active state */ ``` All `1px`. Border craft in this system is about **contrast**, not width. A 2px border is a design smell — it's almost always compensating for insufficient contrast at 1px, or for a missing hierarchy elsewhere. ### Rules 1. **Never a border and a shadow at rest on the same element** — except floating surfaces (e2+), where the ring is *part of* the shadow token. 2. **Dividers are `--border-subtle`, edges are `--border`.** The line between two table rows must be lighter than the line around the table. If they're equal, the table dissolves into a grid. 3. **Hairlines are full-bleed inside cards.** A divider between list rows spans the full card width via negative margin (`-mx-6` against a 24px inset), not the padded width. A divider that stops short of the card edge looks like a mistake — because the eye reads the card edge as the container, not the padding box. 4. **A `1px` line at high DPI is still `1px` CSS.** Do not use `0.5px` — it renders inconsistently across engines and disappears entirely on 1x displays. If a line looks too heavy, lower its contrast, don't thin it. 5. **The bottom edge is optically heavier.** In light mode you may darken a card's bottom border one step (`--border` → between `--border` and `--border-strong`) to simulate a light source above. Optional; use it on the payment card and the hero balance card only, where it earns its keep. Never in tables. ### Rule lines in statements Statements use a distinct border vocabulary carrying accounting meaning — single rule above a subtotal, **double rule** beneath a grand total. That grammar lives in [numerics](06-numerics.md#rule-lines) because it's semantic, not decorative. --- ## Focus rings Not part of the elevation system. Always: ```css :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: inherit; /* outline follows border-radius automatically */ } ``` - `:focus-visible`, never `:focus` — a mouse click on a button should not ring it. - `outline`, never `box-shadow` — outline is never clipped by an ancestor's `overflow: hidden`, and it composites cheaply. - `outline-offset: 2px` so the ring never sits on the element's own border and never obscures a value. - On a dark or accent-filled element, add an inner white ring for contrast on both sides: `outline-color: var(--accent); box-shadow: 0 0 0 2px var(--surface);` - **Never remove a focus outline without a visible replacement.** Non-negotiable. --- ## Backdrops Dialogs and sheets get a scrim, not a blur. ```css --scrim: oklch(0.21 0.02 265 / 0.40); /* light mode */ --scrim-dark: oklch(0 0 0 / 0.60); ``` **No `backdrop-filter: blur()` on full-screen surfaces.** It is the single most expensive thing you can put on a scrim: it forces a full-viewport readback and repaint every frame, and on a mid-range Android or an Intel MacBook it turns a 240ms dialog entrance into a stutter. A tinted scrim reads as "behind" just as well. Where blur *is* permitted: - Small, static surfaces (a sticky toolbar ≤ 64px tall) with `backdrop-filter: blur(12px)` **not animated**. - Short one-shot transition masking at ≤ 2px (Emil's crossfade trick — see [motion](../motion/01-principles.md#blur-as-a-transition-mask)). - Never animated. Never > 8px on anything moving. Never on a full-screen surface. --- ## Scroll shadows When content scrolls under a sticky header, the header earns a shadow — this is the one case where elevation is *dynamic*, and it's genuinely useful because it answers "is there content above?". Implement with `IntersectionObserver` on a 1px sentinel above the header. **Never a scroll listener** — that's a main-thread animation driven by scroll events, which is explicitly forbidden. ```css .sticky-header { box-shadow: none; transition: box-shadow 140ms var(--ease-standard); } .sticky-header[data-scrolled] { box-shadow: var(--elevation-1); } ``` Same technique for horizontal scroll shadows on a wide table: a left/right fade indicating more columns. Use a `mask-image` gradient or a small absolutely-positioned gradient element — the sentinel drives a data attribute, CSS does the rest. --- ## Anti-patterns | Don't | Why | Do | |---|---|---| | `box-shadow` on a resting card | Everything floats, nothing floats | `border: 1px solid var(--border)` | | Single-layer shadow | Reads as a default, not a decision | ≥ 2 layers, ambient + direct | | Pure black shadow | Muddy over tinted neutrals | Tint with the ink hue | | Animating `box-shadow` | Paint on every frame, on a large surface | Cross-fade two stacked pseudo-elements' `opacity`, or accept a 140ms paint on a small element | | `backdrop-filter` on a scrim | Full-viewport readback per frame | Tinted scrim | | `2px` borders | Compensating for a contrast problem | Raise contrast at 1px | | Elevation to show *importance* | Elevation means z-position, not priority | Size, weight, and position show importance | | `will-change: box-shadow` sitting permanently | Holds a compositor layer forever | Add on interaction start, remove on end — or don't | --- # Typography Two families. Three sizes and three weights per component. That constraint is not minimalism for its own sake — it is what forces hierarchy to come from *contrast* rather than from *variety*. --- ## The families ### Inter — every word `Inter Variable`, the interface face: labels, prose, headings, buttons, display sizes, and the landing accent (its true italic). Display is Inter at display sizes — tighter tracking, the optical-size axis doing the rest — not a second text family. Why Inter: a variable optical-size axis, `case`-sensitive forms (which we depend on — see below), excellent hinting at 13–14px, and character-level disambiguation (`cv05`, `cv08`) that matters when someone is reading an account number. ```css --font-sans: var(--font-inter, "Inter Variable"), "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --font-display: var(--font-sans); ``` ### Geist Mono — every figure The figure face. Every amount, rate, ratio and reference number resolves to `--font-numeric` and to nothing else — and so do code, keys and identifiers, so the system still renders in exactly two families. ```css --font-numeric: var(--font-geist-mono, "Geist Mono"), ui-monospace, "SF Mono", "Menlo", "Consolas", monospace; ``` Why a mono for figures: Inter's `tnum` aligns a column, but its digits still carry the proportional rhythm of the text around them. In a 40-column grid the reader scans *shapes* down a column, not left to right. Geist Mono sits on a fixed advance with an unambiguous 1/7 and a zero that never reads as an O, so a column forms a hard vertical edge and a mistyped digit shows as a shape error before it is read as a number. Its digits sit optically large against Inter, so `.numeric` scales them by `--numeric-size-adjust` (0.94) to hold the line. ### The display-figure law > **A figure may leave the figure face only when it never aligns with another figure.** `Figure family="display"` sets a solitary hero numeral in Inter at display size. A parent that stacks figures (`KpiRow`, a table) forces the figure face rather than trusting the caller. | Allowed in display | Forbidden in display | |---|---| | The single hero balance on an account page | Any column of figures | | A large standalone KPI figure in a card, when it's the only figure in that card | Table cells, statement rows, ledger amounts | | Marketing / onboarding numbers | Anything comparative — two KPIs side by side | **Test:** if a second figure could ever appear beside or beneath it, it's the figure face. ### No third family No serif, no second mono, no icon font. A consumer who wants figures in Inter sets `--font-numeric: var(--font-sans)` and every component follows, because none of them name a family directly. --- ## The scale Sizes are chosen for legibility; **line-heights are chosen to land on the 8px grid.** That's what keeps type aligned to layout. ### Text | Token | Size / Line | Weight | Tracking | Use | |---|---|---|---|---| | `display-lg` | 48 / 56 | 400 | −0.02em | Marketing, onboarding only | | `display-md` | 32 / 40 | 400 | −0.02em | Page hero, section opener | | `title-lg` | 24 / 32 | 500 | −0.01em | Page title | | `title-md` | 20 / 28 | 500 | −0.01em | Card title, dialog title | | `title-sm` | 16 / 24 | 500 | 0 | Section heading, list group header | | `body-lg` | 16 / 24 | 400 | 0 | Long-form prose, dialog body | | `body` | 14 / 20 | 400 | 0 | **Default.** UI text, table cells, form values | | `label` | 13 / 16 | 500 | 0 | Field labels, table headers, buttons, tabs | | `caption` | 12 / 16 | 400 | 0 | Metadata, timestamps, helper text | | `overline` | 11 / 16 | 600 | +0.06em | Uppercase eyebrows. **Sparingly** — see below | ### Figures A separate scale, because numbers have different optical requirements. All of these are Inter, all `tabular-nums`, all `-0.01em` tracking (tabular figures are slightly wide; a touch of negative tracking restores rhythm). | Token | Size / Line | Weight | Use | |---|---|---|---| | `figure-hero` | 40 / 48 | 500 | The single most important number on a page | | `figure-lg` | 28 / 32 | 500 | KPI card primary value | | `figure-md` | 20 / 28 | 500 | Card values, statement totals | | `figure-sm` | 14 / 20 | 400 | **Table cells, ledger rows, statement lines** — the workhorse | | `figure-xs` | 12 / 16 | 400 | Dense/compact mode, chart labels, sub-values | Figures use weight 500, text uses 400. A figure at the same weight as its label loses to the label, because the label usually has more glyphs. ### Weights — only three ``` 400 Regular body, figures in tables, prose 500 Medium labels, buttons, headings, prominent figures 600 Semibold emphasis within a heading, the one overline, active nav ``` **No 300. No 700+.** Inter at 300 fails contrast at 13–14px on a light background. Inter at 700 in a dense table is shouting. Three weights is genuinely enough — if you want more emphasis than 600 gives, the answer is size or color, not weight. ### Tracking Do not touch it. The scale above already bakes in the only two adjustments the system permits: - Negative tracking on large sizes (≥ 24px), because Inter's default spacing is set for text sizes. - Positive tracking on `overline` only, because uppercase always needs it. Everything else is `0`. `tracking-tight` on body text is one of the clearest markers of AI-generated UI. --- ## The 3×3 rule > **Max 3 font sizes and max 3 font weights per component.** Per component, not per page. Worked example: ``` BalanceCard eyebrow "Operating account" label 13/16 w500 text-secondary figure "$248,190.24" hero 40/48 w500 text-primary delta "+$12,400 (5.3%) MTD" caption 12/16 w400 positive ───────────────────── 3 sizes, 2 weights ✓ ``` Add a fourth ("last synced 2m ago") and you must reuse `caption/400`, or remove something. Reusing is nearly always right — it becomes a metadata row, which is a *fourth slot at the same rank*, which is legitimate. A fourth *rank* is not. **Why this works:** hierarchy is relative. Three levels are unambiguous — big, medium, small. Five levels force the eye to compare, and comparison is slower than recognition. --- ## OpenType features for money This is where a finance system earns its name. These are `font-feature-settings`, they cost nothing, and essentially no product ships them. ```css .numeric { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1, "case" 1; letter-spacing: -0.01em; } ``` ### `tnum` — tabular figures Every digit gets the same advance width, so decimal points align down a column and a value doesn't reflow as it changes. **Mandatory on every number in the system.** Not just tables — a live-updating balance that shifts horizontally as digits change is the most distracting thing a finance UI can do. Note `0` and `1` are the digits that differ most between proportional and tabular. If a column looks slightly wrong, `tnum` isn't on. ### `case` — case-sensitive forms The detail nobody ships, and the reason this system is worth building. `case` raises parentheses, brackets, hyphens and dashes to align with **lining figures** rather than lowercase text. So accounting negatives: ``` without case: (1,234.00) ← parens sit low, centered on x-height with case: (1,234.00) ← parens centered on the figures ``` At 14px it's a sub-pixel difference on one glyph. Down a column of forty negative numbers it's the difference between a statement and a spreadsheet dump. Apply `case` to **every element that can contain a parenthesized negative or a minus sign**. It also fixes the minus sign: `−` (U+2212) with `case` sits at the vertical center of the figures. A hyphen-minus `-` does not, ever. Use the real character. ### `zero` — slashed zero **Off** for monetary values. On for account numbers, routing numbers, reference IDs, and anything a human will transcribe. ```css .reference { font-feature-settings: "zero" 1, "ss02" 1; } ``` `ss02` is Inter's disambiguation set — it separates `1` / `l` / `I` and `0` / `O`. Exactly right for an IBAN, wrong for a balance. ### `frac` — fractions Off. If you're rendering fractions in a finance UI, use decimals. ### Feature summary by context | Context | Features | |---|---| | Monetary value | `"tnum" 1, "case" 1` | | Percentage, ratio, count | `"tnum" 1, "case" 1` | | Account / routing / reference number | `"tnum" 1, "zero" 1, "ss02" 1` | | Body prose | none (default proportional figures read better in a sentence) | | Chart axis labels | `"tnum" 1` | | Dates | `"tnum" 1` | Note the last one on prose: **inside a sentence, use proportional figures.** "We processed 1,284 payments" reads better proportional. Tabular is for alignment; a sentence has nothing to align to. --- ## The currency symbol Set the symbol lighter than the value. Stripe and Mercury converged on this independently, and the reason is structural: the symbol is a **unit label**, not data. ```tsx $ 248,190 .24 ``` Three decisions in that snippet: 1. **Symbol at `--text-tertiary`.** Present, legible, subordinate. 2. **Cents at `--text-tertiary` for large display figures only** (`figure-hero`, `figure-lg`). At 40px, `.24` at full weight competes with the dollars for no reason. In tables — `figure-sm` and below — cents stay at full color, because at 14px the reduced contrast hurts legibility and the alignment already does the work. 3. **4px gap between symbol and figure**, never a hard-coded space character (which is a proportional advance and breaks tabular alignment). **Never superscript the cents.** It's a retail-pricing device that reads as a discount, which is the opposite of the register a finance product wants. Symbol placement follows the locale via `Intl.NumberFormat`, not a hard-coded prefix — see [numerics](06-numerics.md). --- ## Text behavior Per baseline-ui, and all of these are one-line fixes that almost nothing ships: - **`text-wrap: balance` on every heading.** Prevents a one-word orphan line on a card title. Cheap, always right for ≤ 4 lines. - **`text-wrap: pretty` on every paragraph.** Prevents orphans and improves rag. - **`tabular-nums` on all data.** Restated because it's the most-missed one. - **`truncate` or `line-clamp-N` in dense UI**, with the full value in a `title` attribute *and* an accessible tooltip. A truncated merchant name with no way to see it is a dead end. - **Never truncate a number.** Ever. See [numerics](06-numerics.md#overflow) for what to do instead. - **`hyphens: none` on labels and values.** Hyphenating "Accounts Receivable" is worse than wrapping it. - **`overflow-wrap: anywhere` on user-generated strings** — merchant names, memo lines, and email addresses will otherwise blow out a table cell. --- ## The overline problem `overline` (11px uppercase, +0.06em) is in the scale because eyebrow labels are genuinely useful above a hero figure. It is also the single most abused style in dashboard design. **Rules:** at most one per card, at most three per screen, never on a table header (use `label` at `--text-secondary` instead — uppercase table headers in a dense table are noise), never longer than three words. --- ## Loading fonts ```css @font-face { font-family: "Inter Variable"; src: url("/fonts/InterVariable.woff2") format("woff2-variations"); font-weight: 100 900; font-display: swap; font-named-instance: "Regular"; } ``` - **`font-display: swap`** with a metric-matched fallback so the layout doesn't shift. Inter's metrics are close enough to `system-ui` that CLS is negligible with `size-adjust` tuning; measure it and set `size-adjust`/`ascent-override` on an `@font-face` fallback declaration. - **Load exactly two faces.** Inter (normal + italic, `opsz` axis) and Geist Mono at 400/500 — the two weights figures use. Anything else on the wire is a third family by the back door. - **Subset aggressively.** Latin + the currency block (U+20A0–20BF) + arrows + the math minus (U+2212). Full Inter Variable is ~350KB; a proper subset is under 40KB. - **Self-host.** No third-party font CDN in a financial product — it's a data-leak and an availability dependency. --- # Numerics The document that makes this a finance system rather than a dashboard kit. Everything here is a rule the rest of the library enforces on the consumer's behalf. --- ## 1. Storage: minor units, always > **Money is an integer of minor units plus a currency code. Never a float. Never a `number` alone.** ```ts type Money = { /** Integer amount in the currency's minor unit. 1234567 = $12,345.67 */ amount: bigint | number; /** ISO 4217 */ currency: string; /** Minor-unit exponent. Defaults from the ISO table; override for crypto/high-precision. */ scale?: number; }; ``` `0.1 + 0.2 !== 0.3`. In a ledger that means a reconciliation that never balances and a support ticket you cannot reproduce. Use integers, or a decimal library (`dinero.js` v2, `decimal.js`) — but *never* raw floats, and never do arithmetic in the component layer at all. **Lopifi components never do arithmetic.** They format and lay out. Totals are passed in, already computed by the caller, who owns the rounding policy. A component that sums its own rows will eventually disagree with the backend, and the backend is right. ### Decimal places are per-currency `JPY` has 0. `USD`/`EUR`/`GBP` have 2. `KWD`/`BHD`/`JOD`/`TND` have 3. Crypto has up to 18. Hard-coding `toFixed(2)` breaks in every one of these cases except the 2-decimal ones. ```ts const digits = new Intl.NumberFormat(locale, { style: "currency", currency }) .resolvedOptions().maximumFractionDigits; ``` Let `Intl` answer for most currencies, with one small override table: `Intl` reports CLDR *display* digits, and in V8 (Chrome, Edge, Node) about seventeen currencies — HUF, IDR, COP, PKR, IQD among them — come back with 0 where the ISO 4217 minor unit is 2 (3 for IQD). `currencyScale()` carries those exceptions, so HUF 12,345 minor units renders as 123.45 Ft, not 12,345 Ft. --- ## 2. Formatting ```ts new Intl.NumberFormat(locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", // "$100" not "US$100" currencySign: "accounting", // (100.00) instead of -100.00 }).format(amount / 10 ** scale); ``` Two options doing heavy lifting: - **`currencyDisplay: "narrowSymbol"`** — `$` rather than `US$`, `£` rather than `GB£`. Use `"symbol"` (which disambiguates) only in a multi-currency context where two dollar currencies coexist; then render the code as a separate, subordinate element instead: `$1,200.00 USD`. - **`currencySign: "accounting"`** — hands you parentheses natively, locale-correctly. ### Locale ≠ currency `en-US` formatting a `EUR` amount gives `€1,234.56`. `de-DE` gives `1.234,56 €`. Both correct; they're different questions. **Format in the *viewer's* locale, in the *value's* currency.** A German accountant reviewing a US subsidiary sees `1.234,56 $`, and that is right. ### Consistency within a column beats per-value optimization Fixed decimals throughout: `$1,200.00`, never `$1,200`. `Intl` does this by default; don't undo it. --- ## 3. Negatives ### Two conventions, chosen by context | Context | Format | Why | |---|---|---| | **Statements, ledgers, tables** | `(1,234.00)` | Accounting standard. The reader is scanning a column and parentheses are unmissable. | | **Deltas, KPIs, inline prose** | `−$1,234.00` | A delta is a change, not a balance. An explicit sign reads faster in isolation. | Both are colored `--negative`, and **neither relies on color** — the parens and the sign are the primary signal. ### Use the real minus `−` **U+2212 MINUS SIGN**, not `-` U+002D hyphen-minus. The hyphen is shorter, sits lower, and is a different width from the plus sign — so `+12.4%` and `-12.4%` don't align in a column. `Intl` emits U+2212 already; the bug is introduced when someone hand-builds a string. ### Negative alignment {#negative-alignment} The problem: in a right-aligned column mixing `1,234.00` and `(1,234.00)`, the closing paren shifts the digits ~4px left and destroys decimal alignment. Three fixes, in order of preference: 1. **Reserve the paren column.** Render an invisible `)` on positive values. ```tsx ) ``` Cheapest, works everywhere, keeps copy-paste clean if you strip `aria-hidden` content on copy. 2. **Grid the cell.** `display: grid; grid-template-columns: auto 1fr auto;` — open paren / figure / close paren. Most robust for mixed content. 3. **Pad-right the cell** by the width of `)` (`padding-right: 1ch` on positives). Works only with `tnum`, where `1ch` is a real fixed unit. Same treatment for the `+`/`−` prefix column in a delta list: reserve the glyph slot so values align even when some are unchanged. ### Sign is not sentiment Restated from [color](03-color.md#sign-is-a-domain-decision-not-a-color-decision): a component takes `polarity` (`"standard" | "inverted" | "neutral"`). A negative *expense* variance is good. Never assume down is bad. --- ## 4. Zero, null, and redacted — three different things The most common correctness bug in financial UI, and the cheapest to fix. | State | Glyph | Meaning | Screen reader | |---|---|---|---| | **True zero** | `—` (em dash) in statements; `$0.00` in tables and inputs | We know it, and it's zero | `"zero"` | | **Not applicable** | *empty cell* | This line cannot have a value (e.g. a header row) | *nothing* | | **Unknown / not loaded** | `n/a` at `--text-tertiary` | We don't have it | `"not available"` | | **Redacted** | `••••` at `--text-tertiary` | We have it, you can't see it yet | `"hidden, activate to reveal"` | | **Loading** | structural skeleton | In flight | `aria-busy="true"` | > **Rendering `$0.00` for a value you don't have is a lie**, and in a financial product it is the kind of lie that costs money. The type system should make it impossible: `Money | null | undefined` with three explicit render branches, never `value ?? 0`. The em dash for statement zeroes is the accounting convention — it says "computed, and nil", distinct from a blank, which says "not applicable here". Lopifi's `StatementRow` implements all three. --- ## 5. Abbreviation {#abbreviation} `$1.2M` is a chart-and-KPI device. It is **forbidden** in any surface a person might rely on as a record. | Surface | Abbreviation | |---|---| | Chart axis labels | ✅ Required — full values won't fit | | KPI card hero figure | ✅ Allowed, with the exact value in a tooltip | | Sparkline endpoint labels | ✅ Allowed | | **Table cells** | ❌ Never | | **Statement lines** | ❌ Never | | **Any total or subtotal** | ❌ Never | | **Amount inputs** | ❌ Never | | **Receipts, confirmations, exports** | ❌ Never | Rules when abbreviating: - Thresholds: `< 1,000` full · `≥ 1e3` `K` · `≥ 1e6` `M` · `≥ 1e9` `B` · `≥ 1e12` `T` - **One decimal place max**, and drop it when it's `.0`: `$1.2M`, `$5M` — not `$5.0M` - **Never abbreviate below 10K.** `$1.2K` is harder to read than `$1,200` and saves three characters. - Uppercase `K`/`M`/`B`. Lowercase `k` is an engineering convention, not a financial one. - Use `Intl.NumberFormat(locale, { notation: "compact" })` for correct localization (`1,2 Mio.` in German), not string surgery. - **The exact value must always be reachable** — `title`, tooltip, or on hover. An abbreviated number with no way to see the real one is a dead end. --- ## 6. Rounding ### The sum-of-rounded problem Round each of `33.333`, `33.333`, `33.334` to two decimals and the parts sum to `100.00`. Round `0.005` values and they won't. This is the single most reported "bug" in finance UI, and it isn't a bug — it's arithmetic. **Policy: display rounding is a lie you must not tell twice.** 1. Compute in minor units at full precision. 2. Round **once**, at the display boundary. 3. If rounded parts must sum to a rounded total, apply **largest-remainder allocation**: round everything down, then distribute the remaining minor units one at a time to the items with the largest fractional remainders. ```ts function allocate(total: number, weights: number[]): number[] { const sum = weights.reduce((a, b) => a + b, 0); const raw = weights.map(w => (total * w) / sum); const floored = raw.map(Math.floor); let remainder = total - floored.reduce((a, b) => a + b, 0); return raw .map((r, i) => ({ i, frac: r - floored[i] })) .sort((a, b) => b.frac - a.frac) .reduce((acc, { i }) => (remainder-- > 0 ? (acc[i]++, acc) : acc), [...floored]); } ``` Use it for fee splits, tax allocation, percentage-of-total columns, and pie-chart labels. **Lopifi provides this helper and uses it in `CategoryBreakdown` and `PlByDimension`.** 4. **When a rounded column genuinely can't sum, say so.** A `† rounded` footnote beats a total that's off by a cent with no explanation. `StatementTable` supports a `roundingNote` slot. 5. **Half-even (banker's) rounding** for financial allocation; half-up for display of a single value. Document which you used — in the export, in the tooltip, somewhere. --- ## 7. Percentages, rates, basis points | Type | Format | Precision | Note | |---|---|---|---| | Change / growth | `+5.3%` | 1 dp | Always signed | | Margin, ratio | `42.7%` | 1 dp | Unsigned | | Interest / APR | `4.250%` | 3 dp | Financial convention; trailing zeros kept | | FX rate | `1.08432` | 5 sig figs | Currency-pair dependent | | Basis points | `+25 bps` | 0 dp | Use bps when the change is < 1pp — `+0.25%` is ambiguous | ### Percentage point vs percent — the trap If a margin goes from 20% to 25%, it rose **5 percentage points** (`+5pp`), which is a **25% increase**. These are different numbers and both are correct. Components must be told which: ```ts type PercentChange = | { kind: "points"; value: number } // renders "+5.0pp" | { kind: "relative"; value: number } // renders "+25.0%" ``` Conflating them is how a dashboard reports a 25% margin improvement that didn't happen. `Delta` requires the discriminator; there is no default. ### Division by zero `(x − 0) / 0` is not `∞`, it's undefined, and it must render as `n/a` — never `Infinity`, never `NaN`, never `100%`. Guard at the format boundary. Growth from a zero base is genuinely unknowable; say so. --- ## 8. Rule lines {#rule-lines} Four centuries of accounting typography, ~zero web implementations. Two CSS declarations for enormous credibility. ``` Revenue Product 482,100 Services 118,400 ───────── ← single rule: a subtotal follows Total revenue 600,500 ... Net income 142,880 ═════════ ← double rule: this is final ``` | Rule | CSS | Meaning | |---|---|---| | **Single rule above** | `border-top: 1px solid var(--border)` | The line below is a subtotal of the lines above | | **Double rule below** | `border-bottom: 3px double var(--border-strong)` | Grand total. Nothing is summed past here. | | **Section rule** | `border-top: 1px solid var(--border-subtle)` full-bleed | Visual grouping only, no arithmetic meaning | Details that matter: - **Rules span the numeric column only**, not the label column. This is the actual convention and it's what makes it read as accounting rather than as a generic divider. A rule under "Total revenue" that extends to the left margin is a divider; one that starts where the numbers start is a rule. - `border-bottom: 3px double` renders as 1px / 1px / 1px. Exactly right, and it's a single declaration. - In a multi-period statement, the rule spans **each period column independently**, with the column gap unruled. - Never both a rule and a background fill on a total row. Pick one — the rule, per convention. A grey "total" row is a spreadsheet habit, not an accounting one. --- ## 9. Overflow {#overflow} > **A number never truncates.** A truncated figure isn't degraded, it's *wrong* — `$1,234,5…` is unreadable and dangerous. Escalation ladder, in order: 1. **Step down the figure size** one token (`figure-hero` → `figure-lg` → `figure-md`). Hero figures do this automatically at a character-count threshold, not with `clamp()` — a container-query size step keeps the value on the type scale instead of landing between tokens. 2. **Drop the cents** in display contexts only (never in tables or statements), with the full value in a tooltip. 3. **Abbreviate** — but only in the surfaces §5 allows. 4. **Wrap the container.** Let the card grow. A card that's 8px taller is fine; a wrong number is not. 5. **Horizontal scroll** for the table, with sticky label column and edge fades. The label truncates. The number does not. That's the priority order, always. Related: **reserve the maximum width.** A live-updating figure should occupy the width of its largest plausible value from first paint, so it doesn't reflow as it updates. With `tnum` this is `ch`-computable. --- ## 10. Multi-currency 1. **Never sum across currencies without an explicit conversion**, and when you do, show the rate and the as-of time. A total labeled `$` that silently includes EUR at an unstated rate is a material misstatement. 2. **Mixed-currency columns get the code on every row**, subordinated: `1,200.00 EUR`. Symbols alone are ambiguous ($ is 20+ currencies). 3. **A converted value is a derived value.** Mark it: a `≈` prefix, or an interpolated original in the tooltip. `≈ $1,304.12` with `€1,200.00 @ 1.08677 · 6 Sep 2026 09:12 UTC` on hover. 4. **Never mix currencies in a chart series.** Convert to a stated presentation currency, label the axis with it, and show the conversion basis in the chart footer. 5. **The FX rate is a perishable fact.** Every displayed rate ships with a timestamp, and every rate that will be *transacted on* ships with a lock and a countdown. See [swaps & FX](../catalog/04-swaps-fx.md). --- ## 11. Dates, periods, and "as of" - **ISO 8601 in data, locale-formatted in UI.** `2026-09-06` stored, `6 Sep 2026` rendered via `Intl.DateTimeFormat`. - **Never `MM/DD` or `DD/MM` unlabeled.** `09/06/2026` is two different days depending on the reader. Use an abbreviated month name — it's unambiguous in every locale and costs one character. - **Fiscal ≠ calendar.** `FY26 Q1` may start in July. Period components take a `fiscalYearStartMonth` and label accordingly; never assume January. - **Period labels are explicit and inclusive.** "Sep 1 – Sep 30, 2026", not "September" and definitely not "Last 30 days" without the resolved dates in a tooltip. - **Every financial figure carries an "as of".** A balance without a timestamp is a rumor. Render it at `caption`/`--text-tertiary`, and use relative time only under an hour ("2m ago"), absolute beyond that. - **Timezone is stated.** For anything that determines which period a transaction lands in, show the zone. `9:12 UTC` not `9:12`. - **Relative time must update, and pause when hidden.** Use a shared interval, `document.visibilityState`, and an `IntersectionObserver`. Forty rows each running their own `setInterval` is a real performance bug. --- ## 12. Debit / credit For components that render raw ledger entries (`LedgerTable`, `JournalEntry`): - **Two columns, never one signed column.** Debit and credit are separate columns; that's the whole point of double-entry, and collapsing them destroys the reader's ability to verify. - **A journal entry displays its balance check.** Sum of debits = sum of credits, shown, with a clear failure state when it doesn't. - **Sign convention is stated in the UI**, not assumed. Whether a revenue credit renders positive or negative depends on the presentation basis; the toolbar says which. - Contra accounts render in their natural position with the account name carrying the meaning — never as a "negative asset". --- ## 13. Sensitive values - **Redaction is a presentation state, not missing data.** `••••` at `--text-tertiary`, reveal on explicit user action, and `aria-label="Balance hidden, activate to reveal"`. - **A privacy toggle blurs, it doesn't remove.** `filter: blur(6px)` on the figure with `user-select: none`, so layout is stable when it toggles. Keep the blur ≤ 8px and don't animate it beyond a 140ms opacity crossfade. - **Account numbers show last 4 only** by default: `•••• 4291`. The masked portion is `aria-hidden`; the accessible name is `"account ending 4291"`. - **Never log or telemeter a formatted monetary value.** Analytics events carry the shape of an interaction, never the amount. - **Copy-to-clipboard copies the raw value**, not the formatted one — `1234.56`, not `$1,234.56`. Nobody wants to paste a dollar sign into a spreadsheet. --- ## 14. Accessibility of numbers - Screen readers mangle `$1,234.56` inconsistently across engines. Provide an explicit accessible label: ```tsx $1,234.56 ``` For parenthesized negatives this is mandatory — `(1,234.00)` is read as "1,234.00" by several engines, silently dropping the sign. That is a correctness failure, not a nicety. Announce `"negative 1,234 dollars"`. - `aria-hidden` on the invisible alignment paren and on any decorative arrow glyph. - A live-updating figure uses `aria-live="polite"` on the *container*, and is throttled — announcing every tick is unusable. Announce at most once every 10s, or on a meaningful threshold change. - Column headers in every numeric table use `` so a screen reader announces the column when reading a cell. In a 12-column P&L this is the difference between usable and not. --- # Icons & microcopy --- ## Icons ### The set **Lucide**, 1.5px stroke, at 16 / 20 / 24. It's shadcn's default, it's MIT, it's comprehensive, and its optical grid matches Inter's rhythm. ```css --icon-sm: 16px; /* inline with body/label text, table cells, buttons */ --icon-md: 20px; /* standalone buttons, nav items, card headers */ --icon-lg: 24px; /* empty states, feature rows, mobile nav */ --icon-stroke: 1.5; ``` **Never scale an icon off-grid.** A 16px icon rendered at 18px has a 1.33px stroke and looks blurry against 1.5px siblings. Three sizes is enough. ### Stroke consistency The stroke width is fixed at **1.5 across all sizes**. Lucide's default is 2, which is too heavy next to Inter 400 at 14px — it makes the icon shout over its own label. Set it once, globally: ```tsx ``` The alternative (scaling stroke with size, so 24px icons get 2px) is more "correct" optically but produces visible inconsistency when a 16px and a 24px icon appear in the same view, which happens constantly. Fixed 1.5 wins on coherence. ### Alignment Icons align to **cap height**, not line box. Offsets are in [grid & layout](01-grid-and-layout.md#optical-alignment). Lopifi ships an `` that applies the correct offset for its size pair, because relying on every consumer to remember `-translate-y-[0.5px]` doesn't work. ### Color - Icons default to **`--text-secondary`**, one step down from their label. An icon at the same color as its text competes with it. - An icon that *is* the only content (icon-only button) gets `--text-primary`. - Semantic icons take the semantic color and **must not be the only signal** — a red circle without text is not a status. - **Never `--accent` on an icon** unless it's inside the one primary action. ### Finance icon vocabulary Consistency matters more than cleverness. Fixed mappings: | Concept | Icon | Note | |---|---|---| | Increase / up | `arrow-up-right` | Diagonal, not vertical — vertical reads as "sort" | | Decrease / down | `arrow-down-right` | | | Unchanged | `minus` | Not a dash glyph — an icon, for alignment | | Transfer / move money | `arrow-right-left` | | | Swap / convert | `arrow-left-right` in a circle | Rotates 180° on flip — see [motion catalog](../motion/03-catalog.md) | | Pending | `clock` | Never a spinner — pending is a state, not a load | | Reconciled / matched | `check` in a circle | | | Failed | `x` in a circle | | | Needs review | `alert-triangle` | Amber. Reserve for genuine action-required. | | Locked / final period | `lock` | | | Recurring | `repeat` | | | Attachment / receipt | `paperclip` | | | Export | `arrow-down-to-line` | Not `download` (a cloud) — this is a file, not a fetch | | Drill down | `chevron-right` | Rotates 90° when expanded | | Bank account | `landmark` | | | Card | `credit-card` | | | Cash | `banknote` | | **No emoji.** Anywhere. In any component. They render differently on every platform, they're untintable, and they instantly cost a financial product its seriousness. ### Icon transitions An icon that changes meaning **crossfades**, blur-masked, 140ms — the same treatment as a changed value ([why the blur](../motion/01-principles.md#blur-as-a-transition-mask)). It does not morph. Path morphing — interpolating one outline into another, per `raphaelsalaja/morphing-icons` — is well-made work and wrong here. It needs both icons authored as a single path with matching point counts, which Lucide doesn't give us and which breaks the moment an icon is swapped; it animates `d`, which is neither `transform` nor `opacity`, on a surface that may have forty siblings; and its entire appeal is drawing the eye to the transition, when the thing that changed is the **state**, not the icon. Two exceptions, and both are rotations rather than morphs — a `transform`, free on the compositor: | Case | Motion | |---|---| | Swap direction inverted | `arrow-left-right` rotates 180°, `spring.playful` | | Disclosure opened | `chevron-right` rotates 90°, 140ms `--ease-standard` | Everything else — pending → reconciled, unlocked → locked, synced → stale — is a crossfade. The vocabulary above is fixed, so a swap between two of its members is already legible without being animated into. ### Icon-only buttons Always `aria-label`. Always a tooltip. Always ≥ 40px hit area even when the icon is 16px (padding, or a pseudo-element hit-slop). Non-negotiable — this is the most common accessibility failure in dense toolbars. ```tsx ``` Note `size-*` rather than `w-* h-*` for square elements — a baseline-ui rule and it does read better. ### Brand and bank logos Not icons. See [imagery](08-imagery-and-isometric.md#logo-treatment). --- ## Microcopy The words are part of the design. In finance they're also part of the correctness. ### Voice **Precise, plain, and unhedged.** Not friendly, not clever, not apologetic. | Don't | Do | |---|---| | "Oops! Something went wrong 😅" | "Couldn't load transactions." | | "You're all caught up! 🎉" | "No transactions in this period." | | "Awesome, payment sent!" | "Payment sent. Arrives 8 Sep." | | "Are you sure?" | "Send $12,400.00 to Northwind Ltd?" | | "Invalid amount" | "Enter an amount of $0.01 or more." | | "Processing…" | "Submitted to bank · 2m ago" | ### The lexicon One word per concept, used everywhere. Drift here is why financial products feel incoherent. | Use | Never | |---|---| | **Transaction** (a movement of money) | activity, item, entry (reserve *entry* for journal entries) | | **Balance** (a point-in-time amount) | total, funds, money | | **Available** vs **Current** balance | "your money" — the distinction is material | | **Pending** (submitted, not settled) | processing, in progress, on the way | | **Settled** / **Cleared** | complete, done, finished | | **Reconciled** (matched to a statement) | verified, confirmed, checked | | **Categorized** | tagged, labeled, sorted | | **Period** (a fiscal window) | date range, timeframe | | **Statement** (P&L, BS, CF) | report — a *report* is something you generate | | **Account** (a GL account or a bank account — always qualify which) | bare "account" in mixed context | | **Payee** / **Recipient** (outbound) vs **Payer** (inbound) | customer, vendor, unless you mean specifically that | | **Fee** (a charge we levy) vs **Cost** (spread, FX margin) | "charges" | | **Rate** (FX) vs **Interest rate** | always qualify | ### Numbers in copy - **Always the exact amount in a confirmation.** "Send $12,400.00" — never "Send this payment". - **Always the destination.** "to Northwind Ltd ••••4291". - **Always the timing, if known.** "Arrives 8 Sep" beats "Sent successfully" because it answers the next question. - **Never a number in a heading that also appears in the body.** Pick one. ### Errors Per baseline-ui and [accessibility](../craft/02-accessibility.md): - **Next to where the action happened**, not in a toast at the corner of the screen. A field error goes under the field. - **Say what to do**, not what failed. "Enter a valid routing number (9 digits)" not "Invalid input". - **Never blame the user.** "That routing number isn't recognized" not "You entered an invalid routing number". - **A toast is never the only channel for a critical error.** Toasts are dismissible, timed, and easy to miss. Persist the error inline. - **Financial errors state the consequence.** "Payment not sent. No money has left your account." The reassurance is the most important sentence. ### Empty states Exactly one clear next action. Always. ``` [isometric illustration, ≤ 160px] No transactions yet title-sm 16/24 w500 Connect a bank account to import body 14/20 w400 text-secondary activity automatically. [ Connect account ] primary button ``` Three variants, and they are different components: - **Empty (first run)** — illustration + one primary action. This is onboarding. - **Empty (filtered)** — no illustration, text only, and a "Clear filters" action. An illustration here is condescending; the user knows what happened. - **Empty (period)** — "No transactions between 1–30 Sep." Restate the resolved period, offer to widen it. ### Buttons - **Verb + object.** "Send payment", not "Submit" or "Continue". - **The destructive button names the destruction.** "Delete 42 transactions", not "Delete". - **Never "OK".** The button says what it does. - **Cancel is always available and never destructive-styled.** - **Sentence case**, never Title Case, never ALL CAPS. ### Confirmations for irreversible actions An `AlertDialog`, and it restates the three facts that matter — **amount, destination, timing**: > **Send $12,400.00 to Northwind Ltd?** > To account ••••4291 · Arrives 8 Sep > This can't be undone once submitted. > `[Cancel]` `[Hold to send →]` For genuinely irreversible movements of money (wires, ACH after cutoff), the confirm is **hold-to-confirm**, not click. Specification in [motion catalog](../motion/03-catalog.md#hold-to-confirm) — it's the one place we deliberately make an interaction *slower*, because the user should be deciding for those 1.5 seconds. ### Tone in loading and pending Never "Please wait". State what's happening and, if you know it, how long: - `"Importing 1,284 transactions…"` — with a determinate progress bar if you have a count - `"Submitted to bank · settles in 1–2 business days"` - `"Syncing with QuickBooks · last synced 4m ago"` The last one is the pattern: **current state + last known good state.** It means the UI is still useful while it's stale. --- # Imagery, isometric & visual hierarchy Lopifi has almost no imagery inside its data surfaces — by design. But the places where it *does* have imagery (empty states, onboarding, concept explanation, the payment card, brand marks) carry disproportionate weight, because they're the only pictures in an otherwise typographic product. > **Implemented.** This document is the specification *and* the description of what ships: `IsometricSpot` in `src/ledger/components/isometric-spot.tsx`, distributed as `@lopifi/isometric-spot`. Eight scenes, seven placements on this site, one per entry point. The section below on [what the implementation learned](#what-the-implementation-learned) records the three things this spec originally got wrong. --- ## The isometric system ### Why isometric at all Financial concepts are *structural* — money moves between accounts, a period rolls up into a statement, a transaction matches a bank line. Flat illustration renders these as metaphors (a piggy bank, a rocket) which are noise. Isometric renders them as **diagrams with volume**: you can literally show a flow from one object to another, in space, without perspective distortion. That's the rule that decides when to use it: **isometric explains a mechanism; it does not decorate a screen.** ### The projection **True isometric, 30°.** All three axes equally foreshortened. ``` X axis 30° below horizontal, going right Y axis 30° below horizontal, going left Z axis vertical ``` Practical construction: draw on a square grid, scale `Y` to 86.602% (`cos 30°`), rotate 30°, skew −30°. Or in a design tool, apply the standard isometric transform per face. **Not 2:1 dimetric.** The pixel-art 2:1 (26.565°) projection is for sprite work where you need whole-pixel steps. We're vector, at multiple DPRs, so use the true 30° — it's geometrically correct and it composites correctly when two illustrations sit side by side. ### The isometric grid The illustration grid derives from the layout grid: **8px base cell**, transformed into the isometric plane. Every object's footprint is a whole number of cells. This is why two independently-drawn illustrations stack coherently — they're on the same lattice, same as the layout. Canvas sizes (all on the 8px grid): ``` inline 96 × 96 inside a card, next to text spot 160 × 160 empty states — THE DEFAULT feature 240 × 240 onboarding steps, feature rows hero 400 × 400 marketing, first-run diagram fluid × 240 flow diagrams, multi-object ``` The canvas is square and **fitted to the scene**, not fixed: the component computes the projected bounds of every solid, pads them, and centres the result. A hand-set `viewBox` is how an object ends up clipped at one edge and floating at the other, and it is the single most common reason a set of illustrations stops looking like a set. ### Shading **Three tones per object. No gradients, no cast shadows, no ambient occlusion.** Light comes from **above and to the left**, consistent with the elevation model in [elevation](04-elevation-borders-shadows.md) and consistent across every illustration in the system. | Face | Treatment | OKLCH operation | |---|---|---| | **Top** | Lightest | base `L + 0.10` | | **Left** | Base | base | | **Right** | Darkest | base `L − 0.10` | Because tokens are in OKLCH, "10% lighter" is perceptually the same step at every hue. That's why the whole illustration set looks like one hand drew it. **Ground shadow:** a flat isometric rhombus — the object's own footprint — at `--iso-shadow`, offset down-right by an eighth of a cell. No blur, no stacking, one per object. Two rules that are easy to get wrong and obvious once seen: 1. **The shadow is drawn at the object's base height, not at zero.** A block resting on a plinth casts *onto the plinth*. A shadow that falls through to the floor is exactly what makes a stack read as floating. 2. **A ground plane casts nothing.** The plinth is the surface. Offsetting a copy of it just draws a second plinth half a step away, which reads as a manufacturing defect. ### Palette Illustrations use **the product palette**, not an illustration palette. This is the single decision that keeps them from looking like stock art. Three tones per tone-family, as tokens, so a scene is themed by the page rather than redrawn for it: ``` --iso-top / --iso-left / --iso-right the accent object --iso-neutral-top / --iso-neutral-left / --iso-neutral-right everything else --iso-ground-top / --iso-ground-left / --iso-ground-right the plane it sits on --iso-shadow the flat contact rhombus ``` The ground ramp sits a step below the neutral ramp. Without that separation a plinth reads as another object that happens to be underneath, rather than as the surface. Rules: - **One accent object per illustration.** Same discipline as the UI: the accent is the subject. - **Max 4 hues total**, including neutrals. - **Every illustration ships light and dark variants.** Not a filter — a re-render against the dark tokens, because `L + 0.10` for a top face has to invert its relationship to the canvas. Ship as two SVGs, or one SVG using `currentColor` + CSS custom properties (preferred — one file, themed by the page). ```svg ``` ### Line work **No outlines by default.** Faces meet face-to-face; the tonal step defines the edge. Outlines make it look like a diagram from 2016. The exception: a `0.5px` line at `--border` on the *silhouette only* (not internal edges) when an illustration sits on a surface with insufficient contrast — typically a light-mode illustration on a white card. Use it as a fix, not a style. ### The object library Every scene is assembled from one vocabulary — plane, plinth, slab, column, wall — on one lattice. That shared vocabulary is what lets two independently authored scenes sit side by side without looking like they came from different sets. The eight scenes that ship: | Scene | What it shows | Where it is used | |---|---|---| | `ledger` | Bound ledgers stacked on a plinth | Home page, the Domain section | | `vault` | A vault with its dial on the visible face | Architecture section, the API object model | | `flow` | Three columns ascending — a quantity moving | Motion section, the MCP page | | `grid` | Four modules on one base, one of them ranked | Foundations section | | `stack` | A field of parts at different heights | Catalog section, the component index | | `documents` | Sheets cascading into one | Craft section, the docs index | | `tray` | An empty tray — the first-run empty state | `EmptyState` | | `terminal` | The surface an agent talks to | Tools section | Scenes are authored as **non-overlapping footprints**, which is not a stylistic choice: solids are painted back to front by their near corner's `x + y + z`, and the moment two of them interpenetrate no single sort key is correct and the scene needs a real depth buffer. ### Motion for isometric Isometric illustrations may animate **once, on entrance, when the surface is first seen** — and never loop. ``` Objects enter in Z order, back to front Stagger 60ms · translateY(8px) → 0 · opacity 0 → 1 Duration 320ms · --ease-out-quint Total sequence capped at 480ms ``` Never a looping float, never a rotating coin, never a bouncing arrow. An empty state that animates forever is a distraction on a screen the user is trying to leave. Respect `prefers-reduced-motion` by rendering the final frame with a 160ms opacity fade only. The one exception: a **flow diagram in onboarding** may animate a dash-offset along a connector once when it scrolls into view (`IntersectionObserver`, `{ once: true }`), because the motion *is* the explanation. Still capped, still one-shot, still paused off-screen. ### What the implementation learned {#what-the-implementation-learned} Three things this specification originally got wrong, kept here because each one produced a drawing that looked subtly broken rather than obviously broken. **A CSS length inside an SVG is a user unit, not a pixel.** The entrance in the motion catalog is `translateY(8px)`, which is a restrained nudge on a card and throws an object clean off a ten-unit illustration canvas. Isometric entrance is therefore its own keyframe, `ledger-iso-enter`, expressed in the coordinate system actually in force. **A drawing that never changes should not ship JavaScript to draw itself.** `IsometricSpot` is a server component. Reduced motion is handled by the theme's global reset rather than by a hook, which is the only reason it can be. **Detail belongs on a face you can see.** The vault's dial was originally placed on the far side of the block and painted over it, because it was authored last. It looked like a floating rectangle. In this projection only two vertical faces of a solid are ever visible; anything meant to read as attached has to sit on one of them. ### Where isometric is forbidden - Inside any table, statement, or chart - On a card that contains data - As a background, watermark, or texture - Behind text at low opacity - Anywhere on a screen a user visits daily If a user sees an illustration every day, it has stopped being an explanation and become wallpaper. --- ## Logo treatment {#logo-treatment} Bank logos, merchant logos, and integration marks appear constantly in financial UI — in transaction rows, account lists, payment method pickers. They are the most common source of visual noise in the product, because they're third-party assets we don't control. ### The containment rule **Every third-party logo is contained, never free.** ``` Container 24 (table row) · 32 (list item) · 40 (card) · 48 (account header) Radius 8px — NOT full. See below. Background --surface-sunken Border 1px --border-subtle Logo inset ~20% of container (a 32px container holds a 20px mark) Fit object-fit: contain, centered ``` **Radius 8, not circular.** Circular crops decapitate wordmarks and square logos alike. A rounded square is the only container shape that treats every third-party asset fairly. (People are circular; brands are rounded squares — that distinction also lets the eye separate "who" from "where" at a glance in a transaction row.) ### Normalization Third-party logos arrive at random sizes, with random padding, in random color modes. Normalize: 1. **Fixed container, contained fit, ~20% inset.** Never let a logo touch its container edge. 2. **Neutral background always.** Never render a logo on the raw surface — a white-on-white logo vanishes. 3. **Desaturate in dense contexts.** In a 400-row transaction table, 400 full-color logos are visual static. Apply `filter: saturate(0.85)` at row scale, full color at ≥ 40px. Subtle, and it makes the table dramatically calmer. 4. **Never recolor to match the theme.** A brand's blue is theirs. 5. **Never apply the container's radius to the logo itself.** Contain, don't crop. ### Fallback ladder Assume the logo is missing, because it often is: 1. Provided logo URL 2. Cached logo from a resolver (Clearbit, Brandfetch) 3. **Monogram** — first letter(s) of the name, Inter 500, on a background derived from a stable hash of the name (pick from the `--viz-*` scale, so it's on-palette). Deterministic: the same merchant is always the same color. 4. Generic icon by category (`landmark`, `credit-card`, `shopping-bag`) Never a broken-image icon. Never an empty box. The monogram is good enough that it's often better than a bad logo. ### Loading `loading="lazy"`, `decoding="async"`, explicit `width`/`height` to reserve layout, and a `--surface-sunken` background so the container is visible before the image lands. No fade-in on logos in a table — 400 elements fading in is exactly the kind of decorative motion the frequency law forbids. --- ## Avatars People, not brands. **Circular**, `--radius-full`. ``` 16 (inline mention) · 24 (table row) · 32 (list) · 40 (header) · 64 (profile) ``` - Initials fallback with a deterministic `--viz-*` background, Inter 500, `--text-inverse`. - **Avatar stacks** (approvers, participants) overlap by 25% of diameter with a 2px `--surface` ring, max 4 shown, then `+N` in a neutral chip. The ring is what makes the stack legible; without it the overlaps mush. - Never a colored ring for status. A presence dot, bottom-right, 25% of diameter, 2px surface ring. --- ## Photography **None.** Lopifi ships zero photographic assets. Stock photos of people shaking hands over laptops are the fastest way to make a financial product look untrustworthy. If a consumer needs photography, it goes in their marketing site, not in these components. --- ## Visual hierarchy — the general rules Beyond imagery: how any Lopifi surface ranks its content. Six mechanisms, in order of strength: | Rank | Mechanism | Notes | |---|---|---| | 1 | **Size** | The strongest and cheapest. A 40px figure beats everything on the card. | | 2 | **Position** | Top-left in LTR is read first. The most important number goes there or is the visual anchor. | | 3 | **Weight** | 500 vs 400. One step is enough; two steps is shouting. | | 4 | **Color/contrast** | `--text-primary` vs `--text-secondary` vs `--text-tertiary`. Three ranks, no more. | | 5 | **Space** | Isolation creates importance. A figure with 24px of clear space outranks one with 8px, at the same size. | | 6 | **Color (chromatic)** | The accent. Weakest in the list because we spend it once per view. | ### The hierarchy laws 1. **Every surface has exactly one primary element.** If you can't name it in one word, the surface is unranked and it will feel flat regardless of styling. 2. **Contrast the primary against its *neighbors*, not against an abstract scale.** A 20px figure is primary in a dense card and tertiary on a dashboard hero. 3. **De-emphasize rather than emphasize.** The instinct is to make the important thing bigger; the better move is usually to make everything else `--text-secondary`. Quieter surfaces have clearer hierarchy. 4. **Labels lose to values.** Always. A label is `label`/`--text-secondary`; its value is `body`/`--text-primary` or a figure token. Any surface where a label out-shouts its value is inverted — and uppercase table headers are the usual culprit. 5. **Three ranks per surface.** Primary, secondary, tertiary. A fourth is a sub-surface. 6. **Alignment is hierarchy.** A left edge shared by six elements creates a group with no styling at all. Most "this looks messy" problems are alignment problems wearing a color costume. 7. **Icons never outrank their labels.** One step down in color, matched in size band. 8. **The number wins.** Whenever a chrome decision and a figure's legibility conflict, the figure wins. That's the whole product. --- # The accounting model Every component in Lopifi sits on top of one data model. If the model is wrong, no amount of design saves the product. This document is the model. The core insight that drives most of the UI decisions here: > **The general ledger and the financial statement are two different representations of the same fact, and they disagree about signs.** A component that doesn't know which representation it's rendering will get revenue backwards. --- ## 1. Double-entry Every transaction posts at least two lines. Debits equal credits. Always, without exception, at every level of aggregation. ```ts type JournalLine = { accountId: string; /** Exactly one of these is non-zero. Never a single signed amount. */ debit: Money; credit: Money; dimensions: Dimensions; memo?: string; }; type JournalEntry = { id: string; entryNumber: string; // sequential, gapless, never reused date: Date; // the transaction date periodId: string; // resolved fiscal period — NOT derived at render time lines: JournalLine[]; // ≥ 2 source: EntrySource; // see §6 status: "draft" | "posted" | "reversed"; reversalOf?: string; // entry id reversedBy?: string; }; ``` ### Debit and credit are two columns, never one This is the single most important rendering rule in the accounting family. A signed amount column (`-1,240.00`) collapses two facts into one and destroys the reader's ability to verify. Double-entry exists so a human can check the arithmetic by eye. Collapse the columns and you have removed the control. **`LedgerTable` and `JournalEntry` render two columns. There is no single-column variant.** The balance check is displayed, not assumed: ``` Debit Credit 6200 · Rent expense 4,500.00 1010 · Operating cash 4,500.00 ───────── ───────── 4,500.00 4,500.00 ✓ balanced ``` An unbalanced entry is a **prominent error state with the difference stated** — never a silently saved draft, and never a rounded-away discrepancy. --- ## 2. Account types and normal balances Five types. Each has a *normal balance* — the side that increases it. | Type | Normal | Increases with | Statement | Typical range | |---|---|---|---|---| | **Asset** | Debit | Debit | Balance sheet | 1000–1999 | | **Liability** | Credit | Credit | Balance sheet | 2000–2999 | | **Equity** | Credit | Credit | Balance sheet | 3000–3999 | | **Revenue** | Credit | Credit | P&L | 4000–4999 | | **Expense** | Debit | Debit | P&L | 5000–8999 | Sub-classification that the statements depend on: ``` Asset current · non-current (cash, AR, prepaid, inventory | PP&E, intangibles, ROU) Liability current · non-current (AP, accrued, deferred rev, current LTD | LTD, lease liability) Equity contributed · retained · OCI (common stock, APIC | retained earnings | CTA, unrealized) Revenue operating · other (product, services | interest income, FX gain) Expense COGS · opex · other · tax (direct costs | SG&A, R&D | interest, FX loss | income tax) ``` **The UI consequence:** an `AccountPicker` groups by type and sub-classification, shows the account number, and **shows the normal balance** — because a user posting to an unfamiliar account needs to know which side increases it. This is a one-glyph addition (`Dr` / `Cr`) that prevents a whole class of error. ### Contra accounts Accounts whose normal balance is the *opposite* of their type, presented as a deduction from their parent. | Contra account | Type | Normal | Nets against | |---|---|---|---| | Accumulated depreciation | Asset | **Credit** | PP&E | | Allowance for credit losses (CECL, ASC 326) | Asset | **Credit** | Accounts receivable | | Sales returns and allowances | Revenue | **Debit** | Gross revenue | | Treasury stock | Equity | **Debit** | Total equity (cost method) | | Accumulated amortization | Asset | **Credit** | Intangibles | **Rendering rule:** a contra account is **never displayed as a negative asset**. It renders in its natural position, indented under its parent, with the account name carrying the meaning, and the net shown on a subtotal line: ``` 1500 · Property, plant & equipment 842,000 1590 · Accumulated depreciation (218,400) ← indented, parenthesized ───────── Net property, plant & equipment 623,600 ``` Getting this wrong — showing `Accumulated depreciation 218,400` as a positive asset — is a classic and highly visible error. --- ## 3. The sign problem The one that breaks most finance UIs. In the ledger, revenue is a **credit**. If your data layer represents a line as a single signed number with debits positive, a $600,500 revenue month arrives at your component as `-600500`. On a P&L, it must display as `600,500`. So there are **two signs**, and they are not the same: ```ts type StatementLine = { /** As stored in the GL. Credit-normal accounts are negative in a debit-positive convention. */ ledgerAmount: Money; /** As presented on the statement. Revenue positive, expenses positive, net income positive. */ presentationAmount: Money; /** Which way this line's presentation runs relative to the ledger. */ presentationSign: 1 | -1; }; ``` **Rules Lopifi enforces:** 1. **Components render `presentationAmount`.** They never negate `ledgerAmount` themselves, and never infer the sign from the account type at render time. 2. **The transformation happens once, in the data layer**, and it is the caller's responsibility. Components that do sign math will eventually disagree with the backend. 3. **`LedgerTable`, `JournalEntry`, `AccountRegister`, and `TrialBalance` render the ledger representation** (debit/credit columns). Everything else renders the presentation representation. 4. **A negative number on a P&L means the line genuinely went the other way** — a contra-revenue, a credit to an expense account, a negative gross margin. It is not a sign-convention artifact, and it renders in parentheses like any other negative. This is why `Money` takes `accounting` (parentheses) as an explicit prop rather than inferring it: the component cannot know which representation it's in. --- ## 4. Chart of accounts ```ts type Account = { id: string; number: string; // "6200" — sortable, human-referenced name: string; // "Rent expense" type: AccountType; subType: AccountSubType; normalBalance: "debit" | "credit"; parentId?: string; // hierarchy, max 5 levels isContra: boolean; contraOf?: string; isPostable: boolean; // header/rollup accounts are NOT postable status: "active" | "inactive" | "archived"; statementMapping: StatementLineId; // where it lands on the P&L / BS requiredDimensions: DimensionKey[]; }; ``` **UI rules:** - **The account number is always shown**, never just the name. Accountants navigate by number, and two accounts can legitimately share a name across entities. - **Non-postable accounts are visibly disabled** in every picker. Posting to a rollup account is a data-integrity failure that surfaces months later at close. - **Inactive accounts are hidden by default** but reachable by search, because historical entries reference them. - **Hierarchy is capped at 5 levels.** Deeper is a chart-of-accounts problem, not a UI problem — say so in the empty state rather than supporting infinite nesting. - **The statement mapping is visible on the account.** "Where does this show up?" is the most common question about an unfamiliar account. --- ## 5. Dimensions Modern ledgers are multi-dimensional. The account is one axis; there are others. ```ts type Dimensions = { entityId: string; // legal entity — always required departmentId?: string; // cost center locationId?: string; classId?: string; projectId?: string; customerId?: string; vendorId?: string; productId?: string; [custom: string]: string | undefined; }; ``` **UI rules:** - **Entity is always required and always visible.** A number without an entity is meaningless in a multi-entity group, and mixing entities in one total without eliminating intercompany is a misstatement. - **`requiredDimensions` is enforced at the account level**, not globally. Revenue accounts may require a customer; rent may require a location. The `JournalEntry` form reads it from the account the moment it's picked and adds the fields inline. - **Every statement, table, and chart declares its dimension filter in its header.** "Operating expenses — Engineering, US entity, Sep 2026" is a different number from "Operating expenses". An unlabeled filtered total is the most common way a dashboard misleads. - **Segment reporting is just a dimension pivot.** `StatementTable` supports a `groupBy: DimensionKey` that turns the period columns into segment columns. --- ## 6. Entry sources and provenance Where an entry came from determines what a user may do with it. | Source | Editable? | Typical origin | |---|---|---| | `manual` | Yes, until posted | A person typed it | | `adjusting` | Yes, until posted | Close adjustments | | `recurring` | Template editable; instances not | A schedule generated it | | `system` | **No** | Depreciation, amortization, FX revaluation | | `integration` | **No** | Synced from Xero, QBO, Stripe, a bank | | `reversal` | **No** | The automatic reverse of another entry | | `elimination` | Consolidation only | Intercompany eliminations | **Rules:** - **Source is displayed on every entry.** A user looking at a discrepancy needs to know whether a human or a sync created it before they know who to ask. - **Integration-sourced entries are read-only in Lopifi** and link back to their source system with the external ID. Editing a synced entry locally creates a divergence that the next sync will either overwrite or duplicate. - **A system entry that looks wrong is a configuration problem**, and the UI should route the user to the configuration (the depreciation method, the FX rate source) rather than letting them edit the output. --- ## 7. Immutability > **A posted entry is never edited and never deleted. It is reversed.** This is not a preference. It is the control that makes the ledger auditable, and it is the reason accounting software feels different from a spreadsheet. ``` draft → editable, deletable, not in any statement posted → immutable. Appears in statements. Only action: reverse. reversed → immutable. Appears with its reversal, both visible. ``` **UI rules:** - **There is no delete action on a posted entry.** Not disabled — *absent*. A disabled delete button invites a support ticket asking how to enable it. - **The reverse action explains what it does**, in an `AlertDialog` with hold-to-confirm: "Reverse JE-2026-0841? This creates an offsetting entry dated 30 Sep 2026. The original entry stays in the ledger." - **A reversed entry and its reversal are visually linked** and both remain visible. Hiding the original is the same as deleting it. - **A reversal in a closed period is dated in the current open period**, not the original period. The UI must not offer the closed date. - Drafts are the only place a delete exists, and drafts never appear in a statement. --- ## 8. Cash basis vs accrual basis The same ledger produces two different sets of statements. | | Accrual | Cash | |---|---|---| | Revenue recognized | When earned | When cash received | | Expense recognized | When incurred | When cash paid | | AR / AP | Present | **Absent** | | Prepaid / accrued / deferred | Present | **Absent** | | Used for | GAAP/IFRS, audits, most lenders | Tax returns where IRC §448 permits it; small-business management accounts. Not GAAP for any entity — a special purpose framework | **The UI rule:** the basis toggle is a **first-class, always-visible control on every statement**, not a setting buried in a menu. A user who doesn't know which basis they're looking at is being misled, and the two numbers can differ by an order of magnitude. Consequences the components handle: - On cash basis, AR/AP/prepaid/accrual/deferred lines **disappear entirely** — they don't render as zero, because they don't exist in that view. - The statement header states the basis, and so does every export and print. - Switching basis is a **full data change, so nothing animates** (Money Law 2). Instant swap. - Comparative columns must be on the same basis. Mixing is blocked at the type level. --- ## 9. Materiality Not everything that differs matters. Encoding materiality is what separates a useful variance report from noise. ```ts type MaterialityPolicy = { /** Absolute floor. Differences below this are ignored entirely. */ trivial: Money; /** A variance is material if it exceeds BOTH thresholds. */ absolute: Money; relative: number; // e.g. 0.05 for 5% /** Optional per-account overrides — cash is material at any amount. */ overrides?: Record>; }; ``` **Rules:** - **Both thresholds must be exceeded.** A 200% swing on $12 is not news; a 2% swing on $4M is. Flagging on percentage alone produces a report nobody reads. - **Materiality is a business judgment and therefore configurable.** Lopifi ships a default and a UI to change it; it never hard-codes 5%. - **The threshold in force is displayed** on any surface that flags variances. "Flagging variances over $10,000 and 5%" — so a reviewer knows what they're *not* seeing. - **Cash and related-party accounts commonly override to zero tolerance.** Support per-account overrides. --- ## 10. GAAP vs IFRS Lopifi is framework-aware but not framework-opinionated. Components take a `framework` prop where it changes presentation. | | US GAAP | IFRS | |---|---|---| | P&L name | Income Statement | Statement of Profit or Loss | | BS name | Balance Sheet | Statement of Financial Position | | BS ordering | Most liquid first (cash → …) | Often least liquid first | | Inventory | FIFO, LIFO, weighted avg | **LIFO prohibited** | | Development costs | Generally expensed (ASC 730); some software capitalized (ASC 985-20 for software to be sold; ASC 350-40 for internal-use, where ASU 2025-06 replaces project stages with a probable-to-complete threshold from fiscal 2028) | Capitalized when the IAS 38 criteria are met | | PP&E | Cost model | Cost **or** revaluation model | | Extraordinary items | Prohibited | Prohibited | | OCI presentation | One or two statements | One or two statements | | P&L structure | Captions by function or nature; ASU 2024-03 adds a footnote disaggregating expense captions (purchases of inventory, employee compensation, D&A) for public entities from fiscal 2027 | IFRS 18 (replacing IAS 1 for periods from 1 Jan 2027) imposes operating, investing and financing categories, required subtotals, and disclosure of management-defined performance measures | **What the UI does with this:** statement titles, section ordering, and the presence of certain lines (revaluation surplus) follow the framework. It does **not** attempt to convert between frameworks — that's an accounting judgment, not a rendering one. The framework is stated in the statement header. --- ## Cross-references | Topic | Doc | |---|---| | How numbers are formatted, signed, ruled | [`foundations/06-numerics.md`](../foundations/06-numerics.md) | | Periods, close states, cutoff | [`02-periods-and-close.md`](02-periods-and-close.md) | | Statement structure and consolidation | [`03-statement-semantics.md`](03-statement-semantics.md) | | Schedules, accruals, roll-forwards | [`04-schedules-and-accruals.md`](04-schedules-and-accruals.md) | | Controls, approvals, audit trail | [`08-controls-and-audit.md`](08-controls-and-audit.md) | | Terminology | [`11-lexicon.md`](11-lexicon.md) | --- # Periods & the close The month-end close is the central workflow of accounting software. It is also the workflow that generic dashboard components cannot express at all, because it depends on a concept they don't have: **a period has a state, and that state governs what the UI may offer.** --- ## 1. Fiscal calendars ```ts type FiscalCalendar = { /** 1–12. A June year-end means fiscalYearStartMonth = 7. */ fiscalYearStartMonth: number; /** How periods are cut. */ type: "calendar" | "4-4-5" | "4-5-4" | "5-4-4" | "13-period"; /** Which calendar year labels the FY. A FY starting Jul 2026 may be "FY26" or "FY27". */ yearLabelConvention: "start" | "end"; /** Some entities run a 53-week year periodically. */ weekAlignedYearEnd?: boolean; }; ``` **Never assume January.** This is the most common fiscal bug in finance UI, and it is silent — a Q1 label that means Jan–Mar for an entity whose Q1 is Jul–Sep produces confident, wrong analysis. **Retail calendars (4-4-5 and friends) are not exotic.** They make every "month" a whole number of weeks so week-over-week comparison works. Consequences: - A "month" is 28 or 35 days, not a calendar month. - Period boundaries fall mid-calendar-month. - Every 5–6 years there is a 53rd week, and the extra week distorts every year-over-year comparison in that year. **Flag it** — a component that shows `+8.4% YoY` in a 53-week year without a note is misleading. **UI rules:** - `PeriodPicker` renders the fiscal label **and** the resolved dates, always: `FY26 Q1 (1 Jul – 30 Sep 2026)`. - Never render a bare `Q1`, `Sep`, or `Last quarter`. - Comparative columns state their own resolved period; the header never says "Prior". --- ## 2. Period states A period is not just a date range. It is a state machine, and the state determines the available actions. ``` ┌──────────┐ ┌─────────────┐ ┌────────────┐ ┌────────┐ ┌───────┐ │ future │ → │ open │ → │ soft close │ → │ closed │ → │ filed │ └──────────┘ └─────────────┘ └────────────┘ └────────┘ └───────┘ ↑ │ └────── reopen (approval) ───────┘ ``` | State | Posting | Who can post | UI treatment | |---|---|---|---| | **future** | Blocked | — | Greyed in every picker; selecting it explains why | | **open** | Free | Anyone with the role | Normal | | **soft close** | Adjusting entries only | Controller and above | Amber banner; the entry form defaults to `adjusting` | | **closed** | **Blocked** | Nobody without a reopen | Lock glyph; statements marked final | | **filed** | **Blocked** | Nobody. Never reopened; corrections go through restatement, revision or a current-period adjustment. | Lock + "Filed" badge; restatement workflow only | ### The `PeriodStatus` banner Every surface scoped to a period displays its state. This is not decoration — it is the answer to "why can't I post this?", asked before the user has to ask. ``` 🔒 September 2026 · Closed 4 Oct 2026 by A. Rivera Entries dated in this period must be posted to October and will be flagged for cutoff review. [Request reopen] ``` Three things in that banner: the state, the provenance (who and when), and **the next action**. A lock icon alone is a dead end. ### Reopening - Requires an approval from a role that did not request it. - Writes to the audit trail with a mandatory reason. - **Invalidates every statement generated from that period.** Any export or saved report carrying that period is marked stale, because a PDF someone emailed to a lender is now wrong. - The UI states the blast radius before confirming: "Reopening September will mark 4 published statements as superseded." --- ## 3. Cutoff Cutoff is the boundary problem: a transaction dated in one period, entered in another. Getting cutoff wrong shifts revenue or expense between periods, which is the most common form of unintentional misstatement. **The `CutoffWarning` component fires when:** - An entry's transaction date falls in a closed or soft-closed period. - An entry's date is within N days of a period boundary (configurable, default 5) and the period is in close. - A bank transaction settles in one period but was initiated in another. - An invoice dated in the prior period arrives after close. The warning is **inline at the date field**, not a toast, and it states the consequence: > This is dated 29 Sep, which is in a closed period. It will post to October. If it belongs in September, request a reopen, or record it in October as an out-of-period adjustment. That last clause is the useful part: it names the two legitimate remedies rather than just blocking. --- ## 4. The close pipeline Lopifi models the close as a sequence of gated sections. This structure comes from how closes are actually run, and it drives `CloseChecklist`, `CloseStatus`, and the dashboard. | # | Section | Gate — what "done" means | |---|---|---| | 1 | **Cutoff & capture** | All source documents in; AP and AR cutoff tested | | 2 | **Cash** | Every bank and card account reconciled; exceptions explained | | 3 | **Subledgers** | AR and AP agree to the GL control accounts | | 4 | **Schedules** | Prepaids, accruals, deferred revenue, fixed assets rolled forward and tied | | 5 | **Adjusting entries** | Accruals, depreciation, amortization, provisions, reclasses posted | | 6 | **Balance sheet tie-out** | Every BS account supported by a schedule or reconciliation | | 7 | **Review & flux** | Material variances explained; statements reviewed and signed off | **Rules:** - **Sections gate in order.** You cannot tie out the balance sheet before the schedules are rolled. The UI shows downstream sections as blocked, with the blocker named. - **Every check has an owner, a due date, and a state**: `not started` → `in progress` → `blocked` → `done` → `reviewed`. - **A blocked item names its blocker and who owns it.** "Blocked: waiting on Sep bank statement — Treasury." - **Sign-off is irreversible and audited.** It uses `HoldToConfirm` and writes who, when, and on what version of the numbers. - **The close percentage is by weighted check, not by count.** Ten trivial checks and one bank rec is not 91% done. --- ## 5. Reconciliation vs tie-out Two different controls, constantly conflated, and each has its own component. | | **Bank reconciliation** | **Balance sheet tie-out** | |---|---|---| | Question | Does our cash record match the bank's? | Is this balance supported by evidence? | | Compares | Book cash vs. bank statement | GL balance vs. a supporting schedule | | Output | Reconciling items (timing differences) | A walk-down with a difference of zero or an explanation | | Component | `ReconciliationView` | `AccountTieOut` | | Applies to | Cash and card accounts | **Every balance sheet account** | ### The bank reconciliation summary The standard form, and the component renders exactly this: ``` Bank statement balance 412,880.14 Add: deposits in transit 8,420.00 Less: outstanding checks (22,140.55) Add/less: other reconciling items 0.00 ──────────── Adjusted bank balance 399,159.59 Book (GL) balance 399,159.59 ════════════ Difference 0.00 ✓ ``` **The difference line is the whole component.** It is always visible, never scrolls away, and it is the number the user is working to zero. When it is non-zero, that is the primary state of the screen. ### The tie-out walk-down ``` 1310 · Prepaid expenses GL balance at 30 Sep 2026 84,220.00 Supporting schedule closing balance 84,220.00 ──────────── Difference 0.00 ✓ Tied Supported by: Prepaid schedule (14 items) · reviewed A. Rivera 3 Oct ``` Every balance sheet account gets one. An account with no supporting evidence is itself a finding — `AccountTieOut` renders an explicit "Unsupported" state rather than showing nothing. --- ## 6. Reconciling items and exceptions A reconciling item is a legitimate timing difference. An exception is a break that needs work. The UI must not conflate them. | Kind | Meaning | Aging behavior | |---|---|---| | **Deposit in transit** | Booked, not yet on the bank statement | Should clear next period; flag if > 5 days | | **Outstanding check** | Issued, not yet cashed | Flag if > 90 days; stale-dated after six months (a bank may refuse it, UCC §4-404). Still a liability — void and reissue, or escheat under state unclaimed-property law after the dormancy period. Never write it back to income | | **Bank-only item** | Fee, interest, NSF the bank booked and we didn't | **Requires a journal entry.** Should never age. | | **Book-only item** | We booked something the bank has no record of | Investigate immediately | | **Unmatched** | No counterpart found | The actual exception | **Aging is the priority signal.** A 3-day-old outstanding check is normal; a 200-day-old one is a control finding. `ReconciliationView` sorts exceptions by age descending by default, not by amount — old breaks are what auditors find. --- ## 7. Adjusting entries The entries that make accrual accounting work. Each has a distinct shape, and `AdjustingEntryForm` offers them as templates rather than a blank journal entry. | Type | Pattern | Auto-reverses? | |---|---|---| | **Accrual** | Dr Expense / Cr Accrued liability | **Yes** — day 1 of next period | | **Deferral** | Dr Prepaid asset / Cr Expense | No — released by schedule | | **Depreciation** | Dr Depreciation expense / Cr Accumulated depreciation | No | | **Amortization** | Dr Amortization expense / Cr Accumulated amortization | No | | **Revenue recognition** | Dr Deferred revenue / Cr Revenue | No — released by schedule | | **Provision / allowance** | Dr Bad debt expense / Cr Allowance | No — trued up each period | | **Reclassification** | Dr Account A / Cr Account B | Sometimes | | **True-up** | Adjusts an estimate to actual | No | **The auto-reversal rule is critical.** An accrual that doesn't reverse double-counts the expense when the actual invoice arrives and is coded to expense. `AdjustingEntryForm` **defaults `autoReverse` to true for accruals** and shows the reversal date inline: "Reverses 1 Oct 2026". Making the user remember this is how expenses get double-counted. The reversal is created at posting time as a scheduled entry, visible in the next period before it posts — so a reviewer can see it coming. --- ## 8. Subsequent events Events after the period end but before the statements are issued (or available to be issued, for an entity that is not an SEC filer). Two types, and the distinction has real consequences. ASC 855 calls them **recognized** and **nonrecognized** subsequent events; IAS 10 calls them **adjusting** and **non-adjusting**. | | Type 1 — Adjusting | Type 2 — Non-adjusting | |---|---|---| | Condition | Existed at the balance sheet date | Arose after | | Example | A customer whose finances were already deteriorating at 30 Sep files for bankruptcy in October; a lawsuit about a September incident settles | A fire destroys a warehouse in October; the company issues debt or equity, or acquires a business | | Treatment | **Adjust the statements** (ASC 855-10-25-1) | **Disclose** the nature and an estimate of the effect, if material (ASC 855-10-25-3, -50-2) | `SubsequentEventLog` captures these during the review window with the type, the date discovered, and the treatment decision. It matters because the same event handled the wrong way is a misstatement in one direction or an omitted disclosure in the other. --- ## 9. Restatement When a closed and filed period is found to be materially wrong. - A **restatement** ("Big R", ASC 250-10-45-23) is required when the error is material to the previously issued statements: they are reissued, the cumulative effect lands in opening retained earnings of the earliest period presented, and the error is disclosed. - The prior period is **not silently edited**. A restatement entry is posted, and the comparative column is labeled **"Restated"** everywhere it appears. - The UI never shows a restated comparative without the label. A prior-year column that quietly changed between two reports destroys trust faster than the original error. - `StatementTable` supports a `restated` flag per period column, rendering the label in the header and a footnote naming the reason. - Errors immaterial to the prior period but material if corrected now revise the comparatives, labeled **"As revised"**. Errors immaterial either way are corrected in the current period as an out-of-period adjustment. Materiality is assessed under both the rollover and iron-curtain approaches (SEC SAB No. 108), quantitatively and qualitatively (SAB No. 99). The materiality policy decides which, and the decision is recorded. --- ## 10. Period-scoped UI rules Applying across every component: 1. **Every figure declares its period.** No exceptions. 2. **Every period label shows resolved dates.** 3. **Every period-scoped surface shows the period's state.** 4. **A closed period's numbers cannot change**, so any UI affordance implying they can is removed, not disabled. 5. **Comparatives are on the same basis, same framework, same entity set, and same currency.** Mixing is blocked at the type level, not warned about at render time. 6. **A statement generated from a period that later reopens is marked superseded**, wherever it was saved or exported. --- # Statement semantics What each statement means, how it's structured, and what the components must therefore enforce. --- ## 1. The three statements, and how they connect They are not three reports. They are three views of one set of facts, and they tie to each other. A product that renders them without proving the ties is rendering three unrelated tables. ``` P&L Balance Sheet Cash Flow ───── ───────────── ───────── Net income ──────────────→ Retained earnings └──────────────────────────────────────────────────→ (starting point, indirect method) Cash ←───────────────────── Ending cash ``` **The three articulations Lopifi asserts and displays:** 1. **Net income → retained earnings.** `Opening RE + Net income − Dividends = Closing RE` 2. **Cash flow → balance sheet cash.** `Opening cash + Net change in cash = Closing cash`, and closing cash, cash equivalents and restricted cash equal the balance sheet lines that hold them. 3. **The accounting equation.** `Assets = Liabilities + Equity` Each is rendered as a visible check, not assumed. When one fails, that is the primary state of the screen — a balance sheet that doesn't balance is not a balance sheet with a small problem, it's not a balance sheet. --- ## 2. Profit & loss ### Structure ``` Revenue ← gross less: Returns, allowances, discounts ← contra-revenue Net revenue ← subtotal, single rule Cost of revenue / COGS ══ Gross profit ══ ← subtotal + margin % Operating expenses Sales & marketing Research & development General & administrative ══ Operating income ══ ← subtotal + margin % Other income / (expense) Interest income Interest expense FX gain / (loss) ══ Income before tax ══ Income tax expense ══ Net income ══ ← double rule ``` ### Rules the component enforces - **Margin rows are `Percent`, not `Money`**, and visually distinguished. A percentage in a column of dollars must not look like dollars. - **Expense lines are `polarity="inverted"`.** An increase in COGS is not good news. This is the default for every account classified as an expense, and it's read from the account type, not chosen by the caller. - **Contra-revenue is indented under revenue and parenthesized**, netting to a "Net revenue" subtotal. Never shown as a separate expense. - **Gross profit requires a COGS classification.** If the chart of accounts has no COGS, the component omits the gross profit subtotal rather than showing a gross margin equal to revenue, which is a lie by omission. - **Expense captions will need a breakdown.** ASU 2024-03 (ASC 220-40) requires public business entities, for annual periods beginning after 15 December 2026, to disaggregate relevant expense captions in a footnote into purchases of inventory, employee compensation, depreciation, intangible amortization and other — so the account-to-line mapping must carry a natural classification as well as a functional one. - **Segments follow the CODM.** ASU 2023-07 (ASC 280) requires public entities to disclose, by reportable segment, the significant expenses regularly provided to the chief operating decision maker — again a dimension pivot, not a separate ledger. - **EBITDA is not a P&L line.** It's non-GAAP. If shown, it appears below net income in a clearly separated reconciliation block — see [metrics](10-metrics.md#ebitda). --- ## 3. Balance sheet ### Structure ``` ASSETS Current assets Cash and cash equivalents Accounts receivable, net ← net of allowance (contra) Inventory Prepaid expenses ══ Total current assets ══ Non-current assets Property, plant & equipment, net ← net of accumulated depreciation Right-of-use assets Intangible assets, net ══ Total non-current assets ══ ══ TOTAL ASSETS ══ ← double rule LIABILITIES Current liabilities Accounts payable Accrued liabilities Deferred revenue, current Current portion of long-term debt ← the reclass line, see below ══ Total current liabilities ══ Non-current liabilities ══ Total liabilities ══ EQUITY Common stock and APIC Retained earnings Accumulated other comprehensive income ← CTA lives here ══ Total equity ══ ══ TOTAL LIABILITIES AND EQUITY ══ ← double rule ``` ### Rules - **It is an "as of" a date, not a period.** `BalanceSheet` takes a `Date`, never a range. A balance sheet labeled "September 2026" is a category error the type system prevents. - **The balance check is a first-class UI element.** `Assets 4,281,900 = Liabilities 1,840,200 + Equity 2,441,700 ✓`. When it fails, the difference is stated prominently — never a silently unbalanced statement. - **Current/non-current split is the norm.** IFRS requires it unless a liquidity-order presentation is more reliable (IAS 1, carried into IFRS 18 from 2027); US GAAP permits unclassified balance sheets in some industries. Classification comes from the account, and the boundary — twelve months, or the operating cycle if longer — is measured from the as-of date, not from today. - **The current-portion reclass is visible.** Long-term debt maturing within twelve months moves to current liabilities. A user seeing a large jump in current liabilities needs to see that it's a reclass, not new borrowing — so the line is named explicitly, not merged into "Other current". - **Ordering follows the framework.** US GAAP: most liquid first. IFRS: commonly least liquid first. `framework` prop drives it. - **Retained earnings ties to the P&L.** The component asserts `Opening RE + Net income − Distributions = Closing RE` and surfaces the reconciliation on hover or drill-down. --- ## 4. Cash flow statement The statement most products skip, and the one lenders and boards actually read. ### Indirect method (the common one) ``` Net income ← from the P&L Adjustments to reconcile to cash: Depreciation and amortization ← non-cash, added back Stock-based compensation ← non-cash Provision for credit losses ← non-cash (Gain) / loss on disposal ← non-cash, sign flipped Changes in operating assets and liabilities: (Increase) / decrease in AR ← note the sign convention (Increase) / decrease in inventory Increase / (decrease) in AP Increase / (decrease) in deferred revenue ══ Net cash from operating activities ══ ══ Net cash from investing activities ══ ← capex, acquisitions, disposals ══ Net cash from financing activities ══ ← debt principal, equity, dividends paid ══ Net change in cash ══ Cash at beginning of period ══ Cash at end of period ══ ← MUST equal the BS cash lines ``` ### Rules - **The working-capital sign convention is inverted and it confuses everyone.** An *increase* in AR is a *use* of cash — money you earned but haven't collected — so it appears as a negative. The component labels these lines `(Increase) / decrease in …` exactly as shown, because the parenthetical is what makes the sign readable. - **Ending cash must equal the balance sheet.** Cash, cash equivalents and restricted cash (ASC 230 after ASU 2016-18) tie to the lines that hold them, with a reconciliation when they sit on more than one line. Asserted and displayed. This is the single most useful integrity check in the whole statement family. - **Non-cash adjustments must trace to real accounts.** D&A on the cash flow ties to the depreciation and amortization posted in the period. - **Interest and dividends are classified by the standard, not by intuition.** Under US GAAP, interest paid, interest received and dividends received are operating (ASC 230-10-45-16, -45-17); dividends paid are financing (ASC 230-10-45-15). Only debt *principal* sits in financing. IAS 7 allows a policy choice — narrowed by IFRS 18 from 2027 — so `framework` drives classification, never a per-line toggle. - **Direct method is supported but requires a reconciliation** to the indirect result, because US GAAP requires that reconciliation for disclosure anyway (IFRS does not). - **Free cash flow is not a GAAP line.** If shown, it's below the statement in a labeled non-GAAP block with its definition stated — see [metrics](10-metrics.md). --- ## 5. Supporting statements | Statement | What it shows | Component | |---|---|---| | **Trial balance** | Every account, debit and credit columns, proving they're equal | `TrialBalance` | | **Statement of equity** | Opening equity → net income → issuances → distributions → OCI → closing | `EquityRollForward` | | **AR aging** | Receivables by bucket, tying to the AR control account | `AgingTable` | | **AP aging** | Payables by bucket, tying to the AP control account | `AgingTable` | | **Fixed asset register** | Cost, accumulated depreciation, NBV, by asset | `ScheduleTable` | **Every supporting statement ties to a control account on the balance sheet, and the tie is displayed.** An AR aging that totals to something other than the AR balance means either the aging or the ledger is wrong, and finding out at audit is expensive. --- ## 6. Comparatives - **Comparatives must match on five axes**: basis, framework, entity set, presentation currency, and period type. Mixing is blocked at the type level, not warned about at render. - **Every column states its own resolved period.** No column is ever labeled "Prior" or "Current". - **A restated comparative is labeled "Restated"** in the column header with a footnote naming the reason. Always. See [periods & close](02-periods-and-close.md#9-restatement). - **Reclassified prior-period figures are disclosed.** If this year's presentation moved a line, the comparative moved too, and that must be stated — otherwise the variance is an artifact of presentation, not performance. --- ## 7. Variance and flux analysis The reviewer's actual job at close: explain what moved and why. ```ts type Variance = { current: Money; comparative: Money; absolute: Money; relative: PercentChange; // discriminated — see numerics material: boolean; // BOTH thresholds exceeded explanation?: { text: string; author: string; at: Date; driver?: "volume" | "price" | "mix" | "timing" | "one-off" | "error"; }; }; ``` **Rules:** - **A variance with no comparative base renders `n/a`, never `100%` or `∞`.** Growth from zero is genuinely unknowable. - **Material variances require an explanation** before the review section of the close can be gated as done. Unexplained material variances are the reviewer's worklist. - **The driver taxonomy matters.** "Revenue up 12%" is not an explanation; "volume up 18%, price down 5%, mix neutral" is. `FluxAnalysis` prompts for a driver, not just free text. - **Both absolute and relative are shown.** Either alone misleads: 200% on $12, or $40,000 on $8M. - **The materiality threshold in force is displayed**, so a reviewer knows what is being hidden from them. --- ## 8. Presentation and rounding - **Statements are commonly presented in thousands or millions.** When they are, the header says so — "in thousands, except per-share amounts" — and it is not optional. `StatementTable` takes a `scale` prop and renders the disclosure automatically. - **Rounded statements still foot.** Round once at the display boundary and use largest-remainder allocation so subtotals equal the sum of their rounded parts. See [numerics](../foundations/06-numerics.md#6-rounding). - **When a rounded column genuinely cannot foot, disclose it.** A `† Amounts may not sum due to rounding` footnote beats a total that's off by one with no explanation. - **Per-share amounts are never scaled.** In a statement presented in thousands, EPS stays in dollars and cents, which is why the disclosure says "except per-share amounts". --- ## 9. Statement header — required fields Every statement, on screen, in export, and in print, carries all of these. A statement missing any of them is not usable as evidence. ``` Northwind Holdings Ltd. and subsidiaries ← entity / consolidation scope Consolidated Statement of Profit or Loss ← statement name (framework-aware) For the three months ended 30 September 2026 ← resolved period, or "As at" for a BS Accrual basis · IFRS · Presented in USD ← basis, framework, currency In thousands, except per-share amounts ← scale, when scaled Generated 4 Oct 2026 09:12 UTC · Period: closed ← provenance and period state ``` This block is a component (`StatementHeader`), not per-page markup, precisely so it cannot be forgotten. --- # Schedules, accruals & roll-forwards The supporting evidence layer. Every balance sheet account needs a schedule behind it, and the schedule must tie. This is the most under-served area in accounting software UI — most products have a general ledger and statements, and then the schedules live in spreadsheets. That handoff is where closes break. --- ## 1. The universal roll-forward Every schedule in accounting has the same shape. Encode it once. ``` Opening balance 84,220.00 ← must equal prior period closing + Additions 22,400.00 − Releases / amortization (18,900.00) ± Adjustments (500.00) ───────────────────────────────────────────── Closing balance 87,220.00 ═════════════════════════════════════════════ GL balance at 30 Sep 2026 87,220.00 Difference 0.00 ✓ Tied ``` `RollForward` is one component with a configurable column set. Prepaids, accruals, deferred revenue, fixed assets, intangibles, leases, equity, allowances, and inventory reserves are all this shape. **Non-negotiable rules:** 1. **Opening must equal the prior period's closing.** Asserted, not assumed. A break here means someone edited history. 2. **The closing balance must tie to the GL.** The tie is displayed as part of the component, not computed elsewhere. 3. **The difference line is always visible.** It is the number the user is working to zero. 4. **A non-zero difference is the primary state of the screen**, with the amount, an "Investigate" action, and a required explanation field. 5. **Every movement traces to journal entries.** Clicking `Additions 22,400.00` drills to the entries that make it up, and they sum to the line. --- ## 2. Prepaid expenses Cash paid now, expense recognized over time. ```ts type PrepaidItem = { vendor: string; description: string; invoiceRef: string; totalAmount: Money; serviceStart: Date; serviceEnd: Date; method: "straight-line" | "usage" | "manual"; monthlyAmortization: Money; // derived for straight-line amortizedToDate: Money; remaining: Money; // totalAmount − amortizedToDate glAccount: string; // the expense account it releases to }; ``` **UI rules:** - **Partial first and last months are prorated by day**, not treated as whole months. A policy starting 18 Sep amortizes 13/30 of a month in September. Straight-line-by-month is a simplification that produces a stub at the end, and the component makes the convention explicit rather than silent. - **`remaining` is the balance sheet number.** The schedule total must equal the prepaid GL account. - **Items fully amortized in the period are shown, then dropped.** Silently disappearing rows make the roll-forward impossible to follow. - **A prepaid with a service end date in the past and a non-zero balance is an exception**, flagged prominently. It usually means an amortization entry was missed. --- ## 3. Accrued liabilities Expense incurred, invoice not received. **The auto-reversal rule is the whole component.** An accrual that doesn't reverse double-counts the expense when the real invoice arrives and is coded to expense. Coding the invoice against the accrued liability also works, but depends on someone remembering; auto-reversal removes that dependency. ``` Sep accrual: Dr Professional fees 12,000 / Cr Accrued liabilities 12,000 Oct 1 reversal: Dr Accrued liabilities 12,000 / Cr Professional fees (12,000) Oct invoice: Dr Professional fees 11,400 / Cr Accounts payable 11,400 Net Oct effect: (600) — the true-up, automatically ``` **UI rules:** - `AdjustingEntryForm` **defaults `autoReverse: true`** for accruals and displays the reversal date inline: "Reverses 1 Oct 2026". - The scheduled reversal is **visible in the next period before it posts**, so a reviewer sees it coming. - **Estimate vs. actual is tracked.** The schedule shows what was accrued, what the actual turned out to be, and the true-up — this history is how accrual estimates get better, and it's what an auditor asks for. - **Accruals aging beyond two periods are flagged.** A liability accrued three months running with no invoice is either a bad estimate or a missing vendor. --- ## 4. Deferred revenue (ASC 606 / IFRS 15) Cash received or billed, revenue not yet earned. The mirror of prepaids, and the one with the most regulatory structure. The five-step model, as it shows up in the UI: 1. Identify the contract 2. Identify the **performance obligations** — the unit of recognition 3. Determine the transaction price 4. **Allocate the price to obligations** — by standalone selling price 5. Recognize revenue as each obligation is satisfied ```ts type PerformanceObligation = { contractId: string; description: string; // "Platform subscription", "Implementation" allocatedPrice: Money; // step 4 — uses largest-remainder allocation standaloneSellingPrice: Money; recognitionMethod: "ratable" | "point-in-time" | "input-measure" | "output-measure" | "usage"; // ASC 606 measures of progress serviceStart?: Date; serviceEnd?: Date; recognizedToDate: Money; deferredBalance: Money; }; ``` **UI rules:** - **Allocation across obligations uses largest-remainder** so the allocated amounts sum exactly to the transaction price. Off-by-a-cent allocation is a real and common bug. - **Contract asset vs. contract liability are different balance sheet lines.** Consideration received (or unconditionally due) ahead of delivery is a contract liability (deferred revenue). Revenue recognized before the right to payment is unconditional is a contract asset; recognized with an unconditional right but not yet invoiced is an unbilled receivable. Within a contract the position is presented net, as one asset or one liability (required); across contracts it is never netted, and the component prevents it. - **The revenue waterfall is a first-class view**: how much of the current deferred balance recognizes in each future period. This is the number every SaaS board asks for. - **Variable consideration and constraints** are flagged rather than modeled — the estimate is a judgment, and the UI's job is to make sure it's documented and reviewed, not to compute it. --- ## 5. Fixed assets & depreciation ```ts type FixedAsset = { tag: string; description: string; category: string; // drives default life and method cost: Money; inServiceDate: Date; // NOT the purchase date — depreciation starts here usefulLifeMonths: number; salvageValue: Money; method: "straight-line" | "declining-balance" | "sum-of-years" | "units-of-production"; convention: "full-month" | "mid-month" | "half-year" | "mid-quarter"; accumulatedDepreciation: Money; netBookValue: Money; // cost − accumulated disposal?: { date: Date; proceeds: Money; gainLoss: Money }; }; ``` **UI rules:** - **In-service date, not purchase date.** An asset bought in March and installed in June depreciates from June. This distinction is a frequent source of error and the form labels it explicitly. - **The convention is visible.** Half-year, mid-month and mid-quarter conventions change the first and last period's expense, and a user reconciling to a tax schedule needs to know which is in force. - **Book and tax depreciation are separate schedules.** They use different lives and methods, and the difference creates a deferred tax item. The component supports parallel books and shows the difference. - **Disposal computes gain/loss and shows the working**: `Proceeds − NBV = Gain/(Loss)`. Removing an asset without recognizing gain or loss is a misstatement. - **A worked straight-line example.** Equipment costing $48,000 with a $3,000 salvage value and a 60-month life depreciates `(48,000 − 3,000) ÷ 60 = 750` a month. Sold after 26 months for $25,000: accumulated depreciation is 19,500, NBV is 28,500, and the disposal posts Dr Cash 25,000 / Dr Accumulated depreciation 19,500 / Dr Loss on disposal 3,500 / Cr Equipment 48,000. - **NBV must never go below salvage value.** Asserted, and a schedule that would breach it is an error state. - **Fully depreciated assets still in service are shown**, greyed. Their NBV equals salvage value (often zero) but they exist, and dropping them from the register loses the asset. --- ## 6. Intangibles and amortization Same shape as fixed assets, with two differences the UI must reflect: - **Goodwill and indefinite-lived intangibles are not amortized** — they are tested for impairment (US GAAP private companies may elect to amortize goodwill over up to ten years). The schedule shows them with no amortization row and an impairment test date. - **Impairment is a judgment event**, recorded with its trigger, its measurement basis, and its approver. Goodwill impairment is the excess of a reporting unit's carrying amount over its fair value, capped at the goodwill (ASU 2017-04); a long-lived asset group is first tested for recoverability on undiscounted cash flows (ASC 360-10-35-17). - **Internal-use software is changing.** ASU 2025-06 (ASC 350-40), effective for annual periods beginning after 15 December 2027, removes the preliminary / development / post-implementation stages: costs are capitalized once management has authorized and committed funding and it is probable the project will be completed and used as intended, with significant development uncertainty resolved first. A capitalization schedule should therefore record the date the threshold was met, not a stage. --- ## 7. Leases (ASC 842 / IFRS 16) Both frameworks put nearly all leases on the balance sheet: a right-of-use asset and a lease liability. ``` Lease liability = PV of lease payments not yet paid, at the discount rate ROU asset = Lease liability + prepaid lease payments + initial direct costs − lease incentives received ``` **UI rules:** - **The discount rate is displayed and its source stated** — implicit rate if determinable, otherwise incremental borrowing rate; an entity that is not a public business entity may elect a risk-free rate by class of asset (ASU 2021-09). It drives every number in the schedule and it's a judgment. - **Short-term leases may stay off the balance sheet.** A lease of twelve months or less with no purchase option reasonably certain to be exercised can be expensed straight-line by policy election (ASC 842-20-25-2); the schedule records the election rather than an empty ROU line. - **Under ASC 842, operating vs. finance classification changes the P&L and the balance sheet presentation.** Operating leases produce a single straight-line lease expense; finance leases split into amortization and interest; the two are presented as separate lines or disclosed. IFRS 16 has no lessee classification — every lease is depreciation plus interest. The component renders both correctly and labels which. - **The ROU asset and the lease liability amortize on different curves** and diverge over the lease term. Showing them as one schedule is wrong; `ScheduleTable` renders parallel columns. - **Modifications and remeasurements are events**, recorded with dates and reasons, that produce a new schedule from that point — never a silent edit of the original. - **The maturity analysis** (undiscounted payments by year, reconciled to the discounted liability) is a required disclosure and a first-class view. --- ## 8. Allowances and provisions Valuation allowances against assets (credit losses, inventory obsolescence) and estimated liabilities (warranty, refund liabilities for expected returns). **UI rules:** - **The methodology is displayed, not just the number.** "Allowance = 1% of current + 4% of 1-30 + 10% of 31-60 + 35% of 61-90 + 100% of 90+, adjusted for current conditions and forecasts" — with the calculation shown and each input traceable to the aging. - **The true-up is visible.** Opening allowance → provision expense → write-offs → recoveries → closing allowance. A roll-forward like everything else. - **A write-off is not a provision.** Writing off a specific receivable reduces both AR and the allowance with no P&L impact; increasing the provision hits the P&L. Conflating them is a classic error and the UI names each action distinctly. - **CECL / expected-credit-loss models** (ASC 326-20) are supported as an input to the methodology, not modeled by the component. Lifetime losses are booked from day one — a current invoice carries an allowance too. ASU 2025-05, effective for annual periods beginning after 15 December 2025, lets any entity assume current conditions at the balance sheet date persist for current AR and contract assets, and lets entities that are not public business entities also consider collections received after the balance sheet date; the component records which election is in force and the collection cut-off date used. The judgment belongs to the accountant; the UI's job is to record it and show the working. --- ## 9. Recurring and schedule-generated entries Entries a schedule produces automatically each period: amortization, depreciation, revenue recognition, straight-line lease expense. **UI rules:** - **They are `source: "system"` and read-only.** A wrong output means a wrong schedule; the UI routes the user to the schedule, not to the entry. - **They are previewable before posting.** A close should never be surprised by what the schedules generated — `SchedulePreview` shows the full set of entries the period will produce, with totals, before anything posts. - **A schedule that fails to generate is a blocking close exception**, surfaced in the close checklist, not a silent absence. - **Posting is a batch with one audit record**, not N unrelated entries. Reversing a batch reverses all of it. --- ## 10. The schedule inventory Every balance sheet account maps to exactly one supporting schedule type. This mapping is the backbone of `AccountTieOut`, and an account with no mapping is itself a finding. | Account | Schedule | Component | |---|---|---| | Cash | Bank reconciliation | `ReconciliationView` | | Accounts receivable | AR aging + allowance roll-forward | `AgingTable` · `RollForward` | | Inventory | Inventory roll-forward + reserve | `RollForward` | | Prepaid expenses | Prepaid amortization schedule | `ScheduleTable` | | Fixed assets | Fixed asset register + depreciation | `ScheduleTable` | | Intangibles | Amortization schedule + impairment | `ScheduleTable` | | ROU assets | Lease schedule | `ScheduleTable` | | Accounts payable | AP aging | `AgingTable` | | Accrued liabilities | Accrual schedule with true-ups | `ScheduleTable` | | Deferred revenue | Recognition waterfall by obligation | `RevenueWaterfall` | | Lease liabilities | Lease amortization + maturity analysis | `ScheduleTable` | | Debt | Debt schedule + covenant compliance | `ScheduleTable` · `CovenantMonitor` | | Equity | Equity roll-forward | `EquityRollForward` | | Any other BS account | Account reconciliation | `AccountTieOut` | --- # Order to cash (AR) The complete receivables stack: quote → order → fulfilment → invoice → collection → cash application → revenue. The rule that governs the whole family: > **An invoice is a claim, a payment is cash, and revenue is recognition. They are three different events on three different dates, and a UI that conflates any two of them will misstate something.** --- ## 1. The lifecycle ``` Quote → Sales order → Fulfilment → Invoice → Dunning → Payment → Application → Cash │ │ │ │ │ │ │ │ │ └─ Dr AR / Cr Revenue │ │ │ └─ revenue recognition trigger (point-in-time) │ └─ backlog; no GL impact └─ no GL impact └─ Dr Cash / Cr AR ``` **No GL impact until the invoice.** Quotes and orders are commitments, not transactions. Showing them in a revenue figure is the most common way a pipeline gets mistaken for performance. `SalesOrderTable` renders backlog and is visually separated from anything ledger-derived. ### States | State | Meaning | GL | |---|---|---| | `draft` | Being prepared | None | | `sent` | Delivered to the customer | Dr AR / Cr Revenue (or deferred) | | `viewed` | Customer opened it | None — but it kills the "never received it" excuse | | `partially-paid` | Some cash applied | Dr Cash / Cr AR, partial | | `paid` | Fully applied | — | | `overdue` | Past due date, unpaid | None; a presentation state | | `disputed` | Customer contests it | None; blocks dunning | | `credited` | Offset by a credit note | Dr Revenue (+ Dr Sales tax payable) / Cr AR | | `written-off` | Uncollectible | Dr Allowance / Cr AR | | `void` | Cancelled before it was ever valid | Reversal of the original | **`void` and `credited` are different and the distinction matters.** Voiding says the invoice should never have existed; crediting says it existed and is being reversed for a reason. A voided invoice with a payment against it is an error state. `InvoiceDetail` offers void only while unpaid and within the open period. --- ## 2. The invoice ```ts type Invoice = { number: string; // sequential and unique — many VAT regimes require it customerId: string; issueDate: Date; dueDate: Date; // derived from terms; overridable with a reason terms: PaymentTerms; currency: string; lines: InvoiceLine[]; subtotal: Money; discounts: Discount[]; taxes: TaxLine[]; // per jurisdiction, per rate — never one lump total: Money; amountPaid: Money; amountCredited: Money; balanceDue: Money; // total − paid − credited exchangeRate?: { rate: number; asOf: Date }; // if not in functional currency poNumber?: string; // customer's PO — required by many AP departments revenueSchedule?: PerformanceObligation[]; }; ``` **UI rules:** - **Invoice numbers are sequential and unique.** Many VAT regimes require it, and gaps are a standard audit red flag. The UI never lets a user pick a number, and a gap is an audit finding surfaced in `InvoiceRegister`. - **`balanceDue` is the number that matters**, and it's the one shown largest. Total is context; balance is the ask. - **Taxes are itemized by jurisdiction and rate**, never a single "Tax" line. A customer disputing tax needs to see which authority and which rate. - **The customer PO number is prominent when present.** An invoice missing the PO an AP department requires will be rejected and the user needs to see that before sending. - **Foreign-currency invoices show the rate and date used.** The receivable is recorded at the transaction-date rate, revalued at period end, and settled at the payment-date rate — three rates, two FX gain/loss events. See [multi-entity & FX](09-multi-entity-and-fx.md). ### Payment terms ```ts type PaymentTerms = | { kind: "net"; days: number } // Net 30 | { kind: "eom"; days: number } // Net 30 EOM | { kind: "discount"; discountPct: number; discountDays: number; netDays: number } // 2/10 net 30 | { kind: "due-on-receipt" } | { kind: "installments"; schedule: { date: Date; amount: Money }[] }; ``` **`2/10 net 30` must be rendered in full, not as a code.** "2% discount if paid within 10 days, otherwise due in 30 days" — and the component computes and shows both the discounted amount and the discount deadline, because that's the decision the customer is making. --- ## 3. Aging The core AR view, and the one that ties to the balance sheet. | Bucket | Definition | Typical treatment | |---|---|---| | **Current** | Not yet due | Normal | | **1–30** | Past due up to a month | Reminder | | **31–60** | | Escalation | | **61–90** | | Collections | | **90+** | | Allowance candidate, possible write-off | **UI rules:** - **Aging is by due date, not invoice date.** Aging from the invoice date makes Net 60 terms look delinquent on day 31. This is a common and embarrassing bug. - **The aging total ties to the AR control account.** Displayed, asserted. A difference means either the aging or the ledger is wrong. - **Row totals and column totals both foot, and the grand total is proven both ways.** - **Bucket severity uses a sequential lightness ramp**, not a red gradient — it survives greyscale and the signal palette. Age is also stated numerically ("47 days past due"), never color alone. - **Credit balances in AR are shown, not hidden.** A customer with a net credit (overpayment, unapplied cash) appears as a negative in the aging and should arguably be reclassified to a liability at period end. The component flags it rather than burying it. - **Disputed invoices are excluded from dunning but included in aging.** They are still receivables. --- ## 4. Cash application Where AR actually breaks. Money arrives; matching it to invoices is the work. ```ts type PaymentApplication = { paymentId: string; amountReceived: Money; applications: { invoiceId: string; amount: Money }[]; unapplied: Money; // received − sum(applied) writeOff?: { amount: Money; reason: string; account: string }; }; ``` **The cases the component must handle — all of them, not just the happy path:** | Case | Treatment | |---|---| | **Exact match** | One payment, one invoice, same amount. Auto-applied. | | **Partial payment** | Applies to one invoice, leaves a balance. Invoice stays `partially-paid`. | | **Overpayment** | Excess becomes **unapplied cash** — never revenue; a liability once it leaves the customer in a net credit. | | **Lump sum, many invoices** | The common case. Apply across invoices; the UI suggests a combination that sums exactly. | | **Short pay** | Customer deducts. Either a dispute, an unrecorded credit, or a write-off. **Requires a reason.** | | **Payment with no invoice** | Unapplied cash. Sits on the balance sheet until applied. | | **Duplicate payment** | Flagged by amount + customer + date proximity. Refund or apply forward. | | **Bank fee deducted in transit** | Received is less than remitted. The difference is a bank charge expense, not a short pay. | | **FX difference on settlement** | Realized FX gain/loss. Posted, not written off. | **UI rules:** - **Unapplied cash is never revenue.** A payment from a customer with open invoices reduces that customer's AR even before it's matched; only a net credit balance (or a prepayment with no receivable) is a liability, reclassified at period end. Products that book it to revenue, or leave a credit balance inside AR, misstate both. - **The suggested-combination feature is the highest-value interaction in AR.** Given a lump sum, propose the set of invoices that sums exactly to it. When multiple combinations exist, show them ranked by oldest-first. - **A remaining difference always requires a disposition** — write-off (with reason and account), leave unapplied, or dispute. The UI never lets a difference silently disappear. - **Write-off thresholds enforce approval.** Small differences auto-write-off to a configured account; anything above the threshold requires an approver. - **Applying is reversible** (unapply), and both actions are audited. --- ## 5. Collections and dunning ```ts type DunningStep = { offsetDays: number; // relative to due date; negative = pre-due reminder channel: "email" | "letter" | "task" | "call"; template: string; escalateTo?: string; pauseIf: ("disputed" | "payment-plan" | "manual-hold")[]; }; ``` **UI rules:** - **Disputes pause dunning automatically.** Chasing a customer for an invoice they've formally disputed damages the relationship and is the single most common complaint about automated collections. - **The full communication history is on the customer**, not scattered per invoice. A collector needs one timeline. - **Promise-to-pay is a first-class object** with a date and an amount, and a broken promise escalates. "Customer said they'd pay Friday" is the most actionable fact in collections and it belongs in the data model. - **Payment plans** convert one receivable into an installment schedule without changing the total. The aging reflects the plan's due dates, not the original. --- ## 6. Credit notes and refunds Three different things, constantly conflated: | | What it does | GL | |---|---|---| | **Credit note** | Reduces what the customer owes | Dr Revenue (or contra-revenue) + Dr Sales tax payable / Cr AR | | **Refund** | Returns cash already received | Dr AR (or liability) / Cr Cash | | **Write-off** | Abandons an uncollectible claim | Dr Allowance / Cr AR | **A write-off is not a credit note.** A credit note says the customer doesn't owe it; a write-off says they owe it and won't pay. The revenue treatment and the tax treatment differ, and so does what it tells you about the business. The UI names each distinctly and never offers one where the other is meant. **Credit notes reference their original invoice** and cannot exceed its balance without an explicit override. --- ## 7. Customer master ```ts type Customer = { id: string; name: string; legalName?: string; billingAddress: Address; shippingAddress?: Address; taxId?: string; taxExemptCertificate?: Document; defaultTerms: PaymentTerms; creditLimit?: Money; currency: string; parentCustomerId?: string; // hierarchy for group-level exposure contacts: Contact[]; status: "active" | "on-hold" | "inactive"; }; ``` **UI rules:** - **Credit limit and current exposure shown together**, with exposure including unbilled orders. A limit without exposure is decoration. - **Customer hierarchy matters for exposure.** Three subsidiaries of one group are one credit risk, and `CustomerStatement` supports rolling up. - **Tax exemption certificates expire.** An expired certificate on an active customer is a flagged exception, because invoicing them tax-free after expiry is a liability. - **On-hold blocks new orders but not collections.** --- ## 8. Revenue recognition, separated from billing The most important structural point in this document. **Billing and recognition are independent.** You can bill annually and recognize monthly, deliver first and bill later, or bill milestones that don't match delivery. Every combination is normal. ``` Received/due > Recognized → contract liability (deferred revenue) Recognized > Billed → contract asset (right still conditional) or unbilled receivable (right unconditional) ``` **UI rules:** - **An invoice does not automatically recognize revenue.** It creates a receivable. What it credits — revenue or deferred revenue — depends on the performance obligations. `InvoiceForm` shows which, per line, before posting. - **The revenue waterfall is derived from obligations, not invoices.** - **Never net contract assets against contract liabilities across contracts.** Within a contract the position is presented net (required); across contracts it is not. - Full model in [schedules & accruals](04-schedules-and-accruals.md#4-deferred-revenue-asc-606--ifrs-15). --- ## 9. AR metrics | Metric | Formula | Watch out | |---|---|---| | **DSO** | `(AR ÷ credit sales) × days in period` | Distorted by seasonality — prefer the countback method for volatile revenue | | **DSO (countback)** | Subtract each period's revenue from AR until exhausted; count the days | More robust, harder to explain — show the working | | **Best possible DSO** | `(Current AR ÷ credit sales) × days` | The floor DSO could reach with perfect collection | | **CEI** | `(Opening AR + credit sales − closing total AR) ÷ (opening AR + credit sales − closing current AR)` | Collections effectiveness; better than DSO for team performance | | **ADD** | `DSO − best possible DSO` | Average days delinquent — the actual lateness | | **Bad debt rate** | `Write-offs ÷ credit sales` | | | **% past due** | `Past-due AR ÷ total AR` | The simplest health signal | **Every metric displays its formula, period, and inputs** via `MetricDefinition`. DSO computed two different ways gives two different numbers, and a dashboard that doesn't say which is not comparable to anything. --- ## 10. AR component set | Component | Purpose | |---|---| | `InvoiceForm` | Create/edit with lines, tax, terms, and the recognition preview | | `InvoiceDetail` | Full invoice with payment history, credits, and the audit trail | | `InvoiceRegister` | All invoices, filterable, with the gapless-numbering check | | `AgingTable` | By bucket, tied to the control account | | `CashApplication` | The matching workspace — the highest-value screen in AR | | `PaymentSuggestion` | Proposed invoice combinations for a lump sum | | `UnappliedCashPanel` | Outstanding customer credits awaiting application | | `CreditNoteForm` | Referenced to an invoice, capped at its balance | | `CollectionsWorklist` | Prioritized by amount × age × risk, with promise-to-pay | | `DunningTimeline` | Communication history per customer | | `CustomerStatement` | Printable, per-customer, open items or full activity | | `CustomerCreditPanel` | Limit, exposure, hierarchy roll-up | | `RevenueWaterfall` | Deferred balance recognizing by future period | --- # Procure to pay (AP) The complete payables stack: requisition → PO → receipt → bill → approval → payment → reconciliation. AP is where fraud happens and where controls matter most. The design consequence: > **Every AP screen is a control surface.** Approval, segregation of duties, and duplicate detection are not features bolted onto a form — they are the reason the form exists. --- ## 1. The lifecycle ``` Requisition → PO → Goods receipt → Bill → Match → Approve → Schedule → Pay → Reconcile │ │ │ │ │ │ │ │ │ │ │ └────────┴─ the control gate │ │ │ │ └─ Dr GR/IR or Expense / Cr AP │ │ │ └─ Dr Inventory/Expense / Cr GR/IR (if used) │ │ └─ commitment; no GL impact Dr AP / Cr Cash └─ no GL impact ``` **No GL impact from requisitions or POs.** The GL moves at goods receipt (if GR/IR is used) or at the bill. POs are commitments. Showing committed spend in an expense figure overstates it; showing only booked spend understates the obligation. Both matter — a spend view renders **booked, accrued, and committed as three distinct series**, never summed into one "spend" number. --- ## 2. Three-way match The core AP control. Three documents must agree before payment. ``` Purchase order 100 units @ $12.00 = $1,200.00 Goods receipt 98 units received Vendor invoice 100 units @ $12.00 = $1,200.00 ───────────────────────────────── Quantity variance 2 units short ($24.00) ⚠ requires disposition ``` | Match type | Compares | Used for | |---|---|---| | **Two-way** | PO ↔ invoice | Services, no physical receipt | | **Three-way** | PO ↔ receipt ↔ invoice | Goods | | **Four-way** | + inspection/quality | Regulated or high-value goods | **UI rules:** - **Tolerances are configurable and displayed.** "Matching within $50 or 2%" — a reviewer needs to know what is auto-passing. - **A variance requires a disposition**, never a silent pass: accept (with reason), request a vendor credit, partial-pay, or reject. - **The three documents are shown side by side**, line by line, with differences highlighted at the line level, not just the header. A header total that matches while lines don't is a common way overbilling hides. - **`MatchExceptionQueue` is the AP clerk's worklist**, sorted by age and amount. --- ## 3. The bill ```ts type Bill = { vendorId: string; vendorInvoiceNumber: string; // THEIR number — the duplicate-detection key billDate: Date; dueDate: Date; terms: PaymentTerms; currency: string; lines: BillLine[]; // each with account + full dimensions taxes: TaxLine[]; total: Money; poReference?: string; approvals: Approval[]; status: BillStatus; paymentSchedule?: Date; attachments: Document[]; // the source invoice — required }; ``` **UI rules:** - **Duplicate detection runs before the form is saved**, keyed on `vendor + vendorInvoiceNumber`, with a secondary fuzzy check on `vendor + amount + date ± 5 days`. **Duplicate payment is one of the most common and most preventable AP losses**, and the check must be a blocking warning showing the suspected duplicate side by side. - **The source document is required.** A bill with no attachment cannot be approved. This is the evidence an auditor asks for and it should never be optional. - **The expense account and full dimensions are per line**, not per bill. A single invoice legitimately hits four departments. - **Default coding is suggested from history** — this vendor's last bills — and the suggestion is visibly a suggestion, with the confidence and the basis shown. - **Early-payment discounts are surfaced as a decision.** "Pay by 12 Oct to save $240 (2%)" with the annualized return, because 2/10 net 30 is a ~37% annualized return and finance teams miss it constantly. --- ## 4. Approval ```ts type ApprovalPolicy = { thresholds: { upTo: Money; approvers: Role[]; requireAll: boolean }[]; requireSeparateApprover: true; // preparer ≠ approver. Never configurable to false. dimensionApprovers?: Record; // department owner must approve their spend delegations: { from: string; to: string; until: Date }[]; }; ``` **UI rules:** - **Segregation of duties is enforced, not advisory.** The person who created a bill cannot approve it. This is not a setting. - **The approval chain is visible before submission** — who has to approve, in what order, and what's already done. An approval that disappears into a queue with no visibility is the reason AP teams chase people on Slack. - **Threshold escalation is shown at entry.** "Bills over $10,000 require CFO approval" appears while typing the amount, not after submitting. - **Delegation has an expiry.** Permanent delegation is a control failure and the UI requires an end date. - **Rejection requires a reason and routes back to the preparer** with it visible. - **Approval is on a specific version.** If a bill is edited after approval, approvals are voided and re-requested — with the change shown. Silent post-approval editing is a fraud vector. --- ## 5. Payment runs ```ts type PaymentRun = { runDate: Date; paymentDate: Date; method: "ach" | "wire" | "check" | "card" | "rtp"; bills: string[]; totalAmount: Money; fundingAccount: string; status: "draft" | "approved" | "submitted" | "settled" | "partially-failed"; }; ``` **UI rules:** - **The run is previewed in full before submission**: vendor, amount, method, bank details, and the resulting cash balance after the run. The most important number is **the cash left afterwards** — a payment run that overdraws is a very expensive UI failure. - **Bank detail changes are the highest-risk event in AP.** A vendor bank change within N days of a payment is flagged, requires re-verification through an out-of-band channel, and is displayed prominently in the run preview. This is how vendor-impersonation fraud works and the UI is the last line of defense. - **Payment runs use `HoldToConfirm`.** They are irreversible movements of money, often large. - **Partial failures are a first-class state.** A run where 40 of 42 payments settled is not "failed"; the UI shows exactly which two and what to do. - **Positive pay files** and check numbering are tracked; voided checks are recorded, never deleted. - **Cash requirements forecast** — what's due in the next 7/14/30 days versus available cash — is the view AP managers actually plan from. --- ## 6. Vendor master ```ts type Vendor = { id: string; name: string; legalName: string; taxId?: string; // W-9 / TIN form1099?: { required: boolean; box: string }; bankDetails: BankDetail[]; // versioned, never overwritten defaultTerms: PaymentTerms; defaultAccount?: string; currency: string; status: "active" | "on-hold" | "inactive"; approvedBy?: string; // vendor onboarding is itself an approval }; ``` **UI rules:** - **Bank details are versioned, never overwritten.** The history — who changed them, when, and what verification was performed — is the audit trail that matters most in AP. - **Vendor onboarding requires approval** and is separate from bill entry. Whoever can create a vendor should not be able to pay one. - **1099 tracking flags missing W-9s** before year-end, when it's still fixable. The Form 1099-NEC / 1099-MISC threshold is $600 for payments made through 2025 and $2,000 for payments made from 1 January 2026 (One Big Beautiful Bill Act, indexed for inflation from 2027) — the threshold is a dated table, not a constant. Payments by card or a third-party network are reported by the processor on Form 1099-K, not by the payer. A payee who will not provide a TIN triggers 24% backup withholding. - **Duplicate vendor detection** on name similarity and tax ID — duplicate vendors are how duplicate payments survive duplicate-invoice checks. - **Sanctions/OFAC screening status** is displayed where the integration exists. Paying a sanctioned entity is a legal event, not an accounting one. --- ## 7. Expense management Employee-initiated spend. Same control model, different ergonomics — it's mobile-first, because receipts are photographed on phones. **UI rules:** - **Receipt capture is camera-first**, with OCR pre-filling amount, date, and merchant — all shown as editable suggestions with the extracted value visible next to the image. - **Policy violations are shown at entry**, not at approval. "Meals over $75 require a business purpose" appears while typing, so the fix is cheap. - **Corporate card transactions are matched to receipts**, not re-entered. The unmatched-receipt and unmatched-transaction queues are both first-class. - **Personal vs. reimbursable vs. corporate-card-paid** are three different settlement paths and the UI never guesses. - **Mileage and per diem** use rate tables with effective dates — the rate that applied on the trip date, not today's. --- ## 8. Accruals from AP At period end, goods and services received but not invoiced must be accrued. This is the bridge from AP to the close. **The GR/IR (goods received / invoice received) clearing account** holds the gap: ``` Receipt: Dr Inventory/Expense / Cr GR-IR clearing Invoice: Dr GR-IR clearing / Cr Accounts payable Balance in GR-IR = received not yet invoiced = the accrual ``` **UI rules:** - **The GR/IR balance is a schedule that must be aged and cleared.** Old balances mean either a missing invoice or a receipt that never happened, and both are findings. - **`UninvoicedReceiptsReport` drives the period-end accrual entry** — it is the evidence for it. - **Open POs at period end are not accrued.** Only significant unconditional or noncancelable purchase obligations are disclosed (ASC 440), and a loss on a firm commitment is accrued. Different treatment, and the UI must not conflate them. --- ## 9. AP metrics | Metric | Formula | Watch out | |---|---|---| | **DPO** | `(AP ÷ COGS) × days in period` | Higher isn't automatically better — it may mean you're late, not efficient | | **CCC** | `DSO + DIO − DPO` | The working-capital cycle in days | | **On-time payment rate** | `Bills paid by due date ÷ bills paid` | Vendor relationship health | | **Early-discount capture** | `Discounts taken ÷ discounts available` | Usually shockingly low; high-ROI to fix | | **Invoice cycle time** | Receipt → approval → payment, in days | Where the bottleneck is | | **Cost per invoice** | Processing cost ÷ invoice count | The automation business case | | **Duplicate rate** | Duplicates caught ÷ invoices | The control's effectiveness | **DPO is the metric most likely to be misread.** Rising DPO looks like improved cash management and may be deteriorating vendor relationships. Lopifi renders it with the on-time payment rate adjacent, always — one number without the other is misleading. --- ## 10. AP component set | Component | Purpose | |---|---| | `BillForm` | Entry with duplicate detection, line coding, and dimension capture | | `BillDetail` | Full bill with the source document, match, approvals, and audit trail | | `BillRegister` | All bills, filterable, with aging | | `ThreeWayMatch` | PO / receipt / invoice side by side, line-level variances | | `MatchExceptionQueue` | The clerk's worklist | | `ApprovalChain` | Who, in what order, what's done, what's blocking | | `ApprovalInbox` | The approver's queue, mobile-first, with the document inline | | `PaymentRunBuilder` | Select bills, preview, show the resulting cash balance | | `PaymentRunPreview` | Final confirmation with hold-to-confirm | | `CashRequirements` | Due in 7/14/30 days vs. available cash | | `VendorMaster` | With versioned bank details and change history | | `BankDetailChangeAlert` | The fraud-prevention surface | | `ExpenseCapture` | Camera-first, OCR-assisted, mobile | | `CardTransactionMatch` | Corporate card ↔ receipt matching | | `UninvoicedReceiptsReport` | GR/IR aging, drives the accrual | | `AgingTable` | AP aging, tied to the control account | --- # Cash & treasury Cash is the only balance sheet account with an independent third party asserting what it should be. That makes it both the most verifiable account and the one where a break matters most. --- ## 1. Balances are plural A single "cash balance" is almost always wrong. There are at least four, and they differ. | Balance | Meaning | Source | |---|---|---| | **Book (GL)** | What we've recorded | GL | | **Bank current** | What the bank has posted | Bank feed | | **Bank available** | Posted, minus holds, plus same-day credit | Bank feed | | **Projected** | Available, minus scheduled payments, plus expected receipts | Derived | **UI rules:** - **`BalanceCard` shows available as the figure and current as a secondary line when they differ.** Conflating them is the most consequential copy error in banking UI. - **The ledger balance and the bank balance differ legitimately** — deposits in transit, outstanding checks. That difference is the reconciliation, not an error. - **Projected balance is a forecast and is rendered as such** — dashed on charts, labeled, with the assumptions reachable. - **Every balance carries its "as of" and its source.** A cached bank balance shown as current is the most dangerous state in the product; `StaleIndicator` exists for it. --- ## 2. Transaction states ``` pending → posted → reconciled │ │ │ └─ settled on the bank's books; reaches the GL once coded or matched └─ visible on the feed, may still change amount or vanish entirely ``` **A pending card authorization is not a transaction.** It can settle for a different amount (tips, fuel, hotels) or disappear. UI rules: - Pending transactions render at `opacity: 0.6` with a `--warning` left border, and are **excluded from the ledger balance**. - When a pending transaction settles at a different amount, the change is shown, not silently overwritten. - **Never reconcile a pending transaction.** --- ## 3. Bank reconciliation The full model is in [periods & close](02-periods-and-close.md#5-reconciliation-vs-tie-out). What matters at the component level: ### Matching ladder 1. **Exact** — amount, date, and reference all agree. Auto-matched, no review. 2. **Near** — amount agrees, date within tolerance. Auto-suggested, one-click accept. 3. **Rule-based** — a saved rule (this description pattern → this account). Suggested with the rule named. 4. **Many-to-one** — a bank deposit covering several invoices, or one payment covering several bills. **Common, and the case most tools handle worst.** 5. **One-to-many** — one book entry split across several bank lines. 6. **Unmatched** — the actual exception. **UI rules:** - **Confidence is shown with its basis**, not as a bare percentage. "Matched on amount and reference" is actionable; "94%" is not. - **Many-to-one selection shows a running sum against the target**, so the user can see when the selection is exact. - **Auto-matched items are reviewable and reversible.** An automation you cannot inspect is an automation you cannot trust at audit. - **The difference is always on screen.** It is the number being driven to zero. - The match animation is one of the four sanctioned delight moments — see [motion catalog](../motion/03-catalog.md). ### Categorization rules ```ts type CategorizationRule = { conditions: { field: "description" | "amount" | "counterparty" | "account"; op: Op; value: string }[]; actions: { account: string; dimensions?: Dimensions; memo?: string }; autoPost: boolean; // suggest vs. post automatically priority: number; }; ``` - **Rule creation happens inline from a transaction** — categorize one, offer to categorize the other 40 like it. The highest-leverage interaction in bookkeeping. - **Preview the match count before applying.** "This rule matches 47 existing transactions." - **`autoPost` is off by default.** A rule that posts to the GL without review is a control decision, and it should be a deliberate one. --- ## 4. Cash forecasting ``` Opening available balance 412,880 Add: expected receipts (AR due, by date) 184,200 Less: scheduled payments (AP due + payroll) (241,600) Less: committed (approved POs to invoice) (48,000) ───────────────────────────────────────────────────── Projected closing balance 307,480 ``` **UI rules:** - **Certainty tiers are visually distinct.** Contracted (scheduled payments), expected (AR by due date, with a collection-probability haircut), and forecast (modelled) are three different qualities of number and must not render identically. - **Show the minimum balance in the horizon, not just the endpoint.** A forecast ending at $300k that dips to −$40k in week three is the important fact, and an endpoint-only view hides it. - **Payroll is a separate, prominent line.** It is the payment that cannot be delayed. - **Scenario comparison** (base / downside / upside) uses distinct line styles as well as color, never color alone. --- ## 5. Multi-account treasury - **Sweeps and internal transfers are not income or expense.** They net to zero at the group level and must be eliminated from any cash-flow presentation. A dashboard that counts an internal transfer as a receipt is double-counting. - **`InternalTransferMatch`** pairs the two sides of a transfer and marks both as eliminated. - **Restricted cash is disclosed separately** and excluded from available cash. It is on the balance sheet but it is not spendable. - **Concentration risk**: cash by institution against insured limits (FDIC $250,000 per depositor, per insured bank, per ownership category — sweep and reciprocal-deposit networks can extend it). `CashConcentration` renders exposure by bank with the limit line. --- ## 6. Payment rails Different rails have different timing, reversibility, and cost, and the UI must state all three at the moment of choosing. | Rail | Settlement | Reversible? | Cutoff | |---|---|---|---| | **ACH** | Next or second business day; Same Day ACH up to $1M per payment | Limited — returns and reversals within Nacha windows (generally 2 banking days; 60 days for unauthorized consumer debits) | Three Same Day ACH windows; the last closes at 4:45 p.m. ET | | **Wire (domestic)** | Same day | **No** | Bank-specific, typically mid-afternoon | | **Wire (international)** | 1–5 days | **No** | Plus correspondent-bank delays | | **RTP / FedNow** | Seconds | **No** | 24/7 | | **Check** | 5–10 days + float | Stop-payment possible | Mail | | **Card** | 1–3 days | Chargeback window | | **UI rules:** - **Irreversible rails get `HoldToConfirm`.** Wires, RTP, FedNow. - **The cutoff is shown as a live countdown** where it matters. "Submit within 2h 14m for same-day settlement." - **Business days, not calendar days.** Weekends and bank holidays are respected in every arrival estimate, and the holiday calendar is per-country. - **The arrival estimate is always shown before confirming**, and it's the sentence users read most: "Arrives Tue 8 Sep". --- ## 7. Cash component set | Component | Purpose | |---|---| | `BalanceCard` | Available as the figure, current as the secondary line, with source and as-of | | `AccountCard` | Per-account, with connection health — connected / stale / reauth-required, prominent | | `TransactionTable` | Pending and posted, with settlement changes visible | | `ReconciliationView` | The two-pane matcher; suggestions carry their confidence basis, not a bare score | | `CategoryPicker` | Rule creation inline from a transaction, with match-count preview | | `CashRequirements` | Due in 7/14/30 vs. available | | `CashForecast`* | Certainty-tiered, showing the minimum in the horizon | | `InternalTransferMatch`* | Pairs and eliminates both sides | | `CashConcentration`* | Exposure by institution against insured limits | | `PaymentRailSelector`* | Timing, reversibility, and cost at the point of choice | \* Specified here, not yet in the [catalog](../catalog/00-index.md). --- # Controls & audit Accounting software is not a database with forms on top. It is a **system of controls**, and the UI is where most of those controls are actually enforced. --- ## 1. The control principles | Principle | What it means | How the UI enforces it | |---|---|---| | **Segregation of duties** | The person who prepares is not the person who approves | Enforced, never a setting. The approve action is absent, not disabled, for the preparer. | | **Authorization** | Spend above a threshold needs a specific role | Thresholds shown at entry, not after submission | | **Completeness** | Everything that happened is recorded | Gapless numbering; unmatched queues that cannot be dismissed | | **Accuracy** | Recorded amounts are right | Three-way match, tie-outs, balance checks | | **Validity** | Recorded things actually happened | Required source documents | | **Cutoff** | Recorded in the right period | Period states, cutoff warnings | | **Immutability** | History cannot be rewritten | Reverse, never edit or delete | | **Traceability** | Every number traces to its source | Drill-down at every level | ### Where the principles come from The list above is the working form of two frameworks. **COSO's *Internal Control — Integrated Framework* (2013)** — five components (control environment, risk assessment, control activities, information and communication, monitoring) and seventeen principles — is the framework almost every US registrant uses for its annual ICFR assessment under SOX §404(a). The auditor's side is **PCAOB AS 2201**. The assertions a control is designed to address — existence or occurrence, completeness, valuation or allocation, rights and obligations, presentation and disclosure (PCAOB AS 1105), with accuracy, cutoff and classification broken out separately in the AICPA's AU-C 315 — are the auditor's vocabulary for the same rows. Control failures are graded, and the UI should use the grade rather than a generic "issue": | Grade | Meaning | Consequence | |---|---|---| | **Control deficiency** | A control does not allow timely prevention or detection of misstatements | Tracked and remediated | | **Significant deficiency** | Less severe than a material weakness, yet merits the audit committee's attention | Communicated to those charged with governance | | **Material weakness** | A reasonable possibility that a material misstatement will not be prevented or detected on a timely basis | Disclosed; ICFR is not effective | --- ## 2. The audit trail Every state change on every financial object writes an immutable record. ```ts type AuditEvent = { id: string; at: Date; // server time, UTC, never client time actor: { id: string; name: string; role: string; onBehalfOf?: string }; action: string; // "posted" | "approved" | "reversed" | "reopened" | … objectType: string; objectId: string; before?: unknown; // full prior state for changed fields after?: unknown; reason?: string; // required for privileged actions source: "ui" | "api" | "integration" | "system"; requestId: string; // correlates a multi-object action ipAddress?: string; }; ``` **UI rules:** - **The audit trail is a first-class view on every object**, not a hidden admin log. `AuditTrail` renders as a timeline with actor, action, and diff. - **Field-level diffs**, not "record updated". "Amount changed from $1,240.00 to $1,420.00" is the useful form. - **Server time, UTC, timezone displayed.** A client-supplied timestamp is not evidence. - **`onBehalfOf` is rendered explicitly** — support and delegated access must be visibly distinguishable from the user acting themselves. - **The audit trail is append-only and cannot be filtered into invisibility.** Filters narrow the view and say so: "Showing 12 of 148 events." - **Privileged actions require a reason**: period reopen, write-off above threshold, approval override, bank detail change, permission grant. --- ## 3. Approvals ```ts type Approval = { step: number; requiredRole: string; approver?: { id: string; name: string; at: Date }; status: "pending" | "approved" | "rejected" | "voided"; reason?: string; onVersion: string; // the object hash approved }; ``` **`onVersion` is the critical field.** An approval applies to a specific version of the object. Edit it afterwards and every approval voids and must be re-obtained, with the change shown to the re-approver. Silent post-approval editing is the classic fraud path, and the version binding closes it. **UI rules:** - **The chain is visible before submission** — who, in what order, what's done, what's blocking. - **Rejection routes back with a reason**, visible to the preparer. - **Delegation expires.** Permanent delegation is a control failure; the UI requires an end date. - **The approver sees the evidence inline** — the source document, the match, the coding — not a link to it. An approver who has to click through to see what they're approving will stop clicking through. - **`ApprovalInbox` is mobile-first.** Approvals happen on phones, between meetings, and the document must be readable at 390px. --- ## 4. Roles A minimal, real set. Products that ship twenty granular permissions end up with everyone as admin. | Role | Can | |---|---| | **Viewer** | Read statements and transactions. No export of PII. | | **Preparer** | Create drafts, code transactions, prepare schedules. Cannot post or approve. | | **Accountant** | Post entries, reconcile, roll schedules. Cannot approve payments or close periods. | | **Controller** | Approve entries, close periods, set materiality. Cannot pay. | | **Treasurer** | Approve and release payments. Cannot post entries. | | **Admin** | Users, roles, integrations. **Cannot post, approve, or pay.** | | **Auditor** | Read everything including the audit trail. Write nothing, ever. | **The two structural separations:** Controller and Treasurer are separate because the person who records an obligation should not be the person who settles it. Admin cannot transact, because whoever grants permissions should not be able to use them. --- ## 5. Evidence ```ts type Evidence = { kind: "invoice" | "receipt" | "contract" | "statement" | "memo" | "screenshot"; file: Document; uploadedBy: string; uploadedAt: Date; hash: string; // content hash — proves it hasn't changed linkedTo: { objectType: string; objectId: string }[]; }; ``` **UI rules:** - **Required, not optional, on bills, expenses, adjusting entries, and reconciliations.** These are the four places an auditor will ask. - **The content hash is stored and displayed.** A document that changed after being attached is a finding, and it should be detectable. - **Evidence is viewable inline**, never download-only. `EvidencePanel` renders PDFs and images beside the object. - **Evidence is never deleted**, only unlinked, and the unlink is audited. --- ## 6. Data integrity checks Assertions that run continuously and surface as exceptions, not as silent corrections. | Check | Assertion | |---|---| | Entry balance | Debits = credits, every entry | | Accounting equation | Assets = Liabilities + Equity, every period | | Subledger tie | AR aging = AR control; AP aging = AP control | | Schedule tie | Every schedule closing = its GL account | | Roll-forward continuity | Opening = prior closing, every schedule | | Cash tie | Cash flow ending cash = balance sheet cash | | Retained earnings | Opening RE + NI − distributions = closing RE | | Intercompany | IC receivables = IC payables across entities | | Numbering | Invoice and entry numbers gapless | | Period integrity | No postings in closed periods | | Orphan detection | No entries referencing deleted accounts or dimensions | **A failing check is the primary state of its surface.** Not a badge in a corner — the screen's main content becomes the failure, the difference, and the investigation path. A balance sheet that doesn't balance is not a balance sheet with a small problem. --- ## 7. Auditor mode A read-only view built for the annual audit, because the alternative is exporting everything to spreadsheets and emailing them. - **Read-only, enforced server-side.** Not a UI state. - **Scoped to a period and an entity set**, with the scope displayed and immutable during the session. - **Sampling support** — select N transactions by a stated method (random, monetary-unit, judgmental) with the seed recorded so the selection is reproducible. - **Evidence bundling** — for any selection, export the entries, their source documents, their approvals, and their audit trail as one package with a manifest. - **Auditor activity is itself audited**, and visible to the client. Transparency runs both ways. --- ## 8. Privacy and retention - **No monetary amount in any analytics event, log line, or error report.** Events carry the shape of an interaction, never the amount. - **PII in exports is role-gated**, and the export is audited with its scope. - **Retention is jurisdictional** — commonly 7 years, sometimes 10. Records inside retention cannot be deleted by any role, including admin. - **Deletion requests** (GDPR and equivalents) conflict with retention obligations. The UI supports pseudonymization of the person while retaining the transaction, and states which was applied. - **Copy-to-clipboard copies raw values**, and clipboard actions on sensitive fields are audited. --- # Multi-entity & FX Consolidation and currency are where finance UIs quietly produce wrong numbers, because both involve summing things that look summable and aren't. --- ## 1. The consolidation model ```ts type Entity = { id: string; name: string; legalName: string; functionalCurrency: string; // the currency it primarily operates in parentId?: string; ownershipPct: number; // of the immediate parent's interest consolidationMethod: "full" | "equity" | "proportional" | "none"; fiscalCalendar: FiscalCalendar; // subsidiaries can differ from the parent }; ``` | Ownership | Method | Treatment | |---|---|---| | Control (presumed > 50% of votes; a VIE or IFRS 10 power-and-returns test can override) | **Full consolidation** | Line-by-line, with non-controlling interest for the minority share | | 20–50% | **Equity method** | One line: investment in associate, moving with the share of profit | | < 20% | **Cost / fair value** | An investment asset | | Joint control | **Equity method** for a joint venture | IFRS 11 joint operations recognise their share of assets and liabilities; US GAAP allows proportionate consolidation only in narrow industries | **UI rules:** - **The consolidation scope is always stated in the statement header.** "Northwind Holdings Ltd. and subsidiaries" versus "Northwind Holdings Ltd. (standalone)" are different statements and must never be confused. - **Entity selection is explicit, never inferred from a user's default.** A user who thinks they're looking at the group and is looking at one subsidiary is being misled. - **Non-controlling interest appears on both statements** — as a component of equity on the balance sheet, and as an allocation of net income on the P&L. Omitting it overstates the parent's share. - **Subsidiaries with different fiscal year-ends** require either aligned reporting or a stated adjustment. The UI flags the mismatch rather than silently summing. --- ## 2. Intercompany Transactions between group entities must eliminate on consolidation. They are real for each entity and unreal for the group. ``` Entity A sells $100k of services to Entity B ───────────────────────────────────────────── A: Dr IC receivable 100k / Cr Revenue 100k B: Dr Expense 100k / Cr IC payable 100k Group elimination: Dr Revenue 100k / Cr Expense 100k Dr IC payable 100k / Cr IC receivable 100k Group effect: zero ``` **UI rules:** - **The intercompany matrix must net to zero.** `IntercompanyMatrix` renders an entity × entity grid of balances; every cell must have an equal and opposite counterpart. **Non-zero cells are the exception queue**, and they're the most common cause of a consolidation that doesn't tie. - **Common breaks the UI must name**: timing differences (one side booked in a later period), FX differences (each side translated at a different rate), and one-sided entries (the other entity never booked it). These have different fixes, so the matrix labels the break type rather than showing a bare difference. - **Unrealized profit in inventory** — goods sold intercompany and still on hand — must be eliminated too. Flagged, not computed, because it requires knowing what's still in stock. - **Elimination entries are `source: "elimination"`** and exist only at the consolidated level. They never appear in an entity's standalone statements, and the UI makes that boundary visible. --- ## 3. Functional vs. presentation currency The distinction that causes the most confusion, and the one that decides whether an FX difference hits the P&L or equity. | | Definition | |---|---| | **Transaction currency** | What the invoice was denominated in | | **Functional currency** | The currency of the entity's primary economic environment | | **Presentation currency** | What the statements are shown in — IAS 21's term; ASC 830 calls it the *reporting currency* | Two different processes, two different destinations: | | **Remeasurement** | **Translation** | |---|---|---| | Converts | Transaction → functional | Functional → presentation | | FX difference goes to | **P&L** (FX gain/loss — ASC 830-20-35-1) | **Equity** (CTA, in OCI — ASC 830-30-45-12) | | Applies to | Monetary items held in a foreign currency | An entire subsidiary's statements | **This is not a detail.** A EUR receivable held by a USD-functional entity produces a P&L gain or loss when rates move. That same entity's statements translated into a GBP-presentation group produce a CTA in OCI that bypasses the P&L until the foreign operation is sold or substantially liquidated, when it is reclassified to earnings. Confusing them misstates earnings. --- ## 4. Translation rates | Item | Rate | |---|---| | Assets and liabilities | **Closing rate** at the balance sheet date | | Income and expenses | **Average rate** for the period (or transaction-date rates if volatile) | | Equity | **Historical rate** at the date of each contribution | | Retained earnings | Rolled forward, not re-translated | Because these use different rates, **the translated balance sheet does not balance** — and the plug is the **cumulative translation adjustment**, recorded in other comprehensive income. **UI rules:** - **CTA is displayed as its own equity line, never buried.** It is the arithmetic consequence of the method, and hiding it makes the balance sheet look inexplicable. - **Every translated statement shows the rates used** — closing, average, and the source. A translated number without its rate is unverifiable. - **Average rate methodology is stated** (simple average of daily, monthly weighted, transaction-date). Different methods give different results and comparability depends on knowing which. - **Hyperinflationary economies** differ by framework: IFRS (IAS 29) restates for inflation, then translates; US GAAP (ASC 830) remeasures as if the reporting currency were functional, with differences in the P&L. The UI flags the jurisdiction and the framework rather than attempting it. --- ## 5. FX gain and loss Three events, three rates, on a single foreign-currency invoice: ``` 1. Invoice issued 1 Sep €10,000 @ 1.0850 = $10,850 → AR recorded 2. Period end 30 Sep €10,000 @ 1.0920 = $10,920 → unrealized gain $70 3. Payment received 12 Oct €10,000 @ 1.0790 = $10,790 → Sep unrealized gain reverses (−$70) realized loss $60 vs. invoice rate (October P&L −$130; cumulative −$60) ``` **UI rules:** - **Realized and unrealized are separate lines and never combined.** Unrealized reverses; realized doesn't. Presenting one number destroys the reader's ability to tell what actually happened. - **The revaluation entry at period end is a system entry** with the rate and source shown, reversing at the start of the next period. - **Only monetary items are revalued.** Cash, receivables, payables, and debt — yes. Inventory, fixed assets, and prepaids — no, they stay at historical rate. Revaluing a non-monetary item is a common and material error, and the UI derives this from the account classification rather than trusting a flag. - **The rate source and timestamp are recorded on every conversion.** A rate without provenance is not auditable. --- ## 6. Rate management ```ts type ExchangeRate = { from: string; to: string; rate: number; asOf: Date; type: "spot" | "closing" | "average" | "historical" | "budget"; source: string; // "ECB" | "OANDA" | "manual" enteredBy?: string; // manual rates need an actor }; ``` - **Rate type is not optional.** The same pair on the same date has a different closing rate and average rate, and using the wrong one is a silent misstatement. - **Manual rate overrides require a reason and an approver**, and are visibly distinguished from sourced rates everywhere they're used. - **A missing rate is a blocking error**, never a silent 1.0. Defaulting to parity is the worst possible failure mode here. - **Inverse rates are computed, never stored separately** — storing both invites them to drift. - Rounding: **rates carry 5–6 significant figures**; converted amounts round once, at the end, per the currency's minor unit. --- ## 7. Multi-entity UI rules 1. **Entity is a required dimension on every transaction.** 2. **Every number states its entity scope and its currency.** Both, always. 3. **Never sum across currencies without a stated conversion**, with the rate, the type, and the as-of date reachable. 4. **A converted value is marked** with `≈` and its basis in a tooltip. 5. **Entity switching is a full context change**, so nothing animates and every cached figure is invalidated. A stale figure from the previous entity displayed for even one frame is a serious error. 6. **Consolidated and standalone views are visually distinct**, not just differently labeled — the consolidated view carries a persistent scope chip in the header. 7. **A user with access to a subset of entities sees totals for that subset only**, and the UI says so explicitly rather than presenting a partial total as a group total. --- ## 8. Component set | Component | Purpose | |---|---| | `EntitySelector` | Explicit scope, with standalone/consolidated distinction | | `ConsolidationView` | Entity columns + eliminations + group total | | `IntercompanyMatrix` | Entity × entity, with break types named | | `EliminationEntry` | Consolidation-level only, clearly scoped | | `NciPanel` | Non-controlling interest on both statements | | `TranslationSummary` | Rates used, by type, with sources | | `CtaRollForward` | Opening CTA → movement → closing | | `FxRevaluation` | Period-end revaluation preview and posting | | `FxGainLossPanel` | Realized and unrealized, separated | | `RateTable` | By pair, date, and type, with source and overrides flagged | --- # Metric definitions Every metric in this system ships with its formula, its period, its inputs, and its source. A KPI without a definition is a number people argue about in meetings. > **The rule:** `MetricCard` requires a `definition`. There is no variant without one. The definition is reachable from the card — an info affordance opening `MetricDefinition`, which shows the formula with the actual input values substituted. --- ## Why this is a design problem "ARR" means at least four different things across four companies. "Burn" might be gross or net. "DSO" has two accepted formulas that give different answers. Products that render these without definitions create the appearance of precision and the reality of confusion. `MetricDefinition` renders: ``` Net Revenue Retention 104.6% ──────────────────────────────────────────────────────────────── (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR ( 412,000 + 61,400 − 18,200 − 24,100 ) ÷ 412,000 Cohort: customers active on 1 Sep 2026 Period: 1–30 Sep 2026 Source: subscription ledger · excludes one-time fees Note: new customers excluded by definition ``` Formula, substituted values, cohort, period, source, and the caveat. That block is the component. --- ## Liquidity & working capital | Metric | Formula | Note | |---|---|---| | **Working capital** | `Current assets − Current liabilities` | Absolute, not a ratio | | **Current ratio** | `Current assets ÷ Current liabilities` | > 1 means current assets cover current liabilities — liquidity, not solvency or health | | **Quick ratio** | `(Cash and equivalents + Short-term investments + Accounts receivable) ÷ Current liabilities` | The acid test. The shortcut `(Current assets − Inventory − Prepaids) ÷ Current liabilities` keeps other current assets in and reads higher — state which | | **Cash ratio** | `Cash and equivalents ÷ Current liabilities` | Most conservative | | **CCC** | `DSO + DIO − DPO` | Days cash is tied up. Negative is excellent. | | **DSO** | `(Ending AR ÷ Credit sales for the period) × Days in the period` | Two formulas exist — see [order to cash](05-order-to-cash.md#9-ar-metrics) | | **DIO** | `(Inventory ÷ COGS for the period) × Days in the period` | Ending or average inventory — state which | | **DPO** | `(Ending AP ÷ COGS for the period) × Days in the period` | Some use purchases rather than COGS, which suits a business whose AP is mostly opex. Render with on-time payment rate adjacent — see [procure to pay](06-procure-to-pay.md#9-ap-metrics) | | **Receivables turnover** | `Credit sales ÷ Average AR` | Times per period; `Days in the period ÷ turnover` is DSO on average AR | | **Inventory turnover** | `COGS ÷ Average inventory` | Times per period; `Days ÷ turnover` is DIO | | **CEI** / **Collection effectiveness index** | `(Opening AR + Credit sales − Closing total AR) ÷ (Opening AR + Credit sales − Closing current AR)` | Percent of collectible AR actually collected; 100% is perfect. See [order to cash](05-order-to-cash.md#9-ar-metrics) | --- ## Profitability | Metric | Formula | Note | |---|---|---| | **Gross margin** | `(Revenue − COGS) ÷ Revenue` | Requires a COGS classification; omit the metric if there isn't one | | **Contribution margin** | `(Revenue − Variable costs) ÷ Revenue` | Strictly the ratio form — the amount is `Revenue − Variable costs`. Variable-cost classification is a judgment; state it | | **Operating margin** | `Operating income ÷ Revenue` | | | **EBIT** | `Net income + Interest expense + Income tax expense` | Close to operating income, but also carries other non-operating income and expense. Non-GAAP | | **Net margin** | `Net income ÷ Revenue` | | | **EBITDA** | `Net income + interest + income taxes + D&A` (≈ operating income + D&A with no other non-operating items) | **Non-GAAP** — see below | | **EBITDA margin** | `EBITDA ÷ Revenue` | | | **Rule of 40** | `Revenue growth % + EBITDA margin %` | SaaS heuristic, not an accounting measure. Many use free-cash-flow margin instead of EBITDA margin, and year-over-year ARR growth instead of revenue growth — label which | ### EBITDA {#ebitda} Not a GAAP or IFRS line. Wherever it appears it must: 1. Appear **below** net income, in a separated non-GAAP block — never inside the P&L. 2. Show its **reconciliation to net income**, line by line. 3. State every adjustment. "Adjusted EBITDA" with unnamed adjustments is not a number, and the component requires the adjustment list. The same rules apply to free cash flow, adjusted net income, and every other non-GAAP measure. `NonGaapReconciliation` exists specifically so this can't be skipped. --- ## Cash & runway | Metric | Formula | Note | |---|---|---| | **Gross burn** | Total operating cash outflows in the month | Ignores revenue | | **Net burn** | `Operating and investing cash out − cash in`, excluding financing inflows | The one that matters for runway. Equity raised or debt drawn is not negative burn | | **Runway** | `Cash ÷ net monthly burn` | **State which burn.** Gross-burn runway is a different, shorter number. | | **Months of cash** | Same as runway | Say one thing, not both | | **Free cash flow** | `Operating cash flow − Capital expenditures` | Non-GAAP; state whether capitalized software and finance-lease principal are deducted | | **Burn multiple** | `Net burn ÷ Net new ARR`, over the same period | Efficiency; lower is better — under 1× is excellent, over 2× is a warning (David Sacks's bands). Undefined when net new ARR is zero or negative | **Worked example.** Cash and equivalents of $4,200,000; net burn of $210,000 in July, $230,000 in August and $250,000 in September, so trailing-3-month net burn is $230,000. Runway is `4,200,000 ÷ 230,000 = 18.26`, rendered "18.3 months at trailing-3-month net burn". On September alone it is `4,200,000 ÷ 250,000 = 16.8` months — a 1.5-month difference from the choice of window alone, which is exactly why the window is stated. If the quarter's net burn of $690,000 bought $460,000 of net new ARR, the burn multiple is `690,000 ÷ 460,000 = 1.5×`. **Runway rules:** - **The burn basis is displayed on the card**, not buried. "18.3 months at trailing-3-month net burn." - **The averaging window is stated** — last month, trailing 3, trailing 6. A single anomalous month can double or halve the answer. - **The projection is dashed** on any chart. It's a forecast. - **Committed but unpaid obligations** (signed contracts, POs) should be disclosed alongside, because runway that ignores commitments is optimistic. --- ## Recurring revenue Definitions vary the most here, so the discipline matters the most. | Metric | Formula | Note | |---|---|---| | **MRR** | Sum of normalized monthly recurring subscription value | **Excludes** one-time fees, usage overage, services, and taxes | | **ARR** | `MRR × 12` | Only meaningful for subscriptions. Never for transactional revenue. | | **New MRR** | From customers new this period | | | **Expansion MRR** | Upgrades and seat additions from existing customers | | | **Contraction MRR** | Downgrades, not cancellations | | | **Churned MRR** | Full cancellations | | | **Net new MRR** | `New + Expansion − Contraction − Churned` | The movement that explains the change | | **Gross revenue retention** | `(Starting − Contraction − Churn) ÷ Starting` | Caps at 100% | | **Net revenue retention** | `(Starting + Expansion − Contraction − Churn) ÷ Starting` | Can exceed 100% | | **Logo churn** | `Customers lost ÷ starting customers` | Different from revenue churn | **Rules:** - **MRR is not revenue.** It's an operating metric derived from subscriptions and it will not tie to the P&L. Presenting them adjacent without stating that invites the question every board asks. - **The MRR bridge is the primary view** — the waterfall from opening MRR through each movement to closing. A single MRR number with a percentage change explains nothing. - **NRR and GRR both, or neither.** NRR alone hides churn behind expansion. - **Every cohort definition is stated.** "Customers active on the first day of the period" is a choice, and a different choice gives a different answer. --- ## Unit economics Flag these clearly: **these are not accounting measures.** They rest on definitional choices with no standard, and the component makes those choices visible. | Metric | Formula | The contested part | |---|---|---| | **CAC** | `Sales & marketing spend ÷ new customers` | Which costs count; which period they're attributed to | | **ARPA** / **Average revenue per account** | `MRR ÷ Active customers`, per month | Per account, not per user (ARPU) — say which; mean, not median | | **CAC payback** | `CAC ÷ (Monthly ARPA × Gross margin)`, in months | Whether margin is applied; whether ARPA is new-customer or blended | | **LTV** | `(ARPA × Gross margin) ÷ Revenue churn rate`, both in the same period unit | Extremely sensitive to the churn input; unstable at low churn | | **LTV:CAC** | | Meaningless without both definitions stated | | **Magic number** | `Net new ARR in the quarter ÷ prior-quarter S&M` (equivalently `(Δ quarterly revenue × 4) ÷ prior-quarter S&M`) | | **Rule: LTV computed on a monthly churn rate below ~1% produces an absurd number.** The component caps the horizon (commonly 36 months) and **displays the cap**, rather than rendering a confidently wrong figure. --- ## Leverage & coverage | Metric | Formula | Note | |---|---|---| | **Debt-to-equity** | `Total debt ÷ Total equity` | Debt is interest-bearing borrowings (and, commonly, lease liabilities). Some use total liabilities — a different, larger ratio; state which | | **Net debt** | `Total debt − Cash` | Can be negative | | **Interest coverage** | `EBIT ÷ Interest expense` | Common covenant | | **DSCR** | `Cash available for debt service ÷ Debt service (interest + scheduled principal)` | Common covenant. Real estate uses net operating income; corporate loans usually EBITDA less capex and cash taxes — the credit agreement defines it | | **Leverage ratio** | `Net debt ÷ EBITDA` (or total debt ÷ EBITDA) | Definition is usually specified in the loan agreement, not by convention | **Covenant metrics use the definition in the credit agreement, not the generic one.** Lenders define "EBITDA" contractually, with specific add-backs. `CovenantMonitor` stores the contractual definition per covenant and computes against it, showing the headroom and the test date — and the number it displays may legitimately differ from the dashboard's EBITDA. That's not a bug, and the UI says so. --- ## Display rules 1. **Every metric carries its definition.** No exceptions. 2. **Every metric states its period and comparison basis.** `+5.3% vs. Aug`, never a bare `+5.3%`. 3. **Percentage points vs. percent are discriminated** at the type level. See [numerics](../foundations/06-numerics.md#7-percentages-rates-basis-points). 4. **`polarity` is required.** Rising DPO, rising burn, and rising churn are not good news. 5. **Division by zero renders `n/a`**, never `∞`, `NaN`, or `100%`. 6. **Non-GAAP measures are labeled and reconciled.** 7. **Metrics derived from unreconciled data are marked provisional.** A KPI computed mid-close, before the bank is reconciled, is an estimate — and labeling it as such is what keeps people from quoting it externally. 8. **Trailing averages state their window.** 9. **Ratios show their inputs on hover.** `1.8×` is not inspectable; `$4.2M ÷ $2.3M` is. 10. **Metric definitions are versioned.** When a definition changes, historical values computed under the old one are marked, because an unannounced definition change looks like a business change. --- # Lexicon One word per concept, used identically in code, UI copy, and documentation. Terminology drift is the fastest way to make a financial product feel amateur to the people who use it professionally. --- ## Foundations The words a first month of bookkeeping is made of. They are the most-used terms in the system and the ones most often used loosely elsewhere, so they are defined here first — the rest of this lexicon assumes them. | Term | Definition | Not | Authority | |---|---|---|---| | **Double-entry** | Recording every transaction as equal debits and credits, so the ledger proves itself | "two-sided", "balanced accounting" | [Convention; the Codification governs recognition](https://storage.fasb.org/fas168.pdf) | | **Accounting equation** | Assets = liabilities + equity. True after every single entry, not just at period end. | "balance sheet formula" | [CON 8, Ch. 4, ¶E61](https://www.fasb.org/concepts-statements) | | **Debit** | The left column. It increases assets and expenses, and decreases liabilities, equity and revenue. | "positive", "plus", "money in" | Bookkeeping convention — not codified | | **Credit** | The right column. It increases liabilities, equity and revenue, and decreases assets and expenses. | "negative", "minus", "money out" | Bookkeeping convention — not codified | | **Asset** | A present right of the entity to an economic benefit (FASB CON 8 ¶E16) | "what we own" (a leased asset is controlled, not owned) | [CON 8, Ch. 4, ¶E16](https://www.fasb.org/concepts-statements) | | **Liability** | A present obligation of the entity to transfer an economic benefit (FASB CON 8 ¶E37) | "debt" (debt is one kind of liability) | [CON 8, Ch. 4, ¶E37](https://www.fasb.org/concepts-statements) | | **Equity** | The residual interest in the assets that remains after deducting liabilities (FASB CON 8 ¶E61) | "net worth", "capital" alone | [CON 8, Ch. 4, ¶E61](https://www.fasb.org/concepts-statements) | | **Revenue** | Inflows or enhancements of assets, or settlements of liabilities, from delivering goods or services — recognized when control transfers, not when cash arrives | "income" (income is after costs), "turnover" | [CON 8 ¶E80; ASC 606](https://asc.fasb.org/606/tableOfContent) | | **Expense** | Outflows or using up of assets, or incurrences of liabilities, from delivering goods or services — recognized when incurred, not when paid | "cost" unqualified, "spend" | [CON 8, Ch. 4, ¶E81](https://www.fasb.org/concepts-statements) | | **General ledger** / **GL** | The complete set of accounts, holding every posted entry | "the database", "the books" in UI copy | Bookkeeping convention — not codified | | **Journal** | The chronological record of entries, before they are summarized by account | "log", "history" | Bookkeeping convention — not codified | | **T-account** | The two-column sketch of one account — debits left, credits right | "ledger card" | Bookkeeping convention — not codified | | **Trial balance** | Every account's balance in two columns, proving that total debits equal total credits | "account list", "TB" in UI copy | Bookkeeping convention — not codified | | **Accounts receivable** / **AR** | What customers owe us for invoices already issued | "debtors", "receivables" unqualified in mixed context | [ASC 310](https://asc.fasb.org/310/tableOfContent) | | **Accounts payable** / **AP** | What we owe vendors for bills already received | "creditors", "payables" unqualified in mixed context | [ASC 405](https://asc.fasb.org/405/tableOfContent) | | **Accrual basis** | Recognizing revenue when earned and expense when incurred, regardless of when cash moves | "the accrual method" | [CON 8, Ch. 4, App. A](https://www.fasb.org/concepts-statements) | | **Cash basis** | Recognizing revenue and expense only when cash moves. Not GAAP for any entity — it is a special purpose framework (AICPA AU-C 800), and for tax a method IRS Pub. 538 and IRC §448 limit by entity type and gross receipts | "simple accounting" | [Not GAAP — special purpose framework; tax rules in IRS Pub. 538](https://www.irs.gov/publications/p538) | | **Matching principle** | Recognizing an expense in the same period as the revenue it helped produce | "matching" alone (that is reconciliation) | [CON 8, Ch. 4, App. A — expense recognition](https://www.fasb.org/concepts-statements) | | **Prepaid expense** | Cash paid before the benefit is consumed — an asset until it is | "prepayment" (that is a customer paying us early) | [ASC 340-10](https://asc.fasb.org/340/tableOfContent) | | **Deferred revenue** / **Contract liability** | Consideration received, or unconditionally due, before the goods or services are transferred — a liability until the performance obligation is satisfied. ASC 606 calls it a contract liability | "unearned income", "revenue" | [ASC 606-10-45 — contract liability](https://asc.fasb.org/606/tableOfContent) | | **Depreciation** | Spreading the cost of a fixed asset over the periods it serves | "write-down", "amortization" | [ASC 360-10-35-4](https://asc.fasb.org/360/tableOfContent) | | **Amortization** | Spreading the cost of a finite-lived intangible, a prepaid or a debt discount over the periods it serves | "depreciation" | [ASC 350-30-35](https://asc.fasb.org/350/tableOfContent) (intangibles), [ASC 340-10](https://asc.fasb.org/340/tableOfContent) (prepaids) | | **Gross profit** | Net revenue less cost of revenue, as an amount | "margin" (margin is the ratio, not the amount) | [ASC 220](https://asc.fasb.org/220/tableOfContent) | | **Net income** | Revenues and gains less expenses and losses, including income tax, for the period — the bottom line of the P&L, before any allocation to non-controlling interests | "profit" in a statement, "earnings" unqualified | [ASC 220](https://asc.fasb.org/220/tableOfContent) | | **Retained earnings** | Accumulated net income not distributed to owners — where the P&L lands on the balance sheet | "reserves", "profit" | [ASC 505](https://asc.fasb.org/505/tableOfContent) | | **Cash flow statement** | Where the cash went in a period, reconciled from net income | "cash statement", "cash report" | [ASC 230](https://asc.fasb.org/230/tableOfContent) | ## Core ledger | Term | Definition | Not | Authority | |---|---|---|---| | **Journal entry** | A balanced set of debit and credit lines posted to the GL | "transaction" (which is a business event) | Bookkeeping convention — not codified | | **Transaction** | A business event — a payment, a sale, a purchase | "activity", "item" | Bookkeeping convention — not codified | | **Posting** | Committing an entry to the ledger, making it immutable | "saving", "submitting" | Bookkeeping convention — not codified | | **Account** | A GL account in the chart of accounts. Always qualify when a bank account is meant. | bare "account" in mixed context | Bookkeeping convention — not codified | | **Chart of accounts** | The account structure | "categories" | [Not prescribed by GAAP; presentation per Reg S-X 5-02](https://www.ecfr.gov/current/title-17/part-210) | | **Dimension** | A non-account axis: entity, department, project | "tag", "label" | Bookkeeping convention — not codified | | **Normal balance** | The side that increases an account — debit for assets and expenses, credit for liabilities, equity and revenue, inverted for a contra account | "positive balance" | Bookkeeping convention — not codified | | **Contra account** | An account with the opposite normal balance to its type, presented as a deduction from its parent — accumulated depreciation against PP&E, the allowance against AR | "negative account" | Bookkeeping convention — not codified | | **Control account** | A GL account whose balance is the total of a subledger — AR, AP, fixed assets — and is posted only from it | "summary account" | Bookkeeping convention — not codified | | **Subledger** | The detail, by customer, vendor or asset, whose total must equal its control account in the GL | "sub-account" (a child account in the chart) | Bookkeeping convention — not codified | | **Period** | A fiscal reporting window | "date range", "timeframe" | [ASC 270](https://asc.fasb.org/270/tableOfContent) | | **Basis** | Cash or accrual | "method" | [CON 8, Ch. 4, App. A](https://www.fasb.org/concepts-statements) | | **Reversal** | An offsetting entry that undoes a posted entry | "delete", "undo" | Bookkeeping mechanic and internal control — not codified | | **Suspense account** | A temporary holding account for an amount that must be posted before its correct account is known — cleared to zero before close, never reported | "miscellaneous", "ask my accountant" | Bookkeeping convention — not codified | | **Clearing account** | An account that should net to zero once both sides of a flow have posted — payroll clearing, GR/IR, a payment processor clearing account; a residual balance is the exception | "suspense account" (unknown destination, not a known flow) | Bookkeeping convention — not codified | | **Fiscal year** | The twelve-month (or 52–53 week) reporting year an entity has chosen, which need not start in January | "year" unqualified, "calendar year" | [IRS Pub. 538 — accounting periods](https://www.irs.gov/publications/p538) | | **Transposition error** | Two digits swapped when an amount is keyed — 54 entered as 45. The trial balance goes out by a difference that is always divisible by nine, which is how it is found | "typo", "rounding difference" | Bookkeeping convention — not codified | | **Error of commission** | The right amount posted to the wrong account of the right type — a payment applied to the wrong customer, an expense coded to the wrong department. Debits still equal credits, so the trial balance will not find it | "error of principle" (wrong type of account) | Bookkeeping convention — not codified | | **Error of principle** | An entry posted to the wrong type of account in breach of an accounting rule — a repair capitalized as an asset, a loan receipt booked as revenue. The trial balance still balances; only review finds it | "error of commission" (right type, wrong account) | Bookkeeping convention — not codified | | **Idempotency key** | A unique value sent with a write request so a retried request is applied once — the API returns the original result instead of posting a second invoice, payment or journal | "transaction ID", "dedupe" | Engineering convention — not codified (Stripe, Xero Idempotency-Key, QBO requestid) | ## Close | Term | Definition | Not | Authority | |---|---|---|---| | **Close** | The process of finalizing a period | "month end" as a verb | Bookkeeping convention — not codified | | **Cutoff** | Recording each transaction in the period in which it occurred — the boundary tested at every period end | "period end" | Audit assertion (AICPA AU-C 315) — not codified in GAAP | | **Reconciliation** | Comparing our record to an external party's | "matching" alone | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Tie-out** | Proving a GL balance against supporting evidence | "reconciliation" (different control) | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Roll-forward** | Opening → movements → closing, proven | "rollup" | Bookkeeping convention — not codified | | **Adjusting entry** | A close entry to apply accrual accounting | "correction" | Bookkeeping convention — not codified | | **Accrual** | An expense incurred, or revenue earned, before it is invoiced — recorded at period end so it lands in the right period | "estimate" | Accrual basis; [ASC 405](https://asc.fasb.org/405/tableOfContent) | | **Deferral** | Cash paid or received ahead of the expense or revenue it relates to, held on the balance sheet — as a prepaid or deferred revenue — until consumed or earned | "accrual" (the opposite timing) | [ASC 340-10](https://asc.fasb.org/340/tableOfContent) | | **Flux analysis** | Explaining period-over-period variances | "variance report" | Bookkeeping convention — not codified | | **Materiality** | Whether a misstatement or omission would likely change the judgment of a reasonable user of the statements — judged on size and nature together, never a fixed percentage | "significance", "the 5% rule" | [SEC SAB No. 99](https://www.sec.gov/interps/account/sab99.htm) | | **Sign-off** | Formal approval that a section is complete | "done", "complete" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Restatement** | Revising previously issued statements to correct an error material to them ("Big R") — comparatives are relabeled Restated and the error disclosed | "fixing", "revision" (immaterial errors) | [ASC 250-10-45-23](https://asc.fasb.org/250/tableOfContent) | | **Revision** | Correcting an immaterial prior-period error that would be material if corrected in the current period by adjusting the comparatives ("little r") — labeled As revised, no reissuance | "restatement" (material errors) | [SEC SAB No. 108](https://www.sec.gov/interps/account/sab108.htm) | | **Out-of-period adjustment** | Correcting a prior-period error in the current period, permitted only when the error is immaterial to both periods | "catch-up", "plug" | [ASC 250-10-45](https://asc.fasb.org/250/tableOfContent) | | **Subsequent event** | An event after the balance sheet date but before the statements are issued — recognized if the condition existed at the balance sheet date, disclosed only if it arose after | "post-close event" | [ASC 855-10-25](https://asc.fasb.org/855/tableOfContent) | | **Accrued liability** | An obligation for goods or services received but not yet invoiced or paid — wages, interest, professional fees — recorded by an accrual | "accrued AP", "provision" (a provision is an uncertain estimate) | [ASC 405](https://asc.fasb.org/405/tableOfContent) | | **Reversing entry** | An entry dated the first day of the next period that exactly offsets a period-end accrual, so the real invoice can be coded normally without double-counting | "undo", "auto-delete" | Bookkeeping mechanic — not codified | | **True-up** | The adjustment that replaces an estimate with the actual amount once it is known | "correction" (an estimate change is not an error) | [ASC 250-10-45-17 — change in estimate](https://asc.fasb.org/250/tableOfContent) | | **Reclassification** | Moving an amount from one account or line to another without changing net income or total equity — current portion of debt, a miscoded bill | "adjustment" unqualified | Bookkeeping convention — not codified | | **Lead schedule** | The working paper that lists each GL account in a balance sheet or P&L caption with its balance and the schedule or reconciliation that supports it | "summary", "TB extract" | Audit working-paper convention — not codified | | **Closing entry** | The year-end entry that zeroes every temporary account — revenue, expense, gains, losses, dividends or drawings — into retained earnings, so the new year's P&L starts at nil. Most ledger software performs it virtually | "adjusting entry", "reversing entry" | Bookkeeping mechanic — not codified | | **Temporary account** / **Nominal account** | An account that measures one period only — revenue, expense, gain, loss, dividends declared — and is closed to retained earnings at year end | "P&L category" | Bookkeeping convention — not codified | | **Permanent account** / **Real account** | A balance sheet account — asset, liability or equity — whose closing balance carries forward as the next period's opening balance | "balance account" | Bookkeeping convention — not codified | | **Soft close** | A period state in which routine posting has stopped and only adjusting entries by a controller are accepted. In common usage also an abbreviated month-end that estimates rather than fully reconciles — directional numbers, never the basis for external reporting | "closed", "draft close" | Bookkeeping convention — not codified | | **Hard close** | A close in which every step is done — reconciliations, accruals, review, sign-off — and the period is locked against posting, so its statements can go to lenders, investors and auditors | "final" unqualified, "year-end" | Bookkeeping convention — not codified | ## Receivables | Term | Definition | Not | Authority | |---|---|---|---| | **Invoice** | A claim on a customer | "bill" (that's what we owe) | Bookkeeping convention — not codified | | **Credit note** | A reduction of what a customer owes | "refund", "credit memo" (pick one — we use credit note) | [ASC 606-10-32 — variable consideration](https://asc.fasb.org/606/tableOfContent) | | **Refund** | Returning cash already received | "credit" | [ASC 606-10-32-10 — refund liability](https://asc.fasb.org/606/tableOfContent) | | **Write-off** | Removing an uncollectible receivable against the allowance for credit losses — under the allowance method it has no P&L effect; the expense was taken in the provision | "credit note", "bad debt expense" | [ASC 326](https://asc.fasb.org/326/tableOfContent) | | **Application** | Assigning received cash to specific invoices | "matching", "allocation" | Bookkeeping convention — not codified | | **Unapplied cash** | Customer cash received but not yet assigned to an invoice. It reduces that customer's AR; only a net credit balance is reclassified to a liability at period end | "overpayment", "credit balance" | [ASC 405](https://asc.fasb.org/405/tableOfContent) | | **Aging** | Grouping open receivables or payables into buckets by days past due date — current, 1–30, 31–60, 61–90, 90+ | "outstanding" | [ASC 326](https://asc.fasb.org/326/tableOfContent) | | **Dunning** | The sequence of collection communications | "chasing", "reminders" | Bookkeeping convention — not codified | | **Promise to pay** | A customer commitment to a date and amount | "PTP" in UI copy | Bookkeeping convention — not codified | | **Payer** | The party paying us | "customer" when specifically about payment | Bookkeeping convention — not codified | | **Payment terms** | The agreed timing of payment and any early-payment discount — Net 30, Net 30 EOM, 2/10 net 30, due on receipt; the due date that aging is measured from | "terms" unqualified in UI copy | Commercial practice — not codified | | **Allowance for credit losses** | The contra-asset holding the expected credit losses on receivables, so AR is presented at the amount expected to be collected | "bad debt reserve", "allowance for doubtful accounts" (pre-CECL) | [ASC 326-20](https://asc.fasb.org/326/tableOfContent) | | **CECL** | Current expected credit losses — the ASC 326 model that books lifetime expected losses from day one using history, current conditions and reasonable forecasts. ASU 2025-05 adds a practical expedient for current AR and contract assets | "incurred loss" (the model it replaced) | [ASC 326-20; ASU 2025-05](https://asc.fasb.org/326/tableOfContent) | | **Bad debt expense** | The P&L charge that builds the allowance for credit losses — the provision, not the write-off | "write-off" | [ASC 326-20](https://asc.fasb.org/326/tableOfContent) | ## Payables | Term | Definition | Not | Authority | |---|---|---|---| | **Bill** | A claim against us | "invoice" (that's what we issue) | [ASC 405](https://asc.fasb.org/405/tableOfContent) | | **Purchase order** | A commitment to buy | "order" | Bookkeeping convention — not codified | | **Goods receipt** | Confirmation that goods arrived | "delivery" | [ASC 330](https://asc.fasb.org/330/tableOfContent) | | **Three-way match** | Agreeing the purchase order, the goods receipt and the vendor invoice — quantity and price, line by line, within tolerance — before a bill is approved for payment | "matching" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Payment run** | A batch of payments released together | "batch", "payout" | Bookkeeping convention — not codified | | **Vendor** | A party we buy from | "supplier" (pick one — we use vendor) | Bookkeeping convention — not codified | | **Payee** | The party receiving a payment | "recipient" in an accounting context | Bookkeeping convention — not codified | | **GR/IR** | Goods received / invoice received — the clearing account credited at receipt and debited when the vendor invoice arrives; its balance is received-not-yet-invoiced | "accrued AP" alone | [ASC 330](https://asc.fasb.org/330/tableOfContent) | | **Two-way match** | Agreeing the vendor invoice to the purchase order, without a receipt — used for services and where nothing physical arrives | "three-way match" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Early-payment discount** | A reduction offered for paying before the net due date — 2/10 net 30 is 2% off if paid in 10 days, an annualized return of about 37% | "rebate", "prompt-pay credit" | Commercial practice — not codified | ## Cash | Term | Definition | Not | Authority | |---|---|---|---| | **Available balance** | Spendable now | "balance" unqualified | Bookkeeping convention — not codified | | **Current balance** | Posted, including uncleared items | "actual balance" | Bookkeeping convention — not codified | | **Pending** | Submitted or authorized, not settled | "processing", "in progress" | Bookkeeping convention — not codified | | **Bank-posted** | Recorded by the bank (distinct from posting an entry to the ledger) | "cleared" (a reconciliation state) | Bookkeeping convention — not codified | | **Settled** | Finally and irrevocably transferred | "complete", "done" | Bookkeeping convention — not codified | | **Reconciled** | Matched to a bank statement line | "verified", "confirmed" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Deposit in transit** | A receipt recorded in the book cash account but not yet on the bank statement — added to the bank balance in a reconciliation | "pending deposit" | Bookkeeping convention — not codified | | **Outstanding check** | A check recorded as paid in the books but not yet presented to the bank — deducted from the bank balance in a reconciliation | "uncleared check" | Bookkeeping convention — not codified | | **Sweep** | An automatic transfer between our own accounts at a set time or threshold — typically excess balances into a concentration or investment account | "transfer" unqualified | [ASC 230-10-45 — not a cash flow](https://asc.fasb.org/230/tableOfContent) | | **Cash equivalents** | Short-term, highly liquid investments readily convertible to known cash amounts, with an original maturity of three months or less — treasury bills, money market funds | "cash" unqualified | [ASC 230-10-20](https://asc.fasb.org/230/tableOfContent) | | **Restricted cash** | Cash legally or contractually unavailable for general use — escrow, collateral, compensating balances. Presented separately, but included with cash in the statement of cash flows since ASU 2016-18 | "available cash" | [ASC 230-10-45-4; ASU 2016-18](https://asc.fasb.org/230/tableOfContent) | | **Positive pay** | A bank fraud control: the company sends the bank a file of issued checks (or approved ACH debits) and the bank returns any presented item that does not match for a decision | "check verification" | Banking practice — not codified | | **ACH** | The Automated Clearing House network — the US batch system for electronic credits and debits under Nacha rules; same-day to two-day settlement, with limited return rights | "bank transfer" unqualified, "wire" | [Nacha Operating Rules](https://www.nacha.org/rules) | | **Wire transfer** | A payment settled individually and in full — domestically over Fedwire, cross-border through correspondent banks via SWIFT — same-day and final once sent, which is why it carries the highest fraud risk | "ACH", "bank transfer" unqualified | [Federal Reserve — Fedwire Funds Service](https://www.frbservices.org/financial-services/wires) | | **Stale-dated check** | A check presented more than six months after its date, which the bank may refuse — still a liability on our books until voided or escheated | "expired check" | [UCC §4-404](https://www.law.cornell.edu/ucc/4/4-404) | | **Escheatment** / **Unclaimed property** / **Escheat** | Remitting unclaimed property — uncashed vendor checks, customer credit balances — to the state after its dormancy period | "writing back" to income | State unclaimed property law — not codified in GAAP | | **Chargeback** / **Dispute** | A card payment reversed by the cardholder's issuing bank on a cardholder claim — fraud, goods not received, not as described. The processor debits the merchant the amount plus a fee; the merchant may contest it with evidence (representment) | "refund" (merchant-initiated), "return" | Card network rules (Visa Core Rules, Mastercard Chargeback Guide) — not codified | | **Processor reserve** / **Rolling reserve** | Card settlements a payment processor withholds against future chargebacks and refunds — a rolling percentage held for a set number of days, a fixed amount, or an upfront hold. It is the merchant's receivable from the processor, not cash on hand | "cash", "available balance" | Processor agreement — not codified | | **Day-count convention** | The rule that turns a date range into a fraction of a year for interest — 30/360, actual/360, actual/365, actual/actual. The same rate on the same loan accrues a different amount under each | "days" unqualified, "interest method" | Market convention (ISDA definitions) — not codified | | **Dormancy period** | The time without owner contact after which property — an uncashed vendor check, a customer credit balance, unpaid wages — is presumed abandoned and must be reported to the state. Commonly three years for vendor checks and one year for wages, varying by state and property type | "expiry", "write-back period" | State unclaimed property law (Revised Uniform Unclaimed Property Act) — not codified in GAAP | ## Statements | Term | Definition | Not | Authority | |---|---|---|---| | **Statement** | P&L, balance sheet, or cash flow | "report" (a report is generated ad hoc) | [ASC 205](https://asc.fasb.org/205/tableOfContent) | | **P&L** / **Income statement** | Performance over a period | "profit report" | [ASC 220](https://asc.fasb.org/220/tableOfContent) | | **Balance sheet** | Assets, liabilities and equity at a point in time, proving the accounting equation | "statement of position" in US copy (IFRS: statement of financial position) | [ASC 210](https://asc.fasb.org/210/tableOfContent) | | **As at** / **As of** | The point-in-time label for a balance sheet | "for the period" | [Reg S-X, 17 CFR 210](https://www.ecfr.gov/current/title-17/part-210) | | **For the period ended** | The label for a P&L or cash flow | "as of" | [Reg S-X, 17 CFR 210](https://www.ecfr.gov/current/title-17/part-210) | | **Comparative** | A prior period column | "previous", "last" | [Reg S-X 3-01, 3-02](https://www.ecfr.gov/current/title-17/part-210) | | **Consolidated** | Presenting a parent and the entities it controls as one economic entity, with intercompany balances and transactions eliminated | "group" alone | [ASC 810](https://asc.fasb.org/810/tableOfContent) | | **Standalone** | A single entity, without consolidation | "individual", "solo" | [ASC 810](https://asc.fasb.org/810/tableOfContent) | | **Elimination** | A consolidation-only entry removing intercompany balances, transactions and unrealized profit, so the group does not report trading with itself | "adjustment" | [ASC 810](https://asc.fasb.org/810/tableOfContent) | | **Presentation currency** | The currency the statements are shown in. ASC 830 calls it the reporting currency; IAS 21 the presentation currency — we use the latter | "functional currency" (a different decision) | [ASC 830](https://asc.fasb.org/830/tableOfContent) | | **Functional currency** | The currency of the primary economic environment in which the entity generates and spends cash — a judgment from cash-flow, sales-price, expense and financing indicators | "local currency" | [ASC 830-10-45](https://asc.fasb.org/830/tableOfContent) | | **Translation** | Converting an entity's functional-currency statements into the presentation currency — assets and liabilities at the closing rate, income and expense at average — with the difference recorded in OCI as the CTA | "conversion" | [ASC 830-30](https://asc.fasb.org/830/tableOfContent) | | **Remeasurement** | Restating foreign-currency monetary balances, or books kept in a currency other than the functional one, into the functional currency — differences go to the P&L | "revaluation" | [ASC 830-20](https://asc.fasb.org/830/tableOfContent) | | **CTA** | Cumulative translation adjustment — the running total of translation differences, held in AOCI until the foreign entity is sold or substantially liquidated | "FX adjustment" | [ASC 830-30-45](https://asc.fasb.org/830/tableOfContent) | | **Cost of revenue** / **COGS** | The direct costs of producing the goods or delivering the services sold in the period — the line gross profit is measured after | "expenses" unqualified, "opex" | [Reg S-X 5-03](https://www.ecfr.gov/current/title-17/part-210) | | **Operating income** | Gross profit less operating expenses — the result of the core business before interest, other income and tax | "EBIT" (which adds back non-operating items), "EBITDA" | [ASC 220](https://asc.fasb.org/220/tableOfContent) | | **Other comprehensive income** / **OCI** | Gains and losses that bypass net income by standard — translation adjustments, unrealized gains on available-for-sale debt securities, cash-flow hedges, pension remeasurements — accumulated in AOCI within equity | "other income" | [ASC 220-10](https://asc.fasb.org/220/tableOfContent) | | **Current asset** | An asset expected to be realized, sold or consumed within twelve months of the balance sheet date or the operating cycle, if longer | "short-term asset" | [ASC 210-10-45](https://asc.fasb.org/210/tableOfContent) | | **Current liability** | An obligation expected to be settled with current assets, or by creating another current liability, within twelve months or the operating cycle, if longer | "short-term debt" | [ASC 210-10-45](https://asc.fasb.org/210/tableOfContent) | | **Non-GAAP financial measure** | A numerical measure of performance, position or cash flow that adjusts a GAAP figure — EBITDA, free cash flow, adjusted net income. A registrant publishing one must reconcile it to the most comparable GAAP measure | "adjusted" unlabeled, "pro forma" | [Reg G — SEC Release 33-8176](https://www.sec.gov/rule-release/33-8176) | | **Direct method** | Presenting operating cash flows as gross receipts and payments by major class — cash from customers, cash paid to suppliers and employees. Encouraged by ASC 230, but it must still be accompanied by the reconciliation from net income | "cash receipts report" | [ASC 230-10-45-25](https://asc.fasb.org/230/tableOfContent) | | **Indirect method** | Presenting operating cash flows by starting from net income and adjusting for non-cash items and changes in working capital — the method most entities use. ASC 230 also calls it the reconciliation method | "cash reconciliation" (a bank reconciliation is a different control) | [ASC 230-10-45-28](https://asc.fasb.org/230/tableOfContent) | | **Income tax expense** / **Provision for income taxes** | The P&L charge for income taxes — current tax payable for the period plus the change in deferred tax assets and liabilities | "taxes paid", "tax payable" | [ASC 740-10-20 — glossary](https://asc.fasb.org/740/tableOfContent) | ## Controls | Term | Definition | Not | Authority | |---|---|---|---| | **Segregation of duties** | Splitting authorization, recording, custody of assets and reconciliation across different people, so no one person can both commit and conceal an error or fraud — at minimum, preparer ≠ approver | "permissions" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Approval** | Formal authorization of a specific version | "sign-off" (reserved for close sections) | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Evidence** | Documentation supporting a recorded amount | "attachment", "file" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Audit trail** | The immutable record of every state change | "history", "log" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Exception** | A break requiring investigation | "error", "issue" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **Finding** | An identified control or accounting problem | "issue" | [Internal control — SOX §404](https://www.sec.gov/rule-release/33-8238) | | **ICFR** | Internal control over financial reporting — the process designed to give reasonable assurance that the financial statements are reliable, which management of an SEC registrant must assess annually | "internal audit" | [SEC Release 33-8238; Rule 13a-15(f)](https://www.sec.gov/rule-release/33-8238) | | **Material weakness** | A deficiency, or combination of deficiencies, in ICFR such that there is a reasonable possibility a material misstatement will not be prevented or detected on a timely basis | "finding" unqualified, "audit issue" | [PCAOB AS 2201](https://pcaobus.org/oversight/standards/auditing-standards/details/AS2201) | | **Significant deficiency** | A control deficiency less severe than a material weakness yet important enough to merit the attention of those overseeing financial reporting | "material weakness" | [PCAOB AS 2201](https://pcaobus.org/oversight/standards/auditing-standards/details/AS2201) | | **Compilation** | An engagement in which an accountant presents financial statements from management's information without obtaining or giving any assurance — no audit, no review — and says so in the report; an accountant who is not independent must disclose it | "audit", "review", "certified statements" | AICPA SSARS — AR-C 80 | ## Multi-entity | Term | Definition | Not | Authority | |---|---|---|---| | **Intercompany** | A balance or transaction between two entities in the same group — real in each entity's books, eliminated on consolidation | "related party" (wider — includes owners and directors) | [ASC 810-10-45-1](https://asc.fasb.org/810/tableOfContent) | | **Non-controlling interest** / **NCI** | The equity in a consolidated subsidiary not attributable to the parent — shown within equity, separately from the parent's, with its share of net income allocated on the P&L | "minority interest" (superseded term) | [ASC 810-10-45-16](https://asc.fasb.org/810/tableOfContent) | | **Equity method** | Accounting for an investee over which the investor has significant influence — presumed at 20–50% of the vote — as one investment line adjusted by the investor's share of its earnings | "consolidation", "proportionate consolidation" | [ASC 323](https://asc.fasb.org/323/tableOfContent) | | **Transaction currency** | The currency a single transaction is denominated in — the invoice's currency, which may differ from the entity's functional currency | "functional currency", "local currency" | [ASC 830-20](https://asc.fasb.org/830/tableOfContent) | | **Monetary item** | A balance fixed in currency units — cash, receivables, payables, debt — and therefore remeasured when exchange rates move; inventory, prepaids and PP&E are non-monetary | "cash item" | [ASC 830-10-45-17](https://asc.fasb.org/830/tableOfContent) | | **Closing rate** | The spot exchange rate at the balance sheet date, used to translate assets and liabilities and to remeasure monetary items | "current rate" in UI copy, "today's rate" | [ASC 830-30-45-3](https://asc.fasb.org/830/tableOfContent) | | **Average rate** | An average of exchange rates over the period, used as a practical proxy for transaction-date rates when translating income and expenses — its method stated | "blended rate" | [ASC 830-30-45-3](https://asc.fasb.org/830/tableOfContent) | | **Realized FX gain or loss** | The exchange difference crystallized when a foreign-currency item is settled — settlement-date rate versus the rate it was last carried at | "FX adjustment" | [ASC 830-20-35-1](https://asc.fasb.org/830/tableOfContent) | | **Unrealized FX gain or loss** | The exchange difference on an open foreign-currency monetary item at period end, recognized in the P&L and reversed or carried forward until settlement | "CTA" (translation, in OCI) | [ASC 830-20-35-1](https://asc.fasb.org/830/tableOfContent) | | **Combined reporting** | Filing one state income tax return for a unitary group — the members' income combined, then apportioned. Two rules decide whose in-state sales count in the numerator: Joyce (only members with nexus) and Finnigan (every member, once any member has nexus) | "consolidated return" (the federal election) | State law — not codified in GAAP | | **Water's-edge election** | An election by a unitary group filing a combined state return to leave most foreign affiliates out of the combined report — in California a binding contract for 84 months | "worldwide combined report" | [Cal. Rev. & Tax. Code §25110](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=25110) | ## Revenue recognition | Term | Definition | Not | Authority | |---|---|---|---| | **Performance obligation** | A promise in a contract to transfer a distinct good or service, or a series of substantially the same ones — the unit revenue is recognized by | "deliverable", "line item" | [ASC 606-10-25-14](https://asc.fasb.org/606/tableOfContent) | | **Transaction price** | The consideration an entity expects to be entitled to for the contract, including estimated variable consideration and excluding amounts collected for third parties such as sales tax | "contract value", "TCV" | [ASC 606-10-32-2](https://asc.fasb.org/606/tableOfContent) | | **Standalone selling price** / **SSP** | The price at which an entity would sell a good or service separately — the basis for allocating the transaction price across performance obligations | "list price" (only evidence of SSP, not SSP itself) | [ASC 606-10-32-31](https://asc.fasb.org/606/tableOfContent) | | **Variable consideration** | Any part of the price that can change — discounts, rebates, refunds, credits, usage, bonuses, penalties — estimated and included only to the extent a significant reversal is not probable | "estimated revenue" | [ASC 606-10-32-5](https://asc.fasb.org/606/tableOfContent) | | **Contract asset** | Revenue recognized ahead of an unconditional right to payment — the right still depends on something other than the passage of time | "unbilled receivable", "accrued revenue" | [ASC 606-10-45-3](https://asc.fasb.org/606/tableOfContent) | | **Unbilled receivable** | Revenue recognized where the right to payment is unconditional but the invoice has not yet been issued — a receivable, not a contract asset | "contract asset", "WIP" | [ASC 606-10-45-4](https://asc.fasb.org/606/tableOfContent) | | **Remaining performance obligations** / **RPO** | Transaction price allocated to obligations not yet satisfied — deferred revenue plus contracted, unbilled amounts — disclosed with when it is expected to be recognized | "backlog" (a sales term, broader or narrower by company), "bookings" | [ASC 606-10-50-13](https://asc.fasb.org/606/tableOfContent) | | **Principal versus agent** | Whether the entity controls a good or service before it passes to the customer — a principal reports revenue gross, an agent reports only its fee or commission net | "gross-up" | [ASC 606-10-55-36](https://asc.fasb.org/606/tableOfContent) | | **Accrued revenue** | Revenue earned and recognized before it is invoiced. ASC 606 splits it: an unbilled receivable where only time stands between it and payment, a contract asset where the right still depends on further performance | "deferred revenue" (the opposite timing), "contract asset" unqualified | [ASC 606-10-45-3](https://asc.fasb.org/606/tableOfContent) | | **Contra-revenue account** | An account with a debit balance netted against gross revenue — returns, allowances, sales discounts, rebates — so net revenue is presented after them | "expense", "cost of revenue" | Bookkeeping convention — not codified; ASC 606 reduces the transaction price | | **Material right** | A customer option to buy additional goods or services at a discount it would not get without the contract — a renewal discount, loyalty points — accounted for as a separate performance obligation, deferring part of today's price | "marketing offer", "free gift" | [ASC 606-10-55-42](https://asc.fasb.org/606/tableOfContent) | | **Breakage** | The portion of a prepaid right a customer is expected never to exercise — unused gift card balances, unredeemed credits. Recognized in proportion to the pattern of redemption if the entity expects breakage; otherwise when redemption becomes remote. Amounts owed to a state under unclaimed property law are a liability, not revenue | "forfeit income", "write-back" | [ASC 606-10-55-48](https://asc.fasb.org/606/tableOfContent) | ## Fixed assets & leases | Term | Definition | Not | Authority | |---|---|---|---| | **Fixed asset** / **PP&E** | A tangible asset held for use in operations over more than one period — property, plant and equipment — carried at cost less accumulated depreciation | "capex" (the spend, not the asset) | [ASC 360-10](https://asc.fasb.org/360/tableOfContent) | | **Capitalization** | Recording a cost as an asset rather than an expense because it brings a future benefit — then allocated to expense over that benefit | "capitalising" for financing, "capex" | [ASC 360-10-30](https://asc.fasb.org/360/tableOfContent) | | **Capital expenditure** / **Capex** | Cash spent acquiring or improving long-lived assets in the period — the investing outflow free cash flow subtracts | "fixed asset" | [ASC 230-10-45-13](https://asc.fasb.org/230/tableOfContent) | | **Useful life** | The period over which an asset is expected to contribute to cash flows, which sets the depreciation period — an estimate, revised prospectively | "tax life", "warranty period" | [ASC 360-10-35-4](https://asc.fasb.org/360/tableOfContent) | | **Salvage value** | The amount expected from disposing of an asset at the end of its useful life, net of disposal costs — the floor depreciation stops at | "scrap" alone, "fair value" | [ASC 360-10-35-4](https://asc.fasb.org/360/tableOfContent) | | **Net book value** / **NBV** | Cost less accumulated depreciation and impairment — the carrying amount of an asset | "market value", "fair value" | [ASC 360-10](https://asc.fasb.org/360/tableOfContent) | | **Impairment** | Writing an asset down because its carrying amount is not recoverable — for long-lived assets, a recoverability test on undiscounted cash flows, then a write-down to fair value | "depreciation", "write-off" | [ASC 360-10-35-17](https://asc.fasb.org/360/tableOfContent) | | **Goodwill** | The excess of the consideration paid in a business combination over the fair value of the identifiable net assets acquired — not amortized by public companies, tested for impairment at least annually | "intangible asset" unqualified, "brand value" | [ASC 350-20](https://asc.fasb.org/350/tableOfContent) | | **Intangible asset** | A non-physical asset other than goodwill — software, customer lists, patents, trademarks — amortized over its useful life unless indefinite-lived | "goodwill" | [ASC 350-30](https://asc.fasb.org/350/tableOfContent) | | **Right-of-use asset** / **ROU asset** | The lessee's right to use an underlying asset for the lease term — the lease liability plus prepaid lease payments and initial direct costs, less incentives received | "leased asset" (owned by the lessor) | [ASC 842-20-30-5](https://asc.fasb.org/842/tableOfContent) | | **Lease liability** | The present value of the lease payments not yet paid, discounted at the rate implicit in the lease or, if not determinable, the incremental borrowing rate | "rent payable" | [ASC 842-20-30-1](https://asc.fasb.org/842/tableOfContent) | | **Operating lease** | Under ASC 842, a lessee lease that is not a finance lease — on the balance sheet, with a single straight-line lease cost in the P&L | "off-balance-sheet lease" (no longer, since ASC 842) | [ASC 842-20-25-6](https://asc.fasb.org/842/tableOfContent) | | **Finance lease** | A lease that effectively transfers control of the asset — ownership transfer, purchase option reasonably certain, major part of life or substantially all of fair value, or a specialized asset — expensed as amortization plus interest | "capital lease" (ASC 840 term) | [ASC 842-10-25-2](https://asc.fasb.org/842/tableOfContent) | | **Incremental borrowing rate** / **IBR** | The rate the lessee would pay to borrow, on a collateralized basis over a similar term, an amount equal to the lease payments — the discount rate when the implicit rate is unknown | "cost of capital", "WACC" | [ASC 842-20-20 — glossary](https://asc.fasb.org/842/tableOfContent) | | **Short-term lease** | A lease with a term of twelve months or less and no purchase option the lessee is reasonably certain to exercise — may be kept off the balance sheet by policy election | "month-to-month" alone | [ASC 842-20-25-2](https://asc.fasb.org/842/tableOfContent) | ## Equity & financing | Term | Definition | Not | Authority | |---|---|---|---| | **Dividend** | A distribution of earnings to shareholders, declared by the board — a liability from the declaration date and a reduction of retained earnings, never an expense | "expense", "drawings" (an owner's withdrawal from an unincorporated business) | [ASC 505](https://asc.fasb.org/505/tableOfContent) | | **Accumulated deficit** | Retained earnings below zero — cumulative losses exceeding cumulative earnings, presented as a negative within equity, as at almost every venture-funded startup | "negative equity" (equity can stay positive after paid-in capital) | [ASC 505](https://asc.fasb.org/505/tableOfContent) | | **Liquidation preference** | A preferred stockholder's right to be paid a stated amount — usually 1× the original issue price plus declared but unpaid dividends — before common stockholders in a sale or liquidation. Non-participating preferred takes the greater of that or its as-converted share; participating preferred takes both | "valuation", "debt" | [NVCA model certificate of incorporation](https://nvca.org/model-legal-documents/) | | **83(b) election** | An election, filed with the IRS within 30 days of receiving restricted stock, to be taxed on its value at grant rather than as it vests — at a founder's purchase price, usually no tax at all, and the capital gain holding period starts at once | "early exercise" (which makes the election possible) | [IRC §83(b)](https://www.law.cornell.edu/uscode/text/26/83) | | **Qualified small business stock** / **QSBS** / **Section 1202** | Original-issue stock of a domestic C corporation with gross assets of $75 million or less at issuance that lets holders exclude gain on sale — for stock issued after 4 July 2025, 50% after three years, 75% after four and 100% after five, up to the greater of $15 million or ten times basis per issuer. Stock issued earlier keeps the old rules: $50 million of gross assets, five years, $10 million | "small business stock" unqualified | [IRC §1202](https://www.law.cornell.edu/uscode/text/26/1202) | | **Default alive** | Whether, with expenses held where they are and revenue growing at its recent rate, a company reaches profitability on the cash it already has. If not, it is default dead and its runway ends in a raise | "profitable", "runway" | Startup convention (Paul Graham, 2015) — not codified | | **Quality of earnings** / **QoE** | A diligence analysis, usually by an accounting firm before an acquisition or financing, of whether reported EBITDA is sustainable — normalizing and non-recurring adjustments, proof of cash, the net working capital peg. It gives no assurance and is not an audit | "audit", "review" | Transaction diligence practice — not codified | ## Tax | Term | Definition | Not | Authority | |---|---|---|---| | **Temporary difference** | A difference between an asset or liability's tax basis and its book carrying amount that will reverse in future years — the source of deferred taxes | "permanent difference" (never reverses) | [ASC 740-10-25-20](https://asc.fasb.org/740/tableOfContent) | | **Deferred tax asset** / **DTA** | Future tax savings from deductible temporary differences and carryforwards, measured at enacted rates | "tax refund receivable" | [ASC 740-10-30-5](https://asc.fasb.org/740/tableOfContent) | | **Deferred tax liability** / **DTL** | Future tax payable on taxable temporary differences — typically accelerated tax depreciation — measured at enacted rates | "income tax payable" (current, due now) | [ASC 740-10-30-5](https://asc.fasb.org/740/tableOfContent) | | **Valuation allowance** | The reduction of a deferred tax asset to the amount more likely than not (over 50%) to be realized | "reserve" | [ASC 740-10-30-5](https://asc.fasb.org/740/tableOfContent) | | **Effective tax rate** / **ETR** | Total income tax expense divided by pre-tax income, reconciled to the statutory rate in the notes — by category and jurisdiction under ASU 2023-09 | "statutory rate", "marginal rate" | [ASC 740-10-50-12; ASU 2023-09](https://asc.fasb.org/740/tableOfContent) | | **Sales tax nexus** | The connection with a state that obliges a seller to collect its sales tax — physical presence, or economic nexus from sales into the state above a threshold (commonly $100,000) since South Dakota v. Wayfair (2018) | "registration" | [South Dakota v. Wayfair, 585 U.S. 162 (2018)](https://www.supremecourt.gov/opinions/17pdf/17-494_j4el.pdf) | | **Form 1099-NEC** | The IRS information return for nonemployee compensation paid to a contractor in the course of business — required at $600 a year for payments through 2025 and $2,000 from 2026 (indexed from 2027), due to the recipient and the IRS by 31 January | "1099" unqualified, "payslip" | [IRS — About Form 1099-NEC](https://www.irs.gov/forms-pubs/about-form-1099-nec) | | **Form W-9** | The IRS form a US payee gives a payer to certify its name, tax classification and taxpayer identification number — collected before the first payment, because it drives 1099 reporting and backup withholding | "tax form" unqualified | [IRS — About Form W-9](https://www.irs.gov/forms-pubs/about-form-w-9) | | **FUTA** | The federal unemployment tax an employer pays — 6.0% of the first $7,000 of each employee's wages, reduced to a net 0.6% by the state credit unless the state is a credit-reduction state; filed on Form 940 | "SUTA", "payroll withholding" (FUTA is employer-only) | [IRS — About Form 940](https://www.irs.gov/forms-pubs/about-form-940) | | **SUTA** | The state unemployment insurance tax an employer pays on each employee's wages up to a state wage base, at an experience-rated rate assigned by the state | "FUTA", "SUI withholding" (employee contributions exist only in a few states) | [US DOL — state unemployment insurance](https://oui.doleta.gov/unemploy/) | | **Net operating loss** / **NOL** | The excess of tax deductions over gross income for a year. Federal NOLs arising after 2017 carry forward indefinitely, cannot be carried back (farming aside), and may offset only 80% of taxable income computed before the NOL deduction | "tax loss" unqualified, "book loss" | [IRC §172](https://www.law.cornell.edu/uscode/text/26/172) | | **NOL deduction cap** | A limit on how much of a year's income a loss carryforward may offset — federally 80% for post-2017 NOLs; in the states it varies: Pennsylvania caps pre-2025 losses at 40% and later ones at 50% in 2026, rising ten points a year to 80% from 2029, and some states suspend NOL deductions outright in deficit years | "NOL expiry" (a time limit rather than an amount limit) | [IRC §172(a)(2)](https://www.law.cornell.edu/uscode/text/26/172); state law | | **Section 382 limitation** | The annual cap on using pre-change NOLs and credits after an ownership change — a more-than-50-percentage-point increase in ownership by 5% shareholders over a rolling three-year testing period. The cap is the loss corporation's equity value immediately before the change times the IRS long-term tax-exempt rate; unused cap carries forward | "NOL expiry", "change of control" alone | [IRC §382](https://www.law.cornell.edu/uscode/text/26/382) | | **Section 174A** | The OBBBA rule restoring immediate deduction of domestic research or experimental expenditures for tax years beginning after 2024, with an election to amortize over 60 months or more instead. Foreign research stays capitalized over 15 years under §174; small businesses could apply it back to 2022–2024 | "R&D credit" (a separate credit, §41) | [Rev. Proc. 2025-28](https://www.irs.gov/pub/irs-drop/rp-25-28.pdf) | | **Payroll tax offset** / **Research credit against payroll tax** | An election by a qualified small business — gross receipts under $5 million and none before the last five years — to apply up to $500,000 a year of its research credit against the employer share of Social Security and then Medicare tax. Elected on Form 6765 with a timely original return, claimed on Form 8974 from the next quarter | "refundable R&D credit" | [IRS — Instructions for Form 8974](https://www.irs.gov/instructions/i8974) | | **De minimis safe harbor** | An annual tax election to deduct, rather than capitalize, tangible property costing up to $2,500 per invoice or item — $5,000 with an applicable financial statement — provided the books expense the same amounts under an accounting policy in place at the start of the year | "capitalization threshold" (the book policy it relies on) | [Treas. Reg. §1.263(a)-1(f)](https://www.ecfr.gov/current/title-26/section-1.263(a)-1) | | **Section 179 expensing** / **Section 179** | An election to deduct the cost of qualifying equipment and software in the year it is placed in service — up to $2,560,000 for 2026, phased out dollar for dollar above $4,090,000 of purchases, and limited to taxable business income | "bonus depreciation", "write-off" | [IRC §179](https://www.law.cornell.edu/uscode/text/26/179); Rev. Proc. 2025-32 | | **Bonus depreciation** | Additional first-year tax depreciation under §168(k) — 100%, permanently, for qualified property acquired after 19 January 2025 under the OBBBA. Not limited to taxable income, so it can create an NOL; book depreciation is unaffected, creating a temporary difference | "Section 179", "accelerated depreciation" unqualified | [IRC §168(k)](https://www.law.cornell.edu/uscode/text/26/168) | | **Economic nexus** | A connection with a state created by economic activity alone — sales into the state above a threshold — rather than physical presence. The basis of remote-seller sales tax since South Dakota v. Wayfair (2018), and of many states' income and franchise taxes, where P.L. 86-272 still shields solicitation of tangible goods sales | "physical presence", "registration" | [South Dakota v. Wayfair, 585 U.S. 162 (2018)](https://www.supremecourt.gov/opinions/17pdf/17-494_j4el.pdf) | | **Marketplace facilitator** | A platform — Amazon, Etsy, eBay — that every sales-tax state requires to collect and remit sales tax on sales it facilitates for third-party sellers; the seller does not collect again on those sales | "marketplace seller" (the third party) | State sales tax law — not codified in GAAP | | **Remote-seller grace period** | The time a seller with no physical presence has, after crossing a state's economic nexus threshold, before it must register and collect — immediately, the first day of a following month, or the next calendar year, depending on the state | "amnesty", "safe harbor" | State sales tax law — not codified in GAAP | | **Use tax** / **Compensating tax** | The buyer-side complement of sales tax, owed at the same rate on taxable goods and services used in a state when the seller did not collect sales tax — self-assessed and remitted by the buyer | "sales tax" (collected by the seller) | State sales and use tax law — not codified in GAAP | | **Exemption certificate** / **Resale certificate** | The document a buyer gives a seller to claim a sales tax exemption — resale, manufacturing, an exempt organization — which the seller keeps to prove why it did not collect. New Mexico's version is the nontaxable transaction certificate (NTTC) | "tax ID", "W-9" | State sales tax law — not codified in GAAP | | **Vendor's compensation** / **Timely filing discount** | The share of collected sales tax a seller may keep, in states that allow it, for filing and paying on time — usually a small percentage, often capped | "sales tax refund", "rebate" | State sales tax law — not codified in GAAP | | **Home-rule sales tax** | A local sales tax that a home-rule city administers itself — its own license, return, base and audit — rather than through the state. Common in Colorado, Louisiana and Alabama | "local sales tax" unqualified | [Colo. Const. art. XX, §6](https://leg.colorado.gov/colorado-constitution); state law | | **Gross receipts tax** / **GRT** / **Transaction privilege tax** / **B&O tax** | A tax on a business's gross receipts with no deduction for costs, levied on the seller — Washington B&O, Ohio CAT, Nevada commerce tax, New Mexico GRT, Arizona TPT. An operating expense, not an income tax under ASC 740; Texas's margin tax, measured on a modified gross margin, is treated as an income tax | "sales tax" (a tax on the buyer), "income tax" | State tax law; [ASC 740-10-15-4](https://asc.fasb.org/740/tableOfContent) | | **Municipal net profit tax** / **Net profits tax** / **Earnings tax** | A city income tax on a business's net profits, usually paired with a tax on wages earned in the city — Ohio municipalities, Philadelphia, Wilmington, Kansas City and St. Louis. Filed with the city, not the state | "state income tax", "gross receipts tax" | [Ohio Rev. Code ch. 718](https://codes.ohio.gov/ohio-revised-code/chapter-718) | | **Apportionment** / **Single sales factor** | Dividing a multistate business's taxable income among states by formula — historically property, payroll and sales; now in most states the sales factor alone | "allocation" (assigning nonbusiness income to one state) | Multistate Tax Compact art. IV (UDITPA); state law — not codified in GAAP | | **Market-based sourcing** | Assigning receipts from services and intangibles to the state where the customer receives the benefit, for the sales factor — the rule in most states | "cost-of-performance sourcing" | State law — not codified in GAAP | | **Cost-of-performance sourcing** | Assigning receipts from services to the state where the income-producing activity is performed — under the UDITPA rule, all of it to the state with the greater share of the costs of performance | "market-based sourcing" | Multistate Tax Compact art. IV, §17 (UDITPA) — not codified in GAAP | | **IRC conformity** / **Conformity date** | How a state's income tax adopts the federal Internal Revenue Code — rolling (as currently amended), fixed-date (as of a stated date, updated by legislation) or selective. A fixed-date state may not follow §174A, bonus depreciation or other recent federal changes | "federal taxable income" unqualified | State law — not codified in GAAP | | **Refundable credit** | A tax credit whose excess over the tax due is paid out in cash. Because it does not depend on taxable income, it is generally accounted for outside ASC 740 — as a reduction of the related cost or as other income | "deduction", "carryforward" | Tax law; ASC 740 scope judgment — not codified | | **Transferable credit** | A tax credit the holder may sell for cash to another taxpayer — federal clean-energy credits under §6418, many state film and historic credits; New Jersey's program also lets technology companies sell NOLs | "refundable credit" (paid by the government) | [IRC §6418](https://www.law.cornell.edu/uscode/text/26/6418); state law | | **Voluntary disclosure agreement** / **VDA** | An agreement in which a business with unfiled state tax obligations comes forward before the state contacts it — often anonymously at first — and files and pays a limited lookback period, typically three to four years, with penalties waived and interest usually due | "amnesty" (a time-limited program), "audit settlement" | State tax agency programs; MTC National Nexus Program — not codified | | **Form 1099-K** | The information return from a payment settlement entity reporting a payee's gross payment card and third-party network receipts — card transactions at any amount; third-party network transactions only above $20,000 and 200 transactions, the threshold the OBBBA retroactively reinstated. Gross of refunds, chargebacks and fees, so it never ties to revenue | "1099" unqualified, "sales report" | [IRS — Form 1099-K threshold FAQs](https://www.irs.gov/newsroom/irs-issues-faqs-on-form-1099-k-threshold-under-the-one-big-beautiful-bill-dollar-limit-reverts-to-20000) | | **Backup withholding** | Federal income tax a payer must withhold at 24% from reportable payments — contractor fees, interest, dividends, card settlements — when the payee has not given a correct TIN or the IRS has notified the payer to withhold. Deposited and reported on Form 945 | "payroll withholding" | [IRC §3406; IRS — Backup withholding](https://www.irs.gov/businesses/small-businesses-self-employed/backup-withholding) | | **Combined Federal/State Filing Program** / **CF/SF** | The IRS program that forwards 1099s filed electronically to participating states, satisfying their filing requirement — though many states still require direct filing, or a reconciliation return of their own | "state 1099 filing" unqualified | [IRS Pub. 1220](https://www.irs.gov/pub/irs-pdf/p1220.pdf) | | **Form 7004** | The IRS application for an automatic extension of time to file business income tax returns — six months for Forms 1120, 1120-S and 1065 — filed by the original due date. It extends the time to file, not the time to pay | "tax extension" for payment | [IRS — About Form 7004](https://www.irs.gov/forms-pubs/about-form-7004) | | **Worker classification** | Deciding whether a worker is an employee or an independent contractor — federally by the common-law control test (behavioral, financial and relationship facts), in some states by the stricter ABC test. It decides who withholds and pays employment taxes | "contractor status" chosen by contract alone | [IRS — Independent contractor or employee](https://www.irs.gov/businesses/small-businesses-self-employed/independent-contractor-self-employed-or-employee) | | **ABC test** | A state test presuming a worker is an employee unless the hiring entity proves all three: (A) free from its control, (B) work outside the usual course of its business, and (C) customarily engaged in an independent trade — California (AB 5), Massachusetts, and New Jersey for unemployment | "IRS test", "common-law test" | [Cal. Lab. Code §2775](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB§ionNum=2775) | | **Voluntary Classification Settlement Program** / **VCSP** | The IRS program letting an employer prospectively reclassify contractors as employees by paying 10% of one year's employment tax liability, computed at the reduced §3509 rates — no interest or penalties, and no audit of the prior classification. Applied for on Form 8952 | "amnesty" | [IRS — VCSP](https://www.irs.gov/businesses/small-businesses-self-employed/voluntary-classification-settlement-program) | | **Social Security wage base** / **Contribution and benefit base** | The maximum annual wages subject to the 6.2% Social Security tax, for employee and employer each — $184,500 in 2026, $176,100 in 2025, indexed every year. Medicare tax has no cap | "FUTA wage base", "Medicare wage base" | [SSA — Contribution and benefit base](https://www.ssa.gov/oact/cola/cbb.html) | | **FUTA credit reduction** | The cut to the 5.4% state credit against FUTA for employers in a state that has carried a federal unemployment loan for two consecutive 1 January balances and not repaid it by 10 November — 0.3 points, growing each further year. For 2025: California 1.2%, US Virgin Islands 4.5% | "SUTA increase" | [US DOL — FUTA credit reductions](https://oui.doleta.gov/unemploy/futa_credit.asp) | | **Paid family and medical leave** / **PFML** | A state insurance program, funded by payroll contributions from employees, employers or both, that pays wage replacement during leave for a serious health condition, a new child or family care — in about a dozen states and DC, each with its own rate and wage base | "FMLA" (unpaid, federal job protection) | State law — not codified in GAAP | | **State disability insurance** / **SDI** | A state program paying partial wages to employees off work with a non-work-related illness or injury, funded mainly by employee withholding — California, Hawaii, New Jersey, New York, Rhode Island and Puerto Rico | "workers' compensation" (work-related injury) | State law — not codified in GAAP | | **Monopolistic state fund** | A state workers' compensation fund that is the only insurer allowed — North Dakota, Ohio, Washington and Wyoming — so coverage cannot be bought privately (Ohio and Washington permit self-insurance) | "private carrier" | State law — not codified in GAAP | | **Large corporate filer** | A Delaware corporation with stock listed on a national securities exchange that reports consolidated gross revenues or assets of $750 million or more — it pays a flat $250,000 annual franchise tax instead of the $200,000 maximum | "public company" unqualified | [8 Del. C. §503](https://delcode.delaware.gov/title8/c001/sc05/index.html) | | **Assumed par value capital method** | The alternative Delaware franchise tax calculation: assumed par (total gross assets ÷ issued shares) times authorized shares, taxed at $400 per $1,000,000, minimum $400 — usually far below the authorized shares method for a startup with millions of authorized shares | "authorized shares method" | [Delaware Division of Corporations — franchise tax](https://corp.delaware.gov/frtaxcalc/) | --- ## Words we don't use | Banned | Why | Instead | |---|---|---| | "Oops", "Whoops" | Not a register for money | State what happened | | Any emoji | Platform-inconsistent, untintable, unserious | An icon, or nothing | | "Simply", "just" | Minimizes something the user found hard | Remove | | "Sync failed" | Says nothing about consequences | "Couldn't reach Xero. No data changed." | | "Are you sure?" | Asks nothing useful | Restate the action, amount and destination | | "Invalid" | Doesn't say how to fix it | "Enter an amount of $0.01 or more" | | "Total" for a subtotal | Precision matters here | "Total revenue" vs. "Net income" | | "Balance" unqualified | Available vs. current is material | Say which | | "Delete" on posted data | Impossible by design | "Reverse" | | "Approve" by the preparer | Control violation | The action isn't offered | --- # Lopifi APIs: Xero, QuickBooks Online, NetSuite Three general ledgers, three different opinions about what an invoice *is*. This document compares them object by object, so a component built on one does not quietly break on another. Everything in this system eventually reads from somebody else's ledger. The components render a normalised model; the integration layer produces it. The gap between those two is where most accounting products fail — not in the UI, but in the assumption that "invoice" means the same thing in all three systems. It does not. > **The rule this document exists to enforce:** normalise the *shape*, never the *semantics*. A field that exists in only one system must survive normalisation as an explicit, nullable, source-tagged field — never as a silently defaulted value. Ordered by how much a difference costs you: model differences first, mechanics last. --- ## 1. The three APIs at a glance | | **Xero** | **QuickBooks Online** | **NetSuite** | |---|---|---|---| | Style | REST-ish, one resource per noun | REST + SQL-like query language | Three overlapping APIs (REST record, SuiteQL, SOAP — removed in 2028.2) + RESTlets | | Base | `api.xero.com/api.xro/2.0/` | `quickbooks.api.intuit.com/v3/company/{realmId}/` | `{acct}.suitetalk.api.netsuite.com/services/rest/` | | Payload | JSON or XML; arrays wrapped (`{"Invoices":[…]}`) | JSON; single object or `QueryResponse` | JSON; sublists nested or as sub-resources | | Auth | OAuth 2.0 + `Xero-Tenant-Id` header | OAuth 2.0 + `realmId` in path | OAuth 2.0 (M2M cert or auth-code); OAuth 1.0 TBA for existing integrations only — no new ones from 2027.1 | | Versioning | Path (`2.0`), rarely changes | `minorversion=` query param, changes often and matters | Dated WSDL / REST record schema per release (e.g. `2024.1`) | | Reads a list | Returns **full records** | Returns **full records** | REST returns **ids only**; SuiteQL returns rows | | Server-side logic on write | None | Minimal (tax engine) | **Extensive** — SuiteScript, SuiteFlow, custom validation | | Multi-entity | One org per tenant | One company per realm | **Subsidiaries inside one account** (OneWorld) | | Custom dimensions | 2 tracking categories, fixed | Class + Department, fixed | **Unlimited custom segments** | | Webhooks | Invoices, contacts, credit notes (+ app subscriptions), id-only payload | Many entities, id-only payload | **None native** — script it yourself | The single most consequential row is the last-but-two. Xero and QBO give you a fixed, small dimensional model you can hardcode into a UI. NetSuite gives you a user-defined one, which means any NetSuite-aware component must render dimensions **from metadata, not from a type literal**. ### The mental model of each **Xero is a document store.** Nouns are flat and few. An invoice is an invoice; `Type` distinguishes `ACCREC` (sales) from `ACCPAY` (purchase). The GL is a *derived, read-only* artefact you fetch from a separate `Journals` endpoint — which, since 2 March 2026, only apps on the Advanced tier or above that have passed Xero's security assessment and had their use case approved may call. You cannot post arbitrary debits and credits except through `ManualJournals`. **QBO is a transaction engine with a query language.** Nouns are many and specific — `Invoice`, `Bill`, `Purchase`, `SalesReceipt`, `CreditMemo`, `VendorCredit`, `Deposit`, `Transfer` are all separate types for what Xero models as two invoice types and a bank transaction. Lines are polymorphic via `DetailType`. Writes use optimistic locking (`SyncToken`) and are **destructive by default** unless you set `sparse: true`. **NetSuite is an ERP with a database exposed.** Everything posting is a row in one `transaction` table with a `type` discriminator, plus sublists. Records carry customisations, approval workflows, and scripts that fire on write. The API is closer to "your write attempt enters a business process" than "your write updates a row". --- ## 2. Identity, tenancy, and the entity boundary | Concept | Xero | QBO | NetSuite | |---|---|---|---| | Tenant unit | Organisation (`tenantId`, GUID) | Company (`realmId`, numeric string) | Account (`accountId`), containing many subsidiaries | | Record id | GUID (`InvoiceID`) | Numeric string (`Id: "145"`), **unique per entity type only** | Numeric string (`id: "8412"`), unique per record type | | Human number | `InvoiceNumber`, `ContactNumber` | `DocNumber` | `tranid` | | Concurrency control | Last write wins | **`SyncToken`** — must round-trip, mismatch = 400 | Optimistic via record retrieval; scripts may reject | | Consolidation | Not in product — one org, one currency base | Not in product | **Native**: subsidiary hierarchy, elimination, consolidated FX rates | Two hard consequences. **QBO ids are not globally unique.** `Invoice 145` and `Bill 145` both exist. Any cache, URL, or component key must be `{realmId}:{entityType}:{id}`. This bites the first time you build a global search. **Multi-entity is a modelling difference, not a feature difference.** A Xero or QBO group is *n* disconnected tenants that you must consolidate yourself: your system owns the entity tree, the intercompany elimination, and the FX translation. A NetSuite OneWorld account already has all three, and every record carries a `subsidiary` field that must be respected on read *and* on write. See [multi-entity and FX](/docs/domain/09-multi-entity-and-fx) for what the UI has to do about it. Practical rule: model the entity tree in your own schema regardless of source, and treat NetSuite's subsidiary as a mapping into it rather than as the tree itself. Otherwise a mixed-source group (two Xero orgs plus a NetSuite subsidiary — common in mid-market) has no coherent parent. --- ## 3. Chart of accounts The one object all three broadly agree on, and still the one that most often breaks the P&L. | Field | Xero `Account` | QBO `Account` | NetSuite `account` | |---|---|---|---| | Id | `AccountID` (GUID) | `Id` | `id` | | Number | `Code` — string, **unique-enforced, required except on bank accounts, editable** | `AcctNum` — optional; absent unless account numbers enabled | `acctnumber` | | Name | `Name` | `Name`, plus `FullyQualifiedName` (`"Parent:Child"`) | `acctname`, plus `displayname` | | Type | `Type` (~30 values: `BANK`, `CURRENT`, `FIXED`, `REVENUE`, `DIRECTCOSTS`, `EXPENSE`, `EQUITY`…) | `AccountType` (~20) + `AccountSubType` (~80) | `accttype` (`Bank`, `AcctRec`, `AcctPay`, `Income`, `COGS`, `Expense`, `OthCurrAsset`, `FixedAsset`…) | | Class | `Class`: `ASSET`/`LIABILITY`/`EQUITY`/`REVENUE`/`EXPENSE` | `Classification`: same five | Derived from `accttype` | | Hierarchy | **None** — flat list | `SubAccount` + `ParentRef`, up to 5 levels | `parent` — arbitrary depth | | Reserved accounts | `SystemAccount` (`DEBTORS`, `CREDITORS`, `BANKCURRENCYGAIN`, `RETAINEDEARNINGS`…) | Implied by `AccountType` (`Accounts Receivable`, `Accounts Payable`) | `AcctRec` / `AcctPay` types; `specialaccounttype` | | Bank detail | `BankAccountNumber`, `CurrencyCode` | `AcctNum`, `CurrencyRef` | `currency`, bank fields on account | | Balance | Not on the object | `CurrentBalance`, `CurrentBalanceWithSubAccounts` | Not on the record (query the GL) | | Multi-entity | — | — | `subsidiary` list — an account can be restricted per subsidiary | | Disable | `Status: ACTIVE/ARCHIVED` | `Active: true/false` | `isinactive` | ### What actually differs **Hierarchy.** Xero has none. QBO has five levels and encodes the path in `FullyQualifiedName` with `:` as a separator — which means a colon in an account name is a parsing hazard. NetSuite is arbitrary depth. A COA tree component must therefore handle a flat list gracefully; don't build one that assumes a parent link exists. **Account numbers are not reliable identifiers.** They are optional in QBO (`AcctNum` is only populated when the preference is on) and on Xero bank accounts — Xero enforces `Code` as unique and requires it on every other account — and editable in all three. Unique is not stable: a renumbered chart breaks every mapping keyed on the code. Key on the source id; display the number. **Type granularity.** All three separate receivables from other current assets at the type level — QBO `AccountType` has `Accounts Receivable` vs `Other Current Asset`, NetSuite has `AcctRec` vs `OthCurrAsset`. QBO's `AccountSubType` (~80 values) gives the finest granularity of the three, below that. Xero's `Type` is coarser but includes `SystemAccount` for the accounts you must never let a user post to. Mapping all three onto one taxonomy loses information in exactly one direction: **map to the coarsest common type, and keep the source type verbatim alongside it.** Statement grouping should read your own type, tooltips should show theirs. **Normal balance is not a field anywhere.** Derive it from class — asset and expense are debit-normal, liability, equity and revenue are credit-normal — then **invert it for contra accounts**: accumulated depreciation, allowance for doubtful accounts, treasury stock, sales returns and discounts. Take the contra flag from the source subtype (QBO `AccumulatedDepreciation`, `DiscountsRefundsGiven`…) or from your own mapping; never from class alone. See [the accounting model](/docs/domain/01-accounting-model) §2. --- ## 4. Parties: contacts, customers, vendors | | Xero | QBO | NetSuite | |---|---|---|---| | Model | **One `Contact`** for both roles | **Separate** `Customer` and `Vendor` | Separate `customer` and `vendor` records | | Role flag | `IsCustomer` / `IsSupplier` — **read-only, derived from transaction history** | Implicit in the entity type | Implicit; `entity` is the shared abstraction | | Uniqueness | `Name` **must be unique** across the org | `DisplayName` **must be unique across** `Customer`, `Vendor`, `Employee` (and Other Name) — a customer and a vendor cannot both be `"Acme Ltd"` | `entityid` unique | | Hierarchy | `ContactGroups` (flat grouping, not parenting) | `Job` / sub-customer via `ParentRef`, `BillWithParent` | `parent` on customer; true hierarchy | | Addresses | `Addresses[]` typed `STREET` / `POBOX` | `BillAddr`, `ShipAddr`, `OtherAddr` as distinct fields | `addressbook` sublist, many addresses, one default each | | Contacts-of-contact | `ContactPersons[]` | Single name/email fields | `contact` records linked to entity | | Defaults | `DefaultCurrency`, `SalesDefaultAccountCode`, `PurchasesDefaultAccountCode`, `PaymentTerms` | `CurrencyRef`, `SalesTermRef`, `PaymentMethodRef` | `currency`, `terms`, `receivablesaccount` | | Tax id | `TaxNumber` | `PrimaryTaxIdentifier` / `TaxIdentifier` | `vatregnumber`, `taxidnum` | | 1099 | — | `Vendor1099` boolean | `is1099eligible` | | Balance | — | `Balance`, `BalanceWithJobs` on `Customer` | Query | ### The unification trap Xero's single-contact model is genuinely different, not cosmetically different. One `Contact` record can be simultaneously a customer and a supplier — and often is, legitimately (a client who is also a subcontractor). QBO forces you to create two records, with **no link between them**, so the same real-world party appears twice with different ids and independent balances. If your canonical model has a single `Party` with role flags — the Xero shape — then QBO import must decide whether to merge `Customer "Acme Ltd"` and `Vendor "Acme Ltd (V)"` into one party. QBO will not let both be called `"Acme Ltd"` — `DisplayName` is unique across customers, vendors and employees — so the same party usually arrives with a suffix on one side. **Do not auto-merge on name**, exact or fuzzy. Stripping `(V)` finds candidates, not matches, and wrong-merging corrupts both aging reports. Merge only on an explicit user action or a strong identifier (tax id), and keep the two source ids on the merged party. If your canonical model has separate customers and suppliers — the QBO shape — then a Xero contact that is both must be projected into two, and any UI that navigates "from party to all transactions" has to reunite them. Pick one, write it down, and make the roundtrip explicit. This is a decision, not a detail. --- ## 5. Sales invoices (AR) | | Xero `Invoice` (`Type: ACCREC`) | QBO `Invoice` | NetSuite `invoice` | |---|---|---|---| | Discriminator | `Type` field on a shared object | Its own entity | `type: invoice` on `transaction` | | Number | `InvoiceNumber` (unique, may be auto) | `DocNumber` (uniqueness is a preference) | `tranid` | | Party | `Contact` | `CustomerRef` | `entity` | | Dates | `Date`, `DueDate` | `TxnDate`, `DueDate` | `trandate`, `duedate` | | Terms | `Contact.PaymentTerms` (no per-invoice term object) | `SalesTermRef` | `terms` | | Status | `DRAFT`, `SUBMITTED`, `AUTHORISED`, `PAID`, `VOIDED`, `DELETED` | **No status field** — derive from `Balance`, `TotalAmt`, void state | `status` (`Open`, `Paid In Full`, `Pending Approval`…), `approvalstatus` | | Open amount | `AmountDue` | `Balance` | `amountremaining` | | Total | `Total`, `SubTotal`, `TotalTax` | `TotalAmt`, `TxnTaxDetail.TotalTax` | `total`, `taxtotal` | | Tax mode | `LineAmountTypes`: `Exclusive`/`Inclusive`/`NoTax` | `GlobalTaxCalculation`: `TaxExcluded`/`TaxInclusive`/`NotApplicable` (non-US) | Per-line tax code; nexus-driven | | Line target | **Account code, or item** | **`ItemRef` effectively required** | Item sublist (item required) or expense sublist | | Line types | Uniform | `DetailType`: `SalesItemLineDetail`, `GroupLineDetail`, `DescriptionOnly`, `DiscountLine`, `SubTotalLine` | Sublist columns | | Dimensions | `Tracking[]` per line (≤2) | `ClassRef` (line or header), `DepartmentRef` (header) | department / class / location / custom segments, header **and** line | | FX | `CurrencyCode`, `CurrencyRate` | `CurrencyRef`, `ExchangeRate` | `currency`, `exchangerate` | | Links | `Payments[]`, `CreditNotes[]`, `Prepayments[]`, `Overpayments[]` inline | `LinkedTxn[]` (payments, credit memos) | `links` / transaction relationships | | Attachments | `HasAttachments`, sub-resource | `Attachable` entity, queried separately | `file` records via sublist | ### The three differences that actually cost you **1. QBO has no invoice status.** There is no `Status` field. "Open / Paid / Overdue / Voided" is something you compute: ```ts // QBO status is derived, never read. function qboInvoiceStatus(inv: QboInvoice, today: Date): InvoiceStatus { if (inv.PrivateNote?.startsWith("Voided")) return "voided"; // voided invoices keep TotalAmt 0 if (inv.TotalAmt === 0 && inv.Balance === 0) return "voided_or_zero"; if (inv.Balance === 0) return "paid"; if (inv.Balance < inv.TotalAmt) return "part_paid"; if (inv.DueDate && inv.DueDate < today) return "overdue"; return "open"; } ``` A void in QBO zeroes the amounts and keeps the record — so a naive "sum of `TotalAmt`" revenue figure silently loses voided invoices, correctly, but a naive "count of invoices" over-counts. Xero, by contrast, gives you `VOIDED` explicitly and preserves the original amounts on the record. **2. QBO wants items; Xero wants accounts.** A Xero invoice line can post straight to `AccountCode: "200"`. A QBO invoice line posts to an `Item`, and the item carries `IncomeAccountRef`. The GL result is the same; the write path is not. To create an invoice in QBO from a source that has only an account, you must resolve or create a service item pointing at that account — and that item then appears in the customer's product list forever. Plan for a dedicated "integration" item set rather than creating one item per account on the fly. **3. Dimension placement.** Xero puts tracking on the line, always. QBO puts `DepartmentRef` on the *header* and `ClassRef` on the line **or** header depending on a company preference. NetSuite allows both, with line overriding header. A dimensional P&L component must therefore accept per-line dimensions and know that some sources will hand it a header value applied to every line. Fan the header value down to the lines at normalisation time and record that you did — otherwise an edit UI will happily write per-line values back to a system that cannot store them. ### Same fact, three payloads Selling one consulting day at 1,000, 20% VAT exclusive, tracked to department "R&D": ```jsonc // Xero — account-based line, tracking on the line { "Type": "ACCREC", "Contact": { "ContactID": "…" }, "Date": "2026-09-07", "DueDate": "2026-10-07", "LineAmountTypes": "Exclusive", "LineItems": [{ "Description": "Consulting", "Quantity": 1, "UnitAmount": 1000, "AccountCode": "200", "TaxType": "OUTPUT2", "Tracking": [{ "Name": "Department", "Option": "R&D" }] }] } // QBO (non-US company) — item-based line, DetailType discriminator, // department on the header; TaxCodeRef is the company's numeric 20% code id { "CustomerRef": { "value": "58" }, "TxnDate": "2026-09-07", "DueDate": "2026-10-07", "GlobalTaxCalculation": "TaxExcluded", "DepartmentRef": { "value": "3" }, "Line": [{ "DetailType": "SalesItemLineDetail", "Amount": 1000, "SalesItemLineDetail": { "ItemRef": { "value": "12" }, "Qty": 1, "UnitPrice": 1000, "TaxCodeRef": { "value": "8" } } }] } // NetSuite — item sublist, segments per line, subsidiary on the header { "entity": { "id": "58" }, "trandate": "2026-09-07", "duedate": "2026-10-07", "subsidiary": { "id": "2" }, "item": { "items": [{ "item": { "id": "12" }, "quantity": 1, "rate": 1000, "taxcode": { "id": "5" }, "department": { "id": "7" }, "cseg_product": { "id": "3" } }] } } ``` Three payloads, one journal: Dr AR 1,200 / Cr Revenue 1,000 / Cr VAT 200. The normalised model should be the journal-adjacent one; the payload differences belong entirely in the adapter. --- ## 6. Bills (AP) | | Xero | QBO | NetSuite | |---|---|---|---| | Object | `Invoice` with `Type: ACCPAY` | `Bill` (accrual) — **and** `Purchase` (cash/card, no AP) | `vendorbill` | | Line to account | `AccountCode` on line | `AccountBasedExpenseLineDetail` | **`expense` sublist** | | Line to item | `ItemCode` on line | `ItemBasedExpenseLineDetail` | **`item` sublist** | | AP control account | Implicit `CREDITORS` system account | `APAccountRef` — **selectable** | `account` (AcctPay) | | Vendor ref | `Contact` | `VendorRef` | `entity` | | Billable to customer | `LinkedTransactions` — links an `ACCPAY`/`SPEND` line to a customer or `ACCREC` invoice (the billable-expenses API) | `BillableStatus`, `CustomerRef` on line | `isbillable`, `customer` on line | | PO match | **Not exposed** — only `PurchaseOrder.Status: BILLED` | `LinkedTxn` to `PurchaseOrder` | Native 2- and 3-way match, receipt records | | Approval | Status `SUBMITTED` (single soft gate) | None in the API | **`approvalstatus`, SuiteFlow multi-step** | | Open amount | `AmountDue` | `Balance` | `amountremaining` | ### The differences **Xero models a bill as an invoice.** Same endpoint, same object, `Type` flips the sign of the GL impact and swaps the control account. This is elegant and it is a trap for a component that renders "invoice" — an AR aging table fed from `/Invoices` without a `Type` filter will show your payables as receivables. **Always filter on `Type`, never assume the endpoint.** **QBO splits accrual from cash.** A vendor bill you will pay later is a `Bill`. A card swipe or immediate payment is a `Purchase` (with `PaymentType: Cash | Check | CreditCard`) which posts **no AP** at all. Any "total spend with this vendor" figure that reads only `Bill` under-reports, often dramatically, because card spend lives in `Purchase`. Xero puts the equivalent in `BankTransactions` (`Type: SPEND`); NetSuite has `vendorbill` versus `check` / `expensereport` / card charge. This is the single most common source of wrong vendor-spend numbers. See [procure to pay](/docs/domain/06-procure-to-pay) — the spend dashboard must aggregate across bill *and* non-bill spend objects, per source. **NetSuite's two sublists are not interchangeable.** A vendor bill carries an `expense` sublist (account, amount, memo, segments) and an `item` sublist (item, quantity, rate, receipt matching). Item lines participate in three-way match and inventory; expense lines do not. Normalising both into one flat `lines[]` loses the distinction, which matters the moment someone asks why a bill won't match its receipt. Keep a `lineKind: "account" | "item"` discriminator in the canonical model — QBO needs it too, for its two `DetailType`s. **Approval is real in NetSuite only.** Xero's `SUBMITTED` status is a flag, not a workflow. QBO's API has no approval concept. NetSuite has multi-step approval routing that your write may enter and not clear. A payables approval component that assumes it can approve via the API works on NetSuite and is decorative elsewhere — where the approval state must live in *your* system, not the ledger's. --- ## 7. Payments and allocation | | Xero | QBO | NetSuite | |---|---|---|---| | AR payment | `Payment` (also `BatchPayment` for grouping) | `Payment` | `customerpayment` | | AP payment | Same `Payment` object | **`BillPayment`** — separate entity | `vendorpayment` | | Allocation model | **One payment record per invoice** — but `PUT /Payments` takes an array and `BatchPayments` settles many invoices in one call | One payment, **`Line[]` with `LinkedTxn` per invoice** | One payment, `apply` sublist per invoice | | Partial payment | Amount < `AmountDue`; multiple payment records | `Line.Amount` per linked txn | `apply.amount` per line | | Unapplied cash | `Overpayment` / `Prepayment` objects | `UnappliedAmt` on `Payment` | Unapplied on payment; `customerdeposit` | | Bank account | `Account` (must have `EnablePaymentsToAccount`) | `DepositToAccountRef` | `account` | | Payment method | `Reference` free text | `PaymentMethodRef` | `paymentmethod` | | FX on settlement | `CurrencyRate` on payment; Xero computes realised gain | `ExchangeRate`; QBO computes | `exchangerate`; NetSuite computes, per book | **The allocation shapes are genuinely incompatible.** Xero: N payment records, each pointing at one invoice. QBO and NetSuite: one payment record with N allocation lines. A canonical `Payment { lines: Allocation[] }` handles all three — Xero simply produces one-line payments, which you may group by `BatchPayment` id when present. Going the other way (canonical to Xero) means splitting one user action into N payment records. They can travel in one call — an array to `PUT /Payments`, or one `BatchPayments` — but with `summarizeErrors=false` each item succeeds or fails on its own: **check every allocation in the response, make the write replayable**, and never show the user "payment recorded" until all N succeed. **Unapplied cash has three names.** Xero splits it into `Overpayment` (customer paid too much on an invoice) and `Prepayment` (customer paid before an invoice existed) — separate objects with their own endpoints, which many integrations forget entirely, then wonder why AR doesn't tie. QBO keeps it as `UnappliedAmt` on the payment; on the accrual basis that credit sits in Accounts Receivable, and the "Unapplied Cash Payment Income/Expense" lines appear only on cash-basis reports. NetSuite leaves it unapplied on the payment record and additionally has `customerdeposit`. An AR aging component that only sums invoice balances will disagree with the ledger's own aging report by exactly the unapplied cash. Fetch it, show it as its own row, and tie. See [order to cash](/docs/domain/05-order-to-cash). --- ## 8. Credit notes, refunds, and the negative document | Purpose | Xero | QBO | NetSuite | |---|---|---|---| | Reduce a customer invoice | `CreditNote` (`Type: ACCRECCREDIT`) | `CreditMemo` | `creditmemo` | | Reduce a vendor bill | `CreditNote` (`Type: ACCPAYCREDIT`) | `VendorCredit` | `vendorcredit` | | Cash back to customer | Allocate credit to `Payment`, or `Refund` on the credit note | `RefundReceipt` | `customerrefund` | | Cash back from vendor | `Payment` against `ACCPAYCREDIT` | `Deposit` linked to `VendorCredit` | `vendorpayment` / deposit | | Cash sale (no AR) | `BankTransaction` `Type: RECEIVE` | `SalesReceipt` | `cashsale` | Two rules that hold across all three: **Never render a credit note as a negative invoice.** It is a distinct document with its own number sequence, and finance teams reconcile them separately. A ledger table that merges them into one signed column destroys the audit path. See [the accounting model](/docs/domain/01-accounting-model) §1. **Amounts are stored positive.** All three store credit notes with positive amounts and encode the direction in the document type. If your normalisation flips signs, do it in exactly one place and test the round trip — a double-negation bug here reads as a plausible number and survives review. --- ## 9. Journals and the general ledger This is where the three diverge most and where reconciliation lives. | | Xero | QBO | NetSuite | |---|---|---|---| | User-entered JE | `ManualJournals` | `JournalEntry` | `journalentry` | | Full GL feed | **`Journals`** — read-only, every posting from every source. Since 2 Mar 2026: Advanced tier or above, security assessment and use-case approval | **No equivalent** — no GL export endpoint | `transaction` + `transactionline` via SuiteQL | | Debit/credit shape | Journals: `NetAmount` signed (+ = debit). ManualJournals: `LineAmount` signed | `JournalEntryLineDetail.PostingType: "Debit" \| "Credit"` + positive `Amount` | `debit` and `credit` columns on the line | | Pagination | `offset={JournalNumber}`, 100 per page, sequential | n/a | `limit`/`offset` on SuiteQL | | Entity on a line | `Contact` not available on Journals lines | `Entity` ref (customer/vendor/employee) per line | `entity` per line | | Dimensions | `TrackingCategories[]` per line | `ClassRef`, `DepartmentRef` per line | full segment set per line | | Multi-book | — | — | `accountingbook` — the same txn posts differently per book | | Reversal | Create the opposite manual journal | `JournalEntry` with `Adjustment` flag; reversal is manual | `reversaldate` on the record — native auto-reversal | **Xero's `Journals` endpoint is the crown jewel and is easy to miss.** It emits the actual GL — every posting from invoices, payments, bills, bank transactions, manual journals — as immutable, sequentially-numbered journal records. Paginate by `offset` on `JournalNumber` and you get an append-only, gapless, perfectly replayable ledger feed. If you are building anything that reconciles or re-derives statements, this is the endpoint you build on, not `/Invoices` — **if you can get it.** Since Xero's app tiers took effect on 2 March 2026, `Journals` is a premium endpoint: the app must be on the Advanced tier or above, pass Xero's security assessment (initial and annual) and have its use case approved; a Starter-tier app gets a 401. `ManualJournals` stays open on every tier, but it only carries user-entered journals, not the postings behind invoices, bills and payments. Without `Journals` access, Xero falls back to the same options as QBO below: the Reports API, or reconstruction from the document endpoints. Check the [Xero developer pricing page](https://developer.xero.com/pricing) before designing on it. **QBO has nothing equivalent, and this is the hardest structural gap in the three.** There is no endpoint that returns GL postings. Your options, in order of practicality: 1. **Reports API** — `GeneralLedger`, `TrialBalance`, `ProfitAndLoss` return report *rows*, not records: nested, presentation-shaped, with header/summary rows to walk. Correct totals, awkward parsing, no stable row ids. 2. **Reconstruct** from every transaction entity plus knowledge of QBO's posting rules. Full control, and you now own the correctness of QBO's tax and FX posting logic — which is a large, ongoing liability. 3. **`/cdc`** (change data capture) to keep entity mirrors fresh, and derive statements from the mirror. Most teams end up with (1) for tie-out and (3) for detail. Whichever you choose, **display which one produced the number** — a P&L reconstructed from entities and a P&L from the Reports API will occasionally differ, and the user needs to know which they are looking at. **NetSuite gives you SQL.** `transactionline` joined to `transaction` and `accountingtransaction` (or `transactionaccountingline` for the debit/credit amounts by book) is the GL. It is the most powerful of the three and the most demanding: you must know which line is the posting line, filter `posting = 'T'`, and pick the right accounting book. ```sql -- NetSuite: GL lines for a period, primary book SELECT t.trandate, t.tranid, t.type, a.acctnumber, a.acctname, tal.debit, tal.credit, tl.department, tl.class, tl.location, tl.memo FROM transaction t JOIN transactionline tl ON tl.transaction = t.id JOIN transactionaccountingline tal ON tal.transaction = t.id AND tal.transactionline = tl.id JOIN account a ON a.id = tal.account WHERE tal.posting = 'T' AND tal.accountingbook = 1 AND t.trandate BETWEEN TO_DATE('2026-08-01','YYYY-MM-DD') AND TO_DATE('2026-08-31','YYYY-MM-DD') ``` Note the shape difference that survives into your model: Xero hands you one signed `NetAmount`, QBO a `PostingType` enum, NetSuite two columns. **Store two columns.** Converting signed to two columns is lossless; the reverse is not, once rounding and zero-amount lines are involved. --- ## 10. Items, products, and services | | Xero `Item` | QBO `Item` | NetSuite item records | |---|---|---|---| | Types | One type; `IsTrackedAsInventory` flag | `Type`: `Inventory`, `NonInventory`, `Service`, `Group` (a bundle in the UI), `Category` | **Many record types**: `inventoryitem`, `noninventoryitem`, `serviceitem`, `assemblyitem`, `kititem`, `itemgroup`, `giftcertificateitem`… | | Code | `Code` (unique) | `Name` / `Sku` | `itemid` | | Sales side | `SalesDetails.{UnitPrice, AccountCode, TaxType}` | `IncomeAccountRef`, `UnitPrice` | `incomeaccount`, pricing sublists | | Purchase side | `PurchaseDetails.{UnitPrice, COGSAccountCode}` | `ExpenseAccountRef`, `PurchaseCost` | `expenseaccount` / `cogsaccount` | | Inventory | `InventoryAssetAccountCode`, `QuantityOnHand` | `AssetAccountRef`, `QtyOnHand`, `InvStartDate` | Full: locations, bins, lots, serials, costing methods | | Hierarchy | None | `ParentRef` via `Category` items | `parent`, `matrix` items | Xero treats items as an optional convenience — most Xero invoices have no item at all. QBO treats them as the required mechanism for sales lines. NetSuite treats them as a whole subsystem with its own costing engine. For a components library the implication is narrow but real: **an invoice line editor cannot require an item, and cannot forbid one.** Item is nullable, and whether it is *required* is a property of the connected source, delivered as capability metadata — not a prop hardcoded per instance. --- ## 11. Tax The messiest area, and the one where "just normalise it" fails hardest. | | Xero | QBO | NetSuite | |---|---|---|---| | Object | `TaxRate` (with `TaxComponents[]` for compound rates) | `TaxCode` → `TaxRate` (many-to-many via `TaxRateDetail`) | `taxcode`, `taxgroup`, `taxtype`, nexus | | Line reference | `TaxType` (the rate's code, e.g. `OUTPUT2`) | `TaxCodeRef` | `taxcode` | | Inclusive/exclusive | `LineAmountTypes` on the document | `GlobalTaxCalculation` on the document (non-US only) | Per-nexus configuration | | Can you create rates? | Yes | **US automated sales tax: no** — rates are Intuit-managed. Non-US: yes | Yes | | Computed tax | `TotalTax`, per-line `TaxAmount` | `TxnTaxDetail.{TotalTax, TaxLine[]}` | `taxtotal`, per-line | | Overriding computed tax | Per-line `TaxAmount` override allowed | `TxnTaxDetail` override, honoured inconsistently under AST | Line-level override | | Jurisdictions | Flat rate list | AST resolves by address | **Nexus model** — jurisdiction by subsidiary and address | Three things to internalise: **QBO US is a different product from QBO non-US.** Under Automated Sales Tax you cannot create tax rates via the API, and the tax on a transaction is computed by Intuit from the addresses. Sending your own tax amounts may be silently overridden. Any tax UI must therefore be read-mostly for US QBO and editable elsewhere — again, capability metadata, not a hardcoded branch. **Compound and multi-component tax survives only as a list.** Xero's `TaxComponents[]` and QBO's `TaxLine[]` both express "this 13% is 5% federal plus 8% provincial". A canonical `taxAmount: Money` scalar throws that away, and it is exactly what a tax return needs. Model tax as `{ code, name, components: [{name, rate, amount}], total }`. **Never re-derive tax from rate × base.** Rounding rules differ per system and per jurisdiction (per-line versus per-document, half-up versus banker's). Store the tax amount the source computed, and if you display a rate, display it as *the source's* rate. See [numerics](/docs/foundations/06-numerics). --- ## 12. Dimensions: tracking, class, department, segment | | Xero | QBO | NetSuite | |---|---|---|---| | Mechanism | `TrackingCategory` with `TrackingOption[]` | `Class`, `Department` entities | `department`, `class`, `location`, `subsidiary`, **`custom segments`** | | How many | **Maximum 2 active** | 2 (Class + Department), gated by preference | 4 built-in + unlimited custom | | Naming | User-named categories ("Region", "Team") | Fixed names; UI relabels Department as "Location" | User-named custom segments | | Hierarchy | Flat options | `SubClass` / `ParentRef` | Hierarchical | | Where applied | Invoice/bill/journal **lines** | Class on line or header (preference); Department on **header** | Header and line, line wins | | Constraint | — | — | Segments can be **required, filtered by subsidiary, and validated by script** | | API surface | `/TrackingCategories` | `/query?query=select * from Class` | `customsegment` metadata + `cseg_*` fields on records | This table is the reason a dimensional filter bar cannot be a static component. Xero gives you at most two, named by the user. NetSuite gives you an arbitrary set whose *names and field keys are discovered at runtime* (`cseg_product`, `cseg_channel`, …). Any component that filters or groups by dimension must take: ```ts type Dimension = { key: string; // "tracking.0" | "class" | "department" | "cseg_product" label: string; // the user's name for it, from the source options: DimensionOption[]; // may be hierarchical appliesTo: "line" | "header" | "both"; required: boolean; }; ``` …as data, and render *n* of them. Hardcoding "Class" and "Department" columns produces a component that is wrong on two of the three systems. Note also that **Xero's two-category limit is a hard product limit**, not a plan limit. A client who wants department *and* project *and* location on Xero cannot have it, and the honest UI answer is to say so rather than to offer a control that cannot be saved. --- ## 13. Periods, locking, and the close | | Xero | QBO | NetSuite | |---|---|---|---| | Period object | None — an org has a `PeriodLockDate` and `EndOfYearLockDate` | None — `Preferences.AccountingInfoPrefs.BookCloseDate` | **`accountingperiod` record** — first class, per year/quarter/month | | Granularity | Two org-wide dates: `PeriodLockDate` blocks everyone but advisers, `EndOfYearLockDate` blocks everyone | One close date, optionally password-protected | Per period, **per subsidiary**, and separately for AP/AR/GL | | Enforced on API writes? | Depends on the connected user's role — before `EndOfYearLockDate` always fails; before `PeriodLockDate` fails unless that user is an adviser | Partially — close date is advisory for some paths | Yes, strictly, per book and per subsidiary | | Reopen | Move the date | Move the date | Explicit period reopen, audited | | Adjusting entries | Ordinary manual journals | `JournalEntry.Adjustment: true` | Period-specific adjustment flag, `isadjust` | NetSuite is the only one with a real period model. Xero and QBO have *dates* — two in Xero, whose effect on an API write depends on the connected user's role — which means "period" in your system is your own construct that you must reconcile against a single cut-off. See [periods and close](/docs/domain/02-periods-and-close) — the close checklist component should treat period state as owned by your system, with the ledger's lock date as one input signal rather than the source of truth. Practical consequence for write paths: **always check the lock date before offering an edit.** A disabled control with "Period locked 31 Aug" is correct UI; a 400 from Xero surfaced as a red toast after the user typed for a minute is not. --- ## 14. Attachments, memos, and the audit trail | | Xero | QBO | NetSuite | |---|---|---|---| | Attachment model | `Attachments` sub-resource on most endpoints | **`Attachable`** entity with `AttachableRef[]` pointing back at txns | `file` record in the file cabinet, linked via sublist | | Upload | `PUT/POST /{Endpoint}/{Guid}/Attachments/{FileName}`, raw body | Multipart `/upload` | `file` record with base64 `contents`, then attach | | Discovery | `HasAttachments` flag on the record | Query `Attachable` by `AttachableRef` | Sublist on the record | | Internal notes | `HistoryAndNotes` endpoint | `PrivateNote` field | `memo`, `usernotes`, system notes | | Change history | `HistoryAndNotes` (read + append note) | **None via API** | **`systemnote`** — full field-level audit, queryable | | Who/when | `UpdatedDateUTC` | `MetaData.{CreateTime, LastUpdatedTime}` — **no user** | `lastmodifiedby`, `owner`, systemnote per field | **Only NetSuite gives you a real audit trail through the API.** Xero gives you a history feed per record; QBO gives you timestamps and no actor at all. Any audit or evidence component (see [controls and audit](/docs/domain/08-controls-and-audit)) must therefore build its own trail over *your* actions and be explicit that ledger-side changes made by a human in the ledger's own UI may be invisible to you — on QBO, they are. Attachment retrieval is the most under-estimated cost in a ledger integration: QBO requires a second query per transaction unless you batch, and files are the largest thing you will sync. --- ## 15. Reports | | Xero | QBO | NetSuite | |---|---|---|---| | Endpoint | `/Reports/{ProfitAndLoss,BalanceSheet,TrialBalance,AgedReceivablesByContact,BankSummary,…}` | `/reports/{ProfitAndLoss,BalanceSheet,GeneralLedger,TrialBalance,AgedReceivables,…}` | Saved searches, SuiteAnalytics, SuiteQL | | Shape | `Rows[]` with `RowType: Header \| Section \| Row \| SummaryRow` | `Rows` / `Columns` nested, `ColData[]` | Rows you define | | Stable ids | Some rows carry `AccountID` in cells | `ColData.id` sometimes present | You control the query | | Comparatives | `periods`, `timeframe` params | `date_macro`, `summarize_column_by` | Query it | | Drill-down | Not linked | Not linked | Native | Report endpoints are presentation output, not data. They are the right tool for **tie-out** — proving your reconstructed P&L equals the ledger's own — and the wrong tool for populating an interactive statement component, because the rows have no reliable identity to drill from. The pattern that works: build statements from GL data (or entity mirrors), then run the report endpoint on a schedule and assert equality. Surface a variance if they disagree. That check is worth more to a controller than any chart on the page. See [statement semantics](/docs/domain/03-statement-semantics). --- ## 16. Mechanics: reading, writing, limits ### Reading and pagination | | Xero | QBO | NetSuite | |---|---|---|---| | Page mechanism | `page` + `pageSize` (newer endpoints); `Journals` uses `offset` | `STARTPOSITION` + `MAXRESULTS` in the query string | `limit` + `offset`, `hasMore` / `links` | | Page size | 100 default, up to 1000 on endpoints that support `pageSize` | Up to 1000 rows | Up to 1000 | | Filtering | `where=` expression, `order=`; string ops are limited | SQL-like `WHERE`, `ORDER BY`, no `JOIN`, no aggregate functions | SuiteQL: near-full SQL incl. joins and aggregates | | List returns | Full records | Full records | **REST: ids + links only.** Use SuiteQL for rows | | Delta sync | `If-Modified-Since` header (UTC) | `WHERE MetaData.LastUpdatedTime > '…'`, or `/cdc` | `lastmodifieddate` in SuiteQL | | Deletes visible? | Status becomes `DELETED`/`VOIDED` — still returned | `/cdc` reports deletes; queries do not | Hard deletes are **invisible** — reconcile by id set, or use `deletedrecord` | The NetSuite REST list behaviour deserves emphasis: `GET /record/v1/invoice` returns a page of `{id, links}` objects, not invoices. Fetching 5,000 invoices that way is 5,001 calls against a concurrency limit in the single digits. **Use SuiteQL for bulk reads.** This is not an optimisation; it is the difference between a sync that finishes and one that does not. Deletion visibility is the quiet data-integrity issue. Xero never truly deletes — it flips status, so a delta sync sees it. QBO surfaces deletes only through `/cdc` (with a limited lookback window), so a poll-only integration accumulates ghost records. NetSuite hard-deletes, and a deleted record simply stops appearing. Any full-refresh strategy must reconcile the id set, not just apply changes. ### Writing | | Xero | QBO | NetSuite | |---|---|---|---| | Update semantics | POST the object; supplied fields update, arrays replace wholesale | **Full update replaces the entity** unless `"sparse": true` | PATCH updates supplied fields | | Concurrency | Last write wins | `SyncToken` must match, else 400 | Record-level; scripts may reject | | Idempotency | `Idempotency-Key` header (supported on write endpoints) | **`requestid`** query parameter — unique per company, up to 50 characters (36 in a batch); a retry with the same id returns the original response instead of creating a duplicate | None — dedupe yourself | | Batch | Arrays of up to 50–60 objects per POST on most endpoints | `/batch`, up to 30 operations | Not on REST record; SuiteScript for bulk | | Partial failure | `SummarizeErrors=false` returns per-object validation results | Per-operation results in the batch response | Per-call | | Delete | Status change (`VOIDED`, `DELETED`) | `POST ?operation=delete` or `?operation=void` | `DELETE` (real) where permitted | **QBO's full-replace update is the most dangerous single behaviour in these three APIs.** POST an `Invoice` with only the fields you wanted to change and you will delete every line on it. Read the current record, mutate, write back the whole thing with the current `SyncToken` — or set `sparse: true` and accept its narrower semantics. Never construct a QBO update payload from scratch. ### Rate limits and concurrency *Approximate, and the first thing vendors change — verify against current documentation before relying on a number.* | | Xero | QBO | NetSuite | |---|---|---|---| | Per tenant/company | ~60 calls/minute, ~5,000/day (1,000/day on the Starter tier since 2 Mar 2026) | ~500 requests/minute per realm; batch: 40/min per realm and app | Governed by **concurrency**, not rate — base 5 (Standard), 15 (Premium), 20 (Enterprise/Ultimate) concurrent per account, plus 10 per SuiteCloud Plus licence | | Per app | ~10,000 calls/minute across tenants | Limits are per realm and app — no separate app-wide cap published | — | | Concurrency | ~5 concurrent per tenant | ~10 concurrent per realm and app | Shared across SOAP, REST and RESTlets; RESTlets additionally consume SuiteScript usage units | | On breach | 429 + `Retry-After` | 429 (`ThrottleExceeded`) — wait 60 seconds | 429 / `SSS_REQUEST_LIMIT_EXCEEDED` | The shapes differ enough to change your architecture. Xero's daily cap means a full history backfill of a large org is a **multi-day** job that must be resumable. NetSuite's concurrency governance means parallelism is capped hard and queueing must be centralised per account — two workers each "politely" using three connections will trip a five-connection limit. QBO is the most forgiving of the three. Build one token-bucket limiter per `(source, tenant)` and make every call pass through it, including retries. Retry 429 and 5xx with exponential backoff and jitter; never retry a 400. ### Webhooks | | Xero | QBO | NetSuite | |---|---|---|---| | Available | Invoices, Contacts, Credit notes (added March 2026), app Subscriptions | Most entities | **None** | | Payload | Ids + event type, **no data** | Ids + operation, **no data** | — | | Signature | HMAC-SHA256, `x-xero-signature` | HMAC-SHA256, `intuit-signature` | — | | Validation | Must return 2xx within seconds or the endpoint is disabled ("intent to receive") | Retries with backoff | — | Both are notifications, not data feeds: you receive "invoice X changed" and must fetch. Both require the signature check before you trust anything — and Xero will disable an endpoint that fails its validation handshake, so the verification path has to work before any business logic does. NetSuite has no webhooks. The substitutes are a User Event SuiteScript that calls out on save, or scheduled polling on `lastmodifieddate`. Most integrations poll, which makes freshness a per-source property your UI should be honest about: "Synced 4 minutes ago" is a real component, not decoration. ### Dates, numbers, currency - **Dates.** Xero JSON returns legacy `.NET` epoch strings (`"/Date(1749254400000+0000)/"`) on some endpoints alongside ISO-8601 on others — parse both. QBO uses ISO-8601 dates for transactions and full timestamps in `MetaData`. NetSuite dates are rendered **in the account's or user's timezone and preferred format**, which is the classic source of off-by-one-day errors at period boundaries. Normalise every date to a plain calendar date (no time, no zone) for transaction dates, and to UTC instants for audit timestamps. See [date handling in numerics](/docs/foundations/06-numerics). - **Money.** All three send JSON numbers. Parse to a decimal type immediately — never let a float touch a monetary value — and carry the currency code with it. - **FX.** Each transaction carries its own rate and each system computes realised/unrealised gains its own way. Do not recompute the base-currency amount from `amount × rate`; store the source's base amount when it provides one. NetSuite additionally has per-book and consolidated rates, so "the" base amount is not a single number. --- ## 17. The canonical model The model these components render. Every adapter produces this; nothing above this line appears in a component prop. ```ts type SourceSystem = "xero" | "qbo" | "netsuite"; /** Every normalised record keeps its provenance. Non-negotiable. */ type SourceRef = { system: SourceSystem; tenantId: string; // Xero tenantId | QBO realmId | NetSuite accountId entityType: string; // the source's own noun, verbatim id: string; /** The untouched source payload, for the "why is this number this number" question. */ raw?: unknown; fetchedAt: string; // ISO instant }; type Money = { amount: string; currency: string }; // decimal string, never number type Party = { id: string; name: string; roles: ("customer" | "supplier")[]; // Xero: both on one record. QBO: two records merged, or not. sources: SourceRef[]; // plural — a merged party has two taxNumber?: string; defaultCurrency?: string; }; type LineDimension = { key: string; optionId: string; label: string }; type DocumentLine = { id: string; kind: "account" | "item"; // NetSuite expense vs item sublist; QBO's two DetailTypes description?: string; quantity?: string; unitAmount?: Money; accountId?: string; // resolved even when the source only had an item itemId?: string; net: Money; tax: TaxBreakdown; // never a bare scalar — see §11 dimensions: LineDimension[]; // fanned down from the header where the source put it there source: SourceRef; }; type LedgerDocument = { id: string; /** The discriminator all three encode differently. Ours is explicit. */ type: "sales_invoice" | "purchase_bill" | "sales_credit" | "purchase_credit" | "cash_sale" | "cash_purchase" | "journal"; number?: string; party?: Party; issueDate: string; // calendar date dueDate?: string; currency: string; exchangeRate?: string; total: Money; totalBase?: Money; // from the source, not recomputed outstanding: Money; /** Ours, derived where the source has none (QBO). Never read from the source directly. */ status: "draft" | "awaiting_approval" | "open" | "part_paid" | "paid" | "voided" | "deleted"; sourceStatus?: string; // the source's own word, for the tooltip lines: DocumentLine[]; allocations: Allocation[]; periodId?: string; source: SourceRef; }; /** What the connected source can actually do. Components read this, not a hardcoded branch. */ type SourceCapabilities = { dimensions: Dimension[]; // 0, 1, 2, or many — see §12 lineTarget: "account" | "item" | "both"; canCreateTaxRates: boolean; // false for QBO US under AST hasApprovalWorkflow: boolean; // NetSuite only hasGlFeed: boolean; // Xero Journals (Advanced tier + approval), NetSuite SuiteQL; false for QBO hasAuditTrail: boolean; // NetSuite only periodLock: { date?: string; perSubsidiary: boolean }; writeable: boolean; }; ``` Two properties of this model are the whole point. **`SourceRef` on every record, including `raw`.** The question "why does this number look like that" is asked constantly in accounting software, and the only satisfying answer is the source payload. Components should be able to surface it in a detail drawer. **`SourceCapabilities` as data.** Every difference in this document that changes what a user can *do* resolves to a capability flag. A component that branches on `source.system === "xero"` will be wrong the moment a fourth ledger appears — and there is always a fourth ledger. --- ## 18. What this changes in the UI The differences that reach the surface, and what the component must do about them. | Difference | Consequence for the component | |---|---| | QBO has no invoice status | Status is derived. Show the derivation on hover; never claim a status the source didn't assert. | | Xero AR and AP share an endpoint | Every list is filtered by `type`. An unfiltered ledger table is a bug, not a view. | | QBO splits `Bill` from `Purchase` | Vendor spend aggregates several document types. Label the total precisely: "billed" ≠ "spent". | | Dimensions are 0–2 in Xero, *n* in NetSuite | Dimension controls render from metadata. No hardcoded Class/Department columns. | | No GL feed in QBO | Statement components declare their provenance: reconstructed or reported. Show the tie-out variance. | | Unapplied cash is a separate object in Xero | AR aging shows an explicit unapplied row and ties to the ledger's own aging. | | Approval exists only in NetSuite | Approval state lives in our system; the ledger's approval is mirrored where it exists, and the control is hidden where it does not. | | Audit trail exists only in NetSuite | Never claim "complete history". Say what we can see and when we last looked. | | Period lock is a date, not a period, in two of three | Edit affordances are disabled before the lock date, with the date stated in the disabled state. | | Xero daily call cap; NetSuite concurrency cap | Sync status is a first-class UI object: last synced, in progress, throttled, resumable. Never a spinner with no end. | | Items required on QBO sales lines | The line editor's item field is required-per-capability, never required-per-component. | | QBO full-replace writes | Edit flows read-then-write the whole record; optimistic UI must handle a `SyncToken` conflict as a real, user-visible outcome. | --- ## 19. Naming crosswalk One row per concept, for the moment you are reading someone else's field name and need to know what it is. | Concept | Xero | QBO | NetSuite | |---|---|---|---| | Sales invoice | `Invoice` (`ACCREC`) | `Invoice` | `invoice` | | Vendor bill | `Invoice` (`ACCPAY`) | `Bill` | `vendorbill` | | Cash sale | `BankTransaction` (`RECEIVE`) | `SalesReceipt` | `cashsale` | | Card/cash purchase | `BankTransaction` (`SPEND`) | `Purchase` | `check` / `expensereport` | | Customer credit | `CreditNote` (`ACCRECCREDIT`) | `CreditMemo` | `creditmemo` | | Vendor credit | `CreditNote` (`ACCPAYCREDIT`) | `VendorCredit` | `vendorcredit` | | Customer receipt | `Payment` | `Payment` | `customerpayment` | | Supplier payment | `Payment` | `BillPayment` | `vendorpayment` | | Unapplied receipt | `Overpayment` / `Prepayment` | `Payment.UnappliedAmt` | unapplied / `customerdeposit` | | Manual journal | `ManualJournal` | `JournalEntry` | `journalentry` | | GL posting | `Journal` (read-only) | — | `transactionaccountingline` | | Account | `Account` | `Account` | `account` | | Customer | `Contact` (`IsCustomer`) | `Customer` | `customer` | | Supplier | `Contact` (`IsSupplier`) | `Vendor` | `vendor` | | Product/service | `Item` | `Item` | `inventoryitem` et al. | | Tax rate | `TaxRate` | `TaxCode` / `TaxRate` | `taxcode` / `taxgroup` | | Dimension | `TrackingCategory` | `Class` / `Department` | `department` / `class` / `location` / `cseg_*` | | Entity/company | `Organisation` | `CompanyInfo` | `subsidiary` | | Period lock | `PeriodLockDate` | `BookCloseDate` | `accountingperiod` | | Attachment | `Attachment` | `Attachable` | `file` | | Bank feed | `BankTransaction` / `BankStatement` | `Purchase`/`Deposit` + banking feed | `transaction` + bank import | | Purchase order | `PurchaseOrder` | `PurchaseOrder` | `purchaseorder` | | Quote/estimate | `Quote` | `Estimate` | `estimate` | | Recurring | `RepeatingInvoice` | `RecurringTransaction` | recurring/memorised txn | --- ## 20. Using this document **Volatile facts are marked as approximate.** Rate limits, page sizes, minor versions, and QBO's automated-sales-tax behaviour change without much notice. Treat every number in §16 as "check this", and treat the *shapes* — which are stable — as the durable content. **When a difference is discovered that is not in here, it belongs in here.** The failure mode this document prevents is a per-integration workaround that nobody else learns about. Every row above was, at some point, somebody's surprise. **Adapters own the difference; components never see it.** If a component prop needs to know which ledger it is talking to, either the canonical model in §17 is missing a field or `SourceCapabilities` is missing a flag. Fix it there. Related: [the accounting model](/docs/domain/01-accounting-model) · [periods and close](/docs/domain/02-periods-and-close) · [order to cash](/docs/domain/05-order-to-cash) · [procure to pay](/docs/domain/06-procure-to-pay) · [multi-entity and FX](/docs/domain/09-multi-entity-and-fx) · [controls and audit](/docs/domain/08-controls-and-audit) · [lexicon](/docs/domain/11-lexicon) --- # US GAAP, and where these rules come from This system states a lot of accounting rules. Revenue is earned on delivery. Depreciation allocates cost rather than measuring value. A statement of cash flows has three sections and reconciles to the change in cash. A posted entry is reversed, never edited. Every one of those has an authority behind it, and a component that encodes a rule without naming its source is asking to be trusted on nothing. So this document is the map: what is **required** by US GAAP, what is **presentation convention**, and what is simply **the mechanics of bookkeeping** — three different kinds of claim that get flattened into "best practice" everywhere else. The machine-readable copy of this table lives in `src/learn/sources.ts`. It is rendered on every lesson of the course at `/learn`, on every glossary term page, and asserted against this document by `tests/course.test.ts` — so a citation cannot be added in one place and forgotten in the other. --- ## The hierarchy **The Codification is authoritative GAAP.** There is one source, and everything in it carries the same weight: > This Statement establishes the Codification as the source of authoritative GAAP recognized by the > FASB to be applied by nongovernmental entities. Rules and interpretive releases of the SEC under > federal securities laws are also sources of authoritative GAAP for SEC registrants. All guidance > contained in the Codification carries an equal level of authority. > > — [FASB Statement No. 168, ¶6 (June 2009)](https://storage.fasb.org/fas168.pdf) **Concepts Statements are not GAAP.** They define what an asset *is*; they do not tell you what to do. The Conceptual Framework says so about itself: a Concepts Statement does not "amend, modify, or interpret the Accounting Standards Codification." We quote CON 8 for the definitions of the elements because they are the clearest statement of what those words mean — not as a requirement. **This is US GAAP only.** An entity reporting under IFRS answers to different numbers (IAS 1, IAS 7, IFRS 15) and in places to different rules. Nothing in this system should be read as saying the two are interchangeable. **Access.** The Codification is free to read at [asc.fasb.org](https://asc.fasb.org/) after registration. Where a quotation below could not be pulled from the Codification itself, the "read in" column names the openly readable document the text was verified in, and the date it was checked. --- ## What is required, what is convention | Claim this system makes | Kind | Authority | |---|---|---| | Assets, liabilities, equity, revenue and expenses have precise definitions | Concept | CON 8, Ch. 4, ¶E16, ¶E37, ¶E61, ¶E80–E81 | | Revenue is recognized when control transfers, not when cash arrives | **Required** | ASC 606-10-05-3, -05-4; 606-10-25-23, -25-30 | | A cost paid ahead of consumption is an asset until consumed | **Required** | ASC 340-10 | | Cash taken before delivery is a liability, not revenue | **Required** | ASC 606-10-45 (contract liability) | | A cost incurred and unbilled is accrued into the period | **Required** (accrual basis) | CON 8 accrual concept, ASC 405 | | Depreciation allocates cost over useful life; it is not a valuation | **Required** | ASC 360-10-35-4 | | Finite-lived intangibles amortize over their useful life; prepaid costs are expensed as consumed | **Required** | ASC 350-30-35; ASC 340-10 | | A balance sheet presents position; a P&L presents a period | **Required** | ASC 210, ASC 220, ASC 205 | | Cash flows are classified operating / investing / financing | **Required** | ASC 230-10-45-10 | | The indirect method separately reports the change in receivables, inventory and payables | **Required** | ASC 230-10-45-28, -45-29 | | The statement reconciles opening to closing cash | **Required** | ASC 230-10-45-24 | | An error is corrected and disclosed, not quietly overwritten | **Required** | ASC 250-10-45 | | Materiality is not a fixed percentage | SEC staff guidance (registrants) | SEC SAB No. 99 | | A registrant's balance sheet uses prescribed captions | **Required** (SEC registrants) | Reg S-X, 17 CFR 210.5-02 | | A published non-GAAP measure is reconciled to the GAAP measure | **Required** (SEC registrants) | Reg G; Item 10(e) of Reg S-K | | Negatives are parenthesised; subtotals take a single rule and totals a double rule | Convention | Presentation practice — not codified | | Debit is the left column and credit the right | Mechanics | Bookkeeping convention — not codified | | Debit and credit are two columns rather than one signed number | Mechanics | Bookkeeping convention — not codified | | A chart of accounts has 1000s for assets, 2000s for liabilities | Convention | Not prescribed by GAAP | | Nearly every lease puts a right-of-use asset and a lease liability on the balance sheet | **Required** | ASC 842-20-25-1 (short-term lease election: 842-20-25-2) | | Receivables carry an allowance for lifetime expected credit losses from day one | **Required** | ASC 326-20 (CECL) | | Deferred taxes are measured at enacted rates and reduced by a valuation allowance when realization is not more likely than not | **Required** | ASC 740-10-30 | | Events after the balance sheet date are recognized only if the condition existed at that date | **Required** | ASC 855-10-25 | | A parent consolidates what it controls and eliminates intercompany balances | **Required** | ASC 810-10-45-1 | | Monetary items are remeasured through earnings; translation differences go to OCI | **Required** | ASC 830-20-35-1; ASC 830-30-45 | | Segregation of duties, approval chains, evidence, audit trail | Internal control | SOX §404 — SEC Release 33-8238 | The distinction in the middle column is the point of the table. A component that renders a negative in parentheses is following a convention, and someone is entitled to disagree with it. A component that renders a customer deposit as revenue is wrong, and no amount of taste makes it right. --- ## Recent standards this system tracks Accounting Standards Updates amend the Codification; until an ASU is effective for an entity, the superseded text still governs it. A component that encodes a rule from an ASU states which version it follows. Effective dates below are for calendar-year entities unless noted, and are checked against FASB's ASU pages and the large-firm summaries as of October 2026. | ASU | Topic | What changes for the UI | Effective | |---|---|---|---| | 2016-02 | ASC 842 Leases | ROU asset and lease liability on the balance sheet; maturity analysis | In effect for all entities | | 2016-13 | ASC 326 Credit losses (CECL) | Lifetime expected-loss allowance replaces incurred loss | In effect for all entities | | 2023-07 | ASC 280 Segments | Significant segment expenses provided to the CODM, annually and interim | PBEs: fiscal years beginning after 15 Dec 2023; interim after 15 Dec 2024 | | 2023-08 | ASC 350-60 Crypto assets | In-scope crypto measured at fair value through net income, presented separately | Fiscal years beginning after 15 Dec 2024 | | 2023-09 | ASC 740 Income taxes | Rate reconciliation by prescribed category; income taxes paid by jurisdiction | PBEs: annual periods beginning after 15 Dec 2024; others after 15 Dec 2025 | | 2024-03 / 2025-01 | ASC 220-40 Expense disaggregation | Footnote splitting expense captions into inventory purchases, employee compensation, D&A and other | PBEs: annual periods beginning after 15 Dec 2026; interim after 15 Dec 2027 | | 2025-05 | ASC 326 Credit losses | Practical expedient for current AR and contract assets; private-entity election to consider subsequent collections | Annual periods beginning after 15 Dec 2025 | | 2025-06 | ASC 350-40 Internal-use software | Project stages removed; capitalize once funding is committed and completion is probable | Annual periods beginning after 15 Dec 2027 | Outside GAAP but on the same screens: from 2026 the Form 1099-NEC and 1099-MISC reporting threshold rises from $600 to $2,000 (One Big Beautiful Bill Act, July 2025), and under IFRS, IFRS 18 replaces IAS 1 for annual periods beginning on or after 1 January 2027. --- ## The sources, and what we read Every quotation in this system was read in the document named here, on the date named here. Nothing in this table was recalled from memory. | Cite | What it says | Read in | Access | |---|---|---|---| | [FASB Statement No. 168, ¶6](https://storage.fasb.org/fas168.pdf) | The Codification is the source of authoritative GAAP | Same document (PDF) | Free | | [ASC 105-10](https://asc.fasb.org/105/tableOfContent) | Generally Accepted Accounting Principles — Overall | Codification | Registration | | [CON 8, Ch. 4, ¶E16](https://www.fasb.org/concepts-statements) | "An asset is a present right of an entity to an economic benefit." | Concepts Statement No. 8, Chapter 4 (Dec 2021), PDF | Free | | [CON 8, Ch. 4, ¶E37](https://www.fasb.org/concepts-statements) | "A liability is a present obligation of an entity to transfer an economic benefit." | As above | Free | | [CON 8, Ch. 4, ¶E61](https://www.fasb.org/concepts-statements) | Equity is "the residual interest in the assets … after deducting its liabilities" | As above | Free | | [CON 8, Ch. 4, ¶E80–E81](https://www.fasb.org/concepts-statements) | Definitions of revenues and expenses | As above | Free | | [CON 8, Ch. 4, App. A](https://www.fasb.org/concepts-statements) | What accrual accounting attempts to do (carried forward from CON 6 ¶139) | As above | Free | | [ASC 606-10-05-3, -05-4](https://asc.fasb.org/606/tableOfContent) | The revenue core principle and the five steps | PwC Viewpoint, ASC 606 five-step model | Registration | | [ASC 230-10-45-10](https://asc.fasb.org/230/tableOfContent) | Cash flows are classified as operating, investing or financing | EY FRD, *Statement of cash flows (ASC 230)*, July 2026 | Registration | | [ASC 230-10-45-28, -45-29](https://asc.fasb.org/230/tableOfContent) | The indirect method, and its minimum reconciling items | As above | Registration | | [ASC 230-10-45-24](https://asc.fasb.org/230/tableOfContent) | The statement reconciles beginning and ending cash | As above | Registration | | [ASC 360-10-35-4](https://asc.fasb.org/360/tableOfContent) | Depreciation is "a process of allocation, not of valuation" | PwC Viewpoint, PP&E depreciation overview | Registration | | [SEC SAB No. 99](https://www.sec.gov/interps/account/sab99.htm) | "Exclusive reliance on … any percentage or numerical threshold has no basis in the accounting literature or the law." | Same document | Free | | [Reg S-X, 17 CFR 210.5-02](https://www.ecfr.gov/current/title-17/part-210/section-210.5-02) | The balance sheet captions a registrant must present | 17 CFR 210.5-02, 2024 edition (govinfo PDF) | Free | | [Reg G — SEC Release 33-8176](https://www.sec.gov/rule-release/33-8176) | A non-GAAP measure must be reconciled to the GAAP measure | 17 CFR 244.100(a)(2), Cornell LII | Free | | [SEC Release 33-8238](https://www.sec.gov/rule-release/33-8238) | Management's report on internal control over financial reporting | Same document | Free | | [IRS Publication 538](https://www.irs.gov/publications/p538) | The cash and accrual methods, for tax | Same document | Free | Topic-level pointers used without quotation, because the claim is the topic rather than a paragraph: ASC [205](https://asc.fasb.org/205/tableOfContent), [210](https://asc.fasb.org/210/tableOfContent), [220](https://asc.fasb.org/220/tableOfContent), [235](https://asc.fasb.org/235/tableOfContent), [250](https://asc.fasb.org/250/tableOfContent), [270](https://asc.fasb.org/270/tableOfContent), [280](https://asc.fasb.org/280/tableOfContent), [310](https://asc.fasb.org/310/tableOfContent), [323](https://asc.fasb.org/323/tableOfContent), [326](https://asc.fasb.org/326/tableOfContent), [330](https://asc.fasb.org/330/tableOfContent), [340](https://asc.fasb.org/340/tableOfContent), [350](https://asc.fasb.org/350/tableOfContent), [360](https://asc.fasb.org/360/tableOfContent), [405](https://asc.fasb.org/405/tableOfContent), [450](https://asc.fasb.org/450/tableOfContent), [505](https://asc.fasb.org/505/tableOfContent), [740](https://asc.fasb.org/740/tableOfContent), [810](https://asc.fasb.org/810/tableOfContent), [830](https://asc.fasb.org/830/tableOfContent), [842](https://asc.fasb.org/842/tableOfContent), [855](https://asc.fasb.org/855/tableOfContent). --- ## Metrics are not GAAP Everything in [Metric definitions](10-metrics.md) — ARR, EBITDA, net burn, runway, magic number — is a **non-GAAP financial measure**. That is not a criticism; they are often the most useful numbers a company has. It does have a consequence, and it is a legal one for anyone who files: > A reconciliation (by schedule or other clearly understandable method), which shall be quantitative > for historical non-GAAP measures presented … > > — [Regulation G, SEC Release 33-8176 (January 2003)](https://www.sec.gov/rule-release/33-8176) So a metric component in this system states its formula, states its inputs, and never presents itself with the visual authority of a statement line. Every metric term in the glossary carries the Reg G citation for the same reason. --- ## What this document is not It is not accounting advice, and it is not a substitute for the standard. The Codification runs to thousands of pages of judgment that no component library can encode, and the moment a real question appears — a contract with variable consideration, a lease with a renewal option, a functional currency that is not obvious — the answer is in the standard and in the judgment of a qualified accountant, not in a React component. What this system can do, and does, is refuse to render something that is wrong under any reading: revenue that is really a deposit, a negative rendered as a minus in a statement, a null balance shown as `$0.00`, a posted entry with an edit affordance. Those are the errors that survive review because they look finished. --- ## See also - [Accounting model](01-accounting-model.md) — double-entry, normal balances, the sign problem - [Statement semantics](03-statement-semantics.md) — what each statement asserts and how they articulate - [Schedules & accruals](04-schedules-and-accruals.md) — the schedules behind the adjusting entries - [Lexicon](11-lexicon.md) — every term, with its authority - [Metric definitions](10-metrics.md) — the non-GAAP layer, with its formulas --- # Motion principles Lopifi's motion doctrine is Emil Kowalski's animation framework, plus the performance rules from `ui-skills`, plus two laws specific to money. The default is **no animation**. Every animation in this system had to argue its way in. --- ## The decision framework Answer in order. Stop at the first "no". ### 1. Should this animate at all? **Frequency governs everything.** | How often the user sees it | Decision | |---|---| | 100+ times/day — keyboard shortcuts, command palette, row hover, tab switch | **No animation. Ever.** | | Tens of times/day — dropdowns, filters, tooltips, expanding a row | Minimal. ≤ 160ms, opacity-dominant. | | Occasional — dialogs, sheets, toasts, drawers | Standard animation. | | Rare / first-time — onboarding, payment success, reconciliation match, card flip | Delight is permitted. | In a finance product the top row is enormous. An accountant switches tabs, expands rows and opens filters hundreds of times in a close. **Every one of those is motionless in Lopifi.** > **Never animate a keyboard-initiated action.** Raycast has no open/close animation and that's optimal. Our command palette opens instantly. So does `Cmd+K`, `Esc`, arrow-key navigation, and every shortcut in the product. ### 2. What is the purpose? Must be one of these five. "It looks nice" is not on the list. | Purpose | Example in Lopifi | |---|---| | **Spatial consistency** | A sheet enters from the bottom and leaves to the bottom, so swipe-to-dismiss feels obvious | | **State indication** | The swap arrow rotates 180° to show the direction inverted | | **Explanation** | The reconciliation match animates two rows together, showing *what matched with what* | | **Feedback** | A button scales to 0.97 on press, confirming the interface heard you | | **Preventing a jarring change** | A toast appearing instantly with no transition reads as a glitch | ### 3. What easing? ``` Entering or exiting? → ease-out (fast start = responsive) Moving/morphing on screen? → ease-in-out (natural accel/decel) Hover or color change? → ease (symmetric, unremarkable) Constant motion? → linear (progress, marquee, hold) Default → ease-out ``` > **Never `ease-in` on a UI element.** It starts slow, which delays movement at the exact moment the user is watching most closely. `ease-in` at 300ms *feels* slower than `ease-out` at 300ms. The only legitimate use is an exit that's leaving the viewport entirely, and even then `ease-out` is usually better. **Use custom curves.** CSS's built-in easings are too weak to read as intentional. Tokens in [motion tokens](02-tokens.md). ### 4. How fast? | Element | Duration | |---|---| | Button press feedback | 100–160ms | | Tooltip, small popover | 125–200ms | | Dropdown, select, menu | 150–250ms | | Modal, drawer, sheet | 200–500ms | | Explanatory / marketing | Longer is fine | > **UI animation stays under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster spinner makes an app feel like it loads faster with identical load times. Perceived performance *is* performance. **Exit is faster than enter.** Enter × 0.7, floor 100ms. The user has already decided to dismiss; don't make them wait for the decision they made. --- ## The two money laws Not from any general motion system. These are ours. ### Law 1 — Never animate a value the user might read as truth > **A number does not count up, roll, tick through intermediate values, or interpolate.** A balance animating from `$0` to `$248,190.24` renders roughly forty *false* values on its way. It's charming exactly once and dangerous forever — a user who glances mid-animation reads a wrong number, and in a financial product that's a material error, not a cosmetic one. It also breaks screen readers, `Cmd+F`, and copy-paste. **What to do instead when a value changes:** | Situation | Treatment | |---|---| | Live update (streaming balance, rate tick) | **The Bloomberg flash** — the cell *background* flashes `--positive-flash` / `--negative-flash` and decays to transparent over 640ms `linear`. The digits swap instantly. | | Value changed after a user action | **Crossfade**, 140ms, with `filter: blur(2px)` at the midpoint to mask the swap. Old value out, new value in. Never a scroll or roll. | | Value changed on filter/period change | No animation. The whole dataset changed; animating implies continuity that isn't there. | | First paint | No animation on the value. The *container* may fade in. | The single exception: **a marketing or onboarding surface, where the number is illustrative and labeled as an example.** Not in the product. ### Law 2 — Data does not re-animate > **A chart animates on first paint. It does not animate when the data changes.** If a user changes a filter and the bars grow from zero again, they cannot compare the old and new states — and comparison is the entire reason they changed the filter. Re-animation actively destroys the task. ``` First mount → draw in (once), 400ms, ease-out, stagger 30ms Data change → instant swap, or a 120ms opacity crossfade Scroll into view → draw in once (IntersectionObserver, { once: true }) Resize → instant ``` Same rule for tables: rows don't animate on sort, filter, or pagination. They animate on **insert and delete only**, because there the motion answers "what changed?". --- ## Interruptibility > **Use CSS transitions, not keyframes, for anything that can be re-triggered.** Transitions retarget from their current position. Keyframes restart from zero. For toasts arriving rapidly, a row hover flickering under a moving cursor, or a sheet flicked mid-animation, transitions are smoother and keyframes visibly stutter. Springs go further: they preserve **velocity** across interruption. That's why anything a finger can grab — sheets, drawers, swipe-to-dismiss, drag-to-reorder — uses a spring, not a duration. Use keyframes only for genuinely one-shot, non-interruptible things: a checkmark stroke draw, a skeleton pulse, an indeterminate progress bar. --- ## Performance rules From `ui-skills/fixing-motion-performance`, and they're absolute. ### Never patterns - **Never interleave layout reads and writes in a frame.** Batch all reads, then all writes. - **Never animate layout properties** — `width`, `height`, `top`, `left`, `margin`, `padding`. Use `transform`. - **Never drive animation from `scroll` events or `scrollY`.** Use `IntersectionObserver` for visibility, and scroll/view timelines for scroll-linked motion. - **Never a `requestAnimationFrame` loop without a stop condition.** - **Never mix animation systems** that each measure or mutate layout on the same element. ### Compositor only Animate `transform` and `opacity`. They skip layout and paint entirely. Paint properties (`background`, `color`, `border-color`) are permitted **only on small, isolated surfaces** — a 14px label's color, a 40px row's background, a button's border. Never on a card, a table, or a full-width container. ### Layout-like effects When something must appear to change size, use **FLIP**: measure first position, apply the final state, measure last position, apply an inverting `transform`, then transition it to zero. One read batch, one write batch, compositor-only animation. ### Blur - ≤ 8px, always. - One-shot only. **Never animate blur continuously.** - Never on a large surface. `backdrop-filter` on a full-screen scrim is forbidden — see [elevation](../foundations/04-elevation-borders-shadows.md#backdrops). ### `will-change` Add it when an interaction *starts*, remove it when it ends. A permanent `will-change` holds a compositor layer forever and costs memory on every scroll. Never set it in a static stylesheet. ### CSS variables **Never animate an inherited CSS variable.** Changing `--swipe-amount` on a container recalculates styles for every descendant — in a drawer with forty rows that's a visible stall. Set `transform` directly on the element that moves. ### Motion library caveat Motion's shorthand props (`x`, `y`, `scale`) run on the main thread via `requestAnimationFrame` and drop frames when the browser is busy — which, during a data-heavy page load, it always is. For anything that must stay smooth under load, use the full transform string: ```tsx // composited // main thread ``` Better still: **use CSS for anything predetermined.** CSS animations run off the main thread and stay smooth while the page is loading. Reserve JS animation for what's genuinely dynamic and interruptible. ### Off-screen Pause every looping animation when off-screen (`IntersectionObserver`) and when the tab is hidden (`document.visibilityState`). Applies to presence pulses, indeterminate bars, and live tickers. Also pause toast auto-dismiss timers on tab hide — a toast that expires while the user is in another tab never existed. --- ## Blur as a transition mask {#blur-as-a-transition-mask} When a crossfade between two states looks wrong despite trying every easing and duration, the problem is that you see **two distinct objects** overlapping mid-fade. A `filter: blur(2px)` at the midpoint blends them, and the eye reads one thing transforming rather than two things swapping. Use it for: a changed monetary value (Law 1), a button's label morphing between states, a status pill changing state, an icon swapping. Keep it under 20px — heavy blur is expensive, especially in Safari. Our uses are all 2px. --- ## Reduced motion `prefers-reduced-motion: reduce` means **fewer and gentler**, not zero. | Keep | Remove | |---|---| | Opacity transitions | Translation and movement | | Color transitions | Scale | | Instant state changes | Springs and bounce | | The value flash (it's a color change, and it's informational) | Parallax, stagger | | Focus rings | 3D rotation, the card flip | ```css @media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } /* then selectively restore informational transitions */ [data-motion-safe="opacity"] { transition-duration: 160ms !important; } } ``` The nuance the blanket reset misses: an element that *appears* with no transition at all can be more disorienting than one that fades. So the reset kills movement, and we opt specific opacity transitions back in. In React, `useReducedMotion()` and branch the values, don't branch the component: ```tsx const reduce = useReducedMotion(); const enterY = reduce ? 0 : 8; ``` --- ## Touch and hover Touch devices fire `:hover` on tap, so hover animations produce a stuck state after a tap. Gate every hover effect: ```css @media (hover: hover) and (pointer: fine) { .row:hover { background: var(--surface-hover); } } ``` And every hover-revealed affordance needs a non-hover equivalent — on touch, row actions live in a swipe or an overflow menu, never behind hover alone. --- ## Reviewing motion - **Slow it down.** Run at 4× duration and look for: two states visibly overlapping in a crossfade, wrong `transform-origin`, properties finishing at different times, abrupt starts. - **Step frame by frame** in the DevTools Animations panel to catch coordination bugs invisible at speed. - **Test on real hardware.** Gesture feel does not survive a simulator. Test the sheet on an actual phone, over the network, with the CPU busy. - **Throttle to 4× slowdown** and check that nothing jank-drops. If it does, it isn't compositor-only. - **Look at it the next day.** You will see imperfections with fresh eyes that were invisible while you were building it. --- # Motion tokens Every animation in Lopifi uses a token. Ad-hoc durations and inline cubic-béziers are a review failure — see [quality bar](../craft/03-quality-bar.md). --- ## Easing ```css /* Entrances, exits, and the system default. Strong ease-out — quintic. Punchy start, long settle. */ --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* Movement and morphing on screen. Symmetric, strong. */ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* Sheets and drawers. The Ionic/iOS curve — the one that makes a drawer feel like it has weight. */ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* Hover, color, small unremarkable state changes. */ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* Constant motion: progress, hold-to-confirm, marquee, value flash decay. */ --ease-linear: linear; ``` **There is no `--ease-in`.** Deliberately absent from the token file so it can't be reached for. If a curve is needed that isn't here, it's a design conversation, not a CSS decision. The built-in CSS keywords (`ease`, `ease-out`, `ease-in-out`) are too weak to read as intentional. `cubic-bezier(0.23, 1, 0.32, 1)` moves about two-thirds of the distance in the first 20% of the time — that's what makes it feel like the interface responded rather than played an animation. --- ## Duration ```css --duration-0: 0ms; /* keyboard actions, high-frequency interactions */ --duration-1: 100ms; /* press feedback, instant hover */ --duration-2: 140ms; /* hover, tooltip, color, small crossfade */ --duration-3: 180ms; /* dropdown, popover, menu, expand */ --duration-4: 240ms; /* dialog enter, toast enter */ --duration-5: 320ms; /* sheet, drawer, page-level transition */ --duration-6: 400ms; /* chart draw-in (first paint only) */ --duration-flash: 640ms; /* live value tick decay */ --duration-hold: 1500ms; /* hold-to-confirm */ ``` ### Exit durations **Exit = enter × 0.7, floored at 100ms.** Not a separate token set — computed, and encoded in each component's spec: | Enter | Exit | |---|---| | 140 | 100 | | 180 | 125 | | 240 | 165 | | 320 | 225 | The asymmetry has a reason: entering, the user is discovering the element and the motion explains where it came from. Exiting, they've already decided and are waiting on you. ### The general asymmetry principle **Slow where the user is deciding, fast where the system is responding.** Hold-to-confirm presses over 1500ms `linear` and releases in 200ms `ease-out`. A drag has no duration at all (it tracks the finger); the release springs home fast. --- ## Springs For anything a pointer can grab. Apple's `duration`/`bounce` parameterization — easier to reason about than mass/stiffness/damping, and it maps cleanly onto Motion's spring API. ```ts export const spring = { /** Default. No overshoot. Sheets, panels, layout shifts. */ snap: { type: "spring", duration: 0.35, bounce: 0 }, /** Gesture release. Sheets, drawers, swipe-to-dismiss. Barely-there settle. */ gesture: { type: "spring", duration: 0.50, bounce: 0.18 }, /** The one playful spring. Swap-direction flip, card flip. Nothing else. */ playful: { type: "spring", duration: 0.45, bounce: 0.22 }, /** Decorative pointer tracking — card tilt, magnetic hover. Never functional. */ track: { type: "spring", stiffness: 150, damping: 20, mass: 0.6 }, } as const; ``` > **`bounce` never exceeds 0.25.** Financial UI does not wobble. Overshoot on a balance card reads as unserious, and on a value it reads as unstable. **Springs only where interruption is real.** A dialog that can't be grabbed mid-flight gains nothing from a spring and costs main-thread work — use a duration and a curve. --- ## Stagger ```css --stagger: 40ms; --stagger-max-total: 240ms; /* = 6 items */ ``` Rules: - **40ms between items.** Under 30 reads as simultaneous; over 80 reads as slow. - **Cap the total at 240ms.** Items 7+ share item 6's delay. A 40-row table staggering at 40ms takes 1.6 seconds to finish, which is unusable — and this cap is the fix nobody implements. - **Stagger is decorative and never blocks interaction.** Row 30 must be clickable while rows 1–6 are still animating. - **Only on first paint of a list.** Never on sort, filter, pagination, or re-render (Law 2). - **Removed entirely under reduced motion.** ```ts const delay = Math.min(index * 40, 240); ``` --- ## Transform values Small. Consistently small. This is what separates a considered system from a generic one — the values below never change, anywhere in the library. ```css --enter-y: 8px; /* vertical entrance offset. Not 20px. */ --enter-y-sheet: 100%; /* percentage: element's own height */ --enter-scale: 0.97; /* entrance scale. NEVER 0. */ --press-scale: 0.97; /* :active feedback */ --exit-scale: 0.98; /* exits scale less than they enter */ --blur-mask: 2px; /* crossfade masking */ --lift-y: -1px; /* hover lift on an interactive card. One pixel. */ ``` ### Never animate from `scale(0)` Nothing in the physical world vanishes to nothing and reappears. `scale(0)` looks like the element came from a singularity. Start at `0.95–0.97` with `opacity: 0` — even a barely-visible initial scale makes the entrance read as natural. ### Percentages over pixels for full-element movement `translateY(100%)` moves an element by its own height regardless of its actual size. That's how sheets hide off-screen and how toasts enter — it adapts to content and never needs a magic number. ### 8px, not 20px The most common AI-generated motion tell is a 20–40px entrance offset. Real interface motion is small. 8px is enough to read as "arrived from below" without looking like the element traveled. --- ## Transform origin ```css /* Popovers, dropdowns, menus, tooltips: scale from the TRIGGER */ .popover { transform-origin: var(--transform-origin); } /* Base UI */ .popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Radix */ /* Dialogs: centered — they are not anchored to a trigger */ .dialog { transform-origin: center; } /* Sheets: the edge they enter from */ .sheet-bottom { transform-origin: bottom; } ``` Getting this right on a popover is invisible individually and unmistakable in aggregate — a menu that scales from its button feels attached to it; one that scales from its own center feels like it landed there by accident. --- ## Z-index scale Fixed. No arbitrary values, ever. ```css --z-base: 0; --z-raised: 10; /* hover cards, elevated rows */ --z-sticky: 20; /* sticky table headers, sticky totals */ --z-header: 30; /* app chrome */ --z-dropdown: 40; /* menus, selects, comboboxes */ --z-overlay: 50; /* scrim */ --z-dialog: 60; /* dialogs, sheets */ --z-popover: 70; /* popovers over dialogs */ --z-toast: 80; --z-tooltip: 90; --z-command: 100; /* command palette — above everything */ ``` --- ## Tailwind mapping ```js // tailwind.config.ts theme: { extend: { transitionTimingFunction: { out: "cubic-bezier(0.23, 1, 0.32, 1)", "in-out": "cubic-bezier(0.77, 0, 0.175, 1)", drawer: "cubic-bezier(0.32, 0.72, 0, 1)", standard: "cubic-bezier(0.4, 0, 0.2, 1)", }, transitionDuration: { 1: "100ms", 2: "140ms", 3: "180ms", 4: "240ms", 5: "320ms", 6: "400ms", }, }, } ``` Usage: `transition-transform duration-2 ease-out`. **Never `transition: all`.** It animates properties you didn't intend (including layout properties added later by someone else) and it's the single most common source of accidental jank. Always name the properties. --- ## The complete default transition set Copy-paste starting points. Every component's motion spec is a deviation from these, and must say why. ```css /* Press feedback — every pressable element */ .pressable { transition: transform var(--duration-1) var(--ease-out); } @media (hover: hover) and (pointer: fine) { .pressable:hover { /* hover state */ } } .pressable:active { transform: scale(var(--press-scale)); } /* Popover / dropdown / menu */ .popover { transition: opacity var(--duration-3) var(--ease-out), transform var(--duration-3) var(--ease-out); transform-origin: var(--transform-origin); } .popover[data-starting-style], .popover[data-ending-style] { opacity: 0; transform: scale(var(--enter-scale)); } .popover[data-ending-style] { transition-duration: 125ms; } /* Tooltip — faster, and instant on subsequent hovers */ .tooltip { transition: opacity var(--duration-2) var(--ease-out), transform var(--duration-2) var(--ease-out); transform-origin: var(--transform-origin); } .tooltip[data-instant] { transition-duration: 0ms; } /* Dialog */ .dialog { transition: opacity var(--duration-4) var(--ease-out), transform var(--duration-4) var(--ease-out); transform-origin: center; } .dialog[data-starting-style] { opacity: 0; transform: scale(var(--enter-scale)); } .dialog[data-ending-style] { opacity: 0; transform: scale(var(--exit-scale)); transition-duration: 165ms; } /* Row hover — HIGH FREQUENCY. Color only, fast, no transform. */ .row { transition: background-color var(--duration-1) var(--ease-standard); } /* Value flash — the live tick */ @keyframes value-flash { from { background-color: var(--flash-color); } to { background-color: transparent; } } .value-flash { animation: value-flash var(--duration-flash) var(--ease-linear); } ``` Note `@starting-style` / `data-starting-style` rather than a `useEffect`-driven `mounted` flag — the modern CSS way to animate entry, no JS, no extra render. --- # Motion catalog Every animation in Lopifi, specified. Each entry states its **frequency tier** (which is what licenses it), its **purpose** (one of the five), and its exact values. If a component isn't listed here, it doesn't animate. --- ## Tier 0 — Motionless by law These are seen 100+ times a day. They change state **instantly**. This list is as important as everything below it. | Surface | What happens instead | |---|---| | Command palette open/close | Instant. No fade, no scale. | | Any `Cmd+K` / keyboard-shortcut action | Instant. | | Tab switch | Instant content swap. The indicator may slide (see below). | | Table row hover | `background-color` only, 100ms. No transform, no shadow, no lift. | | Table sort | Instant re-order. No FLIP, no stagger. | | Filter apply | Instant. | | Pagination | Instant. | | Expand/collapse a statement row | Instant. Hundreds of times per close — see below for the one concession. | | Checkbox / radio / switch toggle | Instant state; the switch *thumb* may translate 100ms. | | Focus move (Tab) | Instant ring. | | Number value change | Never interpolated. Ever. (Money Law 1) | **The statement-row concession:** expanding a P&L row is high-frequency, so the content appears instantly. The *chevron* rotates 90° over 140ms, because that's a 12px paint on a tiny surface and it confirms the toggle registered. The rows themselves do not slide, grow, or stagger. --- ## Tier 1 — Feedback (every interaction) ### Press ``` transform: scale(0.97) 100ms --ease-out ``` Every pressable element: buttons, cards that navigate, list rows that open, chips, icon buttons. This is the single highest-value animation in the system — it makes the interface feel like it's listening. Scale, not opacity: `scale()` also scales children, so the icon and label compress with the button, which is what a physical press does. ### Hover ``` background-color / border-color: 140ms --ease-standard gated: @media (hover: hover) and (pointer: fine) ``` Never a transform on hover in a data surface. On an interactive **card** (not a row), a `translateY(-1px)` lift plus `--elevation-1` is permitted — one pixel, and only on cards a user clicks occasionally. ### Focus Instant. No transition on the focus ring. A ring that fades in is a ring that isn't there when a fast keyboard user needs it. ### Disabled → enabled ``` opacity: 140ms --ease-standard ``` Common in payment forms as validation completes. Opacity only; never animate the color. --- ## Tier 2 — Overlays Every exit below is a claim about what happens when the element leaves. In React that claim is false by default — see [exits that actually run](#exits). ### Dropdown / menu / select / combobox ``` enter opacity 0→1, scale 0.97→1 180ms --ease-out exit opacity 1→0, scale 1→0.97 125ms --ease-out origin var(--transform-origin) ← from the trigger ``` ### Popover Same as dropdown. Origin-aware is mandatory: a popover that scales from its own center looks detached from the button that opened it. ### Tooltip ``` enter opacity 0→1, scale 0.97→1 140ms --ease-out exit opacity 1→0 100ms delay 400ms before the first one ``` **Skip the delay and the animation on subsequent tooltips.** Once one tooltip in a group is open, hovering a neighbor opens it instantly (`data-instant` → `transition-duration: 0ms`). This makes an entire toolbar feel faster without defeating the delay's purpose, and it's a detail almost nothing ships. ### Dialog ``` scrim opacity 0→1 240ms --ease-out dialog opacity 0→1, scale 0.97→1 240ms --ease-out, origin center exit opacity, scale→0.98 165ms ``` Origin `center` — a dialog is not anchored to a trigger. (The one exception to the origin-aware rule.) ### Sheet / drawer (mobile primary) ``` enter translateY(100%) → 0 320ms --ease-drawer exit 0 → translateY(100%) 225ms --ease-drawer drag tracks the finger 1:1, no duration release spring.gesture { duration: 0.5, bounce: 0.18 } ``` Full gesture spec below. Use **Vaul** — it implements all of this correctly and there's nothing to improve. ### Toast ``` enter translateY(100%) → 0, opacity 0→1 400ms ease (CSS transition, not keyframes) exit translateY(100%), opacity 1→0 200ms stack each toast behind: scale −0.05, translateY −16px, opacity −0.2 swipe tracks the finger; dismiss at 45px OR velocity > 0.11 px/ms ``` Transitions, not keyframes — toasts arrive rapidly and keyframes restart from zero on interruption. Use **Sonner**; same reasoning. Enter and exit from the **same edge**, always, so swipe-to-dismiss is spatially obvious. **Pause the auto-dismiss timer when the tab is hidden.** A toast that expires while the user is in another tab never happened. --- ## Tier 3 — The ambient "life" layer This is where the interface stops feeling static. All of it is peripheral — nothing here is in the path of a task. ### The value flash — the tick {#value-flash} The most important animation in a live financial interface. ``` When a displayed value updates from live data What the CELL BACKGROUND flashes; the digits swap instantly Up background: --positive-flash → transparent Down background: --negative-flash → transparent Timing 640ms linear ``` Why linear: a decaying flash should read as a fading afterimage, and any ease makes it look like a deliberate animation rather than a trace. This solves the problem count-up animation creates: it draws the eye to a change **without ever rendering a false intermediate value**. Forty years of trading terminals is the evidence. Rules: - Never on first paint. Only on *update*. - Never on a filter/period change — the whole dataset changed, so flashing every cell is noise. - Throttle: max one flash per cell per 400ms. A cell updating at 10Hz should flash 2–3 times a second, not 10. - Under reduced motion: **keep it.** It's a color change carrying information, and it's the flash that tells the user something moved. - Respect `--data-value-palette="signal"` — the flash uses the same blue/amber mapping. ### Sparkline draw-in ``` When first paint, or scrolling into view (IntersectionObserver, { once: true }) What stroke-dashoffset: length → 0 Timing 600ms --ease-out ``` One-shot. Never on data change (Money Law 2). The area fill, if present, fades in over the last 200ms so the line leads and the fill follows. ### Chart draw-in ``` Line/area stroke-dashoffset, 400ms --ease-out Bar scaleY 0→1, transform-origin at the ZERO BASELINE, 400ms, stagger 30ms (cap 240ms) Donut stroke-dashoffset sweep, 400ms ``` `transform-origin` at the zero baseline, not the bottom — otherwise negative bars grow upward from the floor, which is wrong and briefly shows a value with the wrong sign. First paint only. On data change: instant, or a 120ms opacity crossfade. **"First paint" means the first time it is *seen*, not the first time it mounts.** These are two different moments on any page taller than the viewport, and reading them as one is how a chart ends up with a correct animation nobody ever watches: it plays four hundred milliseconds while sitting eight hundred pixels below the fold and is fully drawn by the time the reader scrolls to it. Gate on `useInViewOnce` — `IntersectionObserver`, `rootMargin: "0px 0px -15% 0px"`, disconnected on entry. Held at its start state until then, so the growth is the first thing the reader sees. `once` is not a convenience, it is the rule. A chart that redraws each time it re-enters the viewport is re-animating data, and re-animation destroys the one thing a reader scrolling back is trying to do: compare what is on screen now with what was there before. ### Chart hover readout ``` Crosshair + point transform: translate(), 100ms --ease-out (glides between points) Readout panel ledger-enter — opacity + 8px, 240ms --ease-out, on hover start ONLY Value replaced instantly. Never interpolated. (Money Law 1) Change revealed with the panel, not counted up ``` The readout carries the **change from the previous point** — the number a line chart draws and never states. Reading a slope off a line is exactly the estimate a financial interface should not ask anyone to make. It is *revealed*, never *counted*. A figure that ticks from 0 to 5.6% displays a few dozen wrong percentages on the way, and someone glancing mid-animation reads one of them. What animates is the panel arriving; the digits are correct in the first frame. The panel enters once per hover, not once per point: the element stays mounted as the pointer sweeps and only its contents change, so a pass across twelve months plays one entrance rather than twelve. The change is **not coloured** — a chart does not know its own polarity (rule 13), and painting every fall red reports a cost coming down as bad news. The arrow and the U+2212 carry the direction, and both survive greyscale (rule 12). A change is `undefined` at the first point and wherever the previous value is zero, and renders as nothing rather than as `0%` or `∞`. A change from nothing has no percentage, and inventing one is the same class of error as rendering a null balance as `$0.00` (rule 10). ### Presence / sync indicator ``` 2s, opacity 0.4 ↔ 1, --ease-standard, infinite Pauses off-screen (IntersectionObserver) and on tab hide ``` Opacity only. No scale, no glow, no ripple — a pulsing ring is a notification pattern and this is a status. ### Indeterminate sync bar A 2px hairline at the top of a syncing card. `translateX(-100% → 100%)` on a 40%-width segment, 1.2s `linear`, infinite. Pauses off-screen. **Only when there's no progress figure** — if you know the count, use a determinate bar and a number. ### Optimistic / pending row No animation. A pending row renders at full opacity with a 2px `--warning` left border and its pending status. It is never dimmed: a row at `opacity: 0.6` takes its secondary text under AA contrast (3.2:1), which is a reading cost on every pending row to buy one moment of motion. When it confirms, the border goes. ### Skeleton **Structural, not shimmering.** Grey blocks matching the exact shape and position of the content they replace, `--surface-sunken`, correct radii, correct heights. A very slow breathe is permitted: `opacity 0.6 ↔ 1, 1.6s ease-in-out infinite`. **No shimmer sweep** — a translating gradient across a full-page skeleton is a large-surface paint animation and it's the tell of a generic template. Skeletons must match the real layout precisely, or the content lands with a jump. That jump is worse than no skeleton. ### Scroll shadow ``` opacity 0→1 on the sticky header's shadow, 140ms --ease-standard driven by IntersectionObserver on a 1px sentinel — never a scroll listener ``` ### Number crossfade When a value changes as a *result of a user action* (not live data): ``` old: opacity 1→0, filter blur(0→2px) 140ms new: opacity 0→1, filter blur(2→0px) 140ms, starting at 70ms ``` The blur mask is what makes it read as one number transforming rather than two numbers swapping. Never a roll, scroll, or odometer. --- ## Tier 4 — The four sanctioned moments Rare, emotionally loaded, and the only places delight is licensed. ### 1. The card flip {#card-flip} `PaymentMethodCard` revealing the CVV. ``` container transform-style: preserve-3d; perspective: 1200px flip rotateY(0 → 180deg), spring.playful { duration: 0.45, bounce: 0.22 } faces backface-visibility: hidden; back face pre-rotated 180deg ``` Real 3D, not a crossfade. Seen a handful of times, high emotional weight, physical object metaphor. Worth every millisecond. Under reduced motion: instant swap, no rotation. ### 2. The pointer tilt `PaymentMethodCard` on hover, desktop only. ``` rotateX / rotateY ±6deg max, from pointer position spring.track { stiffness: 150, damping: 20, mass: 0.6 } specular a linear-gradient sheen tracking the pointer, opacity 0→0.12 gated @media (hover: hover) and (pointer: fine) reset springs home on pointer leave ``` Tying rotation *directly* to pointer position feels artificial because it lacks momentum — the spring is what makes it feel like a physical object. This is decorative, and it is only acceptable because the card is decorative. **The same effect on a KPI card would be forbidden.** ### 3. The payment success ``` 1. Button label crossfades to a spinner 140ms 2. On success: circle scales 0.9→1 240ms --ease-out 3. Checkmark stroke draws 320ms --ease-out, 80ms after the circle 4. The amount and destination fade up 8px 240ms --ease-out, staggered 40ms ``` Stroke draw via `stroke-dasharray`/`stroke-dashoffset`. No confetti, no bounce, no sound. The satisfaction comes from the check being *drawn* rather than appearing. Total sequence ≤ 700ms. Any longer and it's between the user and their next task. ### 4. The reconciliation match ``` 1. Both rows highlight --accent-subtle 140ms 2. FLIP: they translate toward each other 320ms --ease-in-out 3. Merge: one fades out, the other gains a check 200ms 4. The merged row settles into the matched list spring.snap ``` The most satisfying moment in accounting software, and it happens ~40 times in a close. Bulk match: stagger at 40ms, cap at 240ms, and run the whole sequence once for the group rather than per pair. --- ## Gestures {#gestures} ### Swipe to dismiss (toasts, sheets, mobile rows) ``` Track the finger 1:1 with transform: translate() Dismiss when: distance > 45px OR velocity > 0.11 px/ms Return when neither: spring.gesture ``` **Velocity, not just distance.** A quick flick must be enough. Requiring a long drag when the user clearly meant to dismiss is the most common gesture failure. ```ts const velocity = Math.abs(delta) / (Date.now() - dragStart); if (Math.abs(delta) >= 45 || velocity > 0.11) dismiss(); ``` ### Boundary damping Dragging past a natural limit (a sheet already at its top) applies increasing friction rather than a hard stop: ```ts const damped = Math.sign(overshoot) * Math.pow(Math.abs(overshoot), 0.7); ``` Things in the physical world slow before they stop. An invisible wall feels broken. ### Pointer capture `setPointerCapture()` the moment a drag starts, so it continues when the pointer leaves the element bounds. Without it, a fast drag "drops" the element mid-gesture. ### Multi-touch protection Ignore additional touch points once a drag is underway. Without this, switching fingers mid-drag teleports the element to the new position. ```ts function onPointerDown(e) { if (isDragging) return; /* … */ } ``` ### Never set a CSS variable per frame Setting `--swipe-amount` on a container recalculates styles for every descendant. Set `transform` directly on the moving element. ### Drag to reorder (table columns, dashboard widgets) ``` lift scale 1.02, --elevation-4, 140ms track 1:1, transform only others FLIP into their new positions, spring.snap drop spring.snap to final ``` Measure once at drag start. Never read layout during the drag. --- ## Layout transitions ### Expand / collapse ``` Statement rows instant (Tier 0). Chevron rotates 90°, 140ms. Accordion / disclosure grid-template-rows: 0fr → 1fr, 180ms --ease-out Card expanding to a panel FLIP + spring.snap ``` `grid-template-rows: 0fr → 1fr` is the only correct way to animate to auto height in CSS. It does trigger layout — acceptable on a single small disclosure, forbidden on a list of forty. ### List insert / delete ``` insert opacity 0→1, translateY(-8px)→0, 240ms --ease-out delete opacity 1→0, 165ms; then siblings FLIP into place, spring.snap ``` Insert and delete only. Not sort, not filter, not paginate. ### Route transitions None. A finance app's navigation is high-frequency and the content is dense; a page transition delays the data. View Transitions are explicitly out of scope for in-app navigation — they're for navigation-level changes in content-led sites, and they interact badly with interruption. --- ## Exits that actually run {#exits} Unmounting a React element removes it from the DOM immediately, and the exit transition never gets a frame. A dialog with a specified 165ms exit that simply vanishes is the most common way this catalog gets quietly ignored — and it reads as a bad spec rather than a bug. Adapted from `raphaelsalaja/mastering-animate-presence`. **The rule:** everything in Tier 2, plus [list delete](#list-insert--delete), must stay mounted through its exit. Base UI and Radix already do this — they hold the element with `data-state="closed"` until the transition ends, so a CSS-only exit works and needs nothing else. **Reach for `AnimatePresence` (`motion/react`) only for elements you mount yourself.** ```tsx {open && ( )} ``` `easeOut` is `--ease-out` as a coefficient array — `[0.23, 1, 0.32, 1]`. JS animation can't read a CSS variable, so the curve exists twice; keep both in the token file so they cannot drift. Four ways it breaks, all of them silent: - **A missing or index-derived `key`.** React reuses the node, and the exit is skipped for the item that actually left. Key on the row's stable id, never its position. - **The wrapper unmounting with its child.** `AnimatePresence` must live *above* the condition. If a parent route, tab panel or `Suspense` boundary tears it down in the same commit, there is nothing left to run the exit. - **`mode="wait"` on a list.** It serializes exits: with rapid toast dismissals or a bulk row delete it queues them, and the last one lands seconds late. Default `mode="sync"` for anything that can leave more than one at a time. - **`initial={false}` omitted on a list that renders with content.** Without it, every row animates in on first paint — which is precisely the *everything appearing at once* tell in the [deslop list](../craft/03-quality-bar.md#the-deslop-list). **Reduced motion still unmounts.** Set the exit duration to zero; never remove the wrapper. Removing it changes mount timing and produces a second set of bugs in the one configuration nobody tests. Route level is out of scope — see route transitions above. There is nothing to animate out. --- ## Hold to confirm {#hold-to-confirm} For irreversible movements of money — wires, ACH past cutoff, bulk deletes. ```css .hold-btn { transition: transform 100ms var(--ease-out); } .hold-btn:active { transform: scale(0.97); } .hold-fill { clip-path: inset(0 100% 0 0); transition: clip-path 200ms var(--ease-out); /* release: FAST */ } .hold-btn:active .hold-fill { clip-path: inset(0 0 0 0); transition: clip-path 1500ms var(--ease-linear); /* press: SLOW */ } ``` The asymmetry is the whole design: **slow where the user is deciding, fast where the system is responding.** 1500ms of deliberate pressure, 200ms to abandon it. `clip-path` because it's compositor-friendly and needs no extra DOM. Linear because a progress fill that eases is lying about progress. **Accessibility:** hold-to-confirm cannot be the only path. A keyboard user gets `Space` to start and hold, `Escape` to abort, with `aria-live` progress announcements at 0/50/100% — and a typed-confirmation fallback (`type SEND to confirm`) for anyone who can't hold a key. Never gate a financial action behind a motor-skill requirement. --- ## The site layer The nine above are the motion a *component* carries. A documentation site is navigated rather than operated, and the four moments below belong to the shell rather than to anything installed from the registry. Same tokens, same rules, same reduced-motion switch. ### Route change ``` main { animation: ledger-page-enter 180ms --ease-out both } opacity 0→1, translateY 4px→0 ``` 4px and 180ms, not the components' 8px and 240ms: a whole page moving 8px reads as being dropped into place, and the reader is watching for the first line of text rather than for the choreography. It is one CSS rule on `main` rather than a `template.tsx`, because a root template remounts every client component under it — which would reset the showcase's theme, palette and density toggles on every navigation. The one shell that keeps its `main` across routes applies the same keyframe to a `key={pathname}` wrapper instead. The keyframe ends at `transform: none`, not at a zero translate, so a `position: sticky` descendant has no transformed ancestor once it settles. ### Navigation state ``` Rule under a nav item scaleX 0→1 from the left, 180ms --ease-out Active drawn, --accent, full opacity Hover / focus-visible drawn at 45% Pending full width, --text-tertiary, ledger-breathe 900ms ``` The pending state is the one that matters, and it is the one nothing had. Clicking a documentation page with a hundred live demos on it produced no feedback at all until the route arrived, which is indistinguishable from a dead link. Read from `useLinkStatus`, so it cannot drift out of sync with the navigation it reports. Deliberately not a progress bar at the top of the window: the reader's eye is on the thing they just clicked, and that is where the answer belongs. Vertical navigations get the same three states as a 2px bar in the gutter, which grows in place rather than sliding between entries — a marker travelling thirty rows describes a journey the reader did not take. ### Segmented control ``` Thumb transform: translateX(index × 100%), 180ms --ease-out Labels do not move, do not fade ``` Tier 0 says the tab switch itself is instant; the *indicator* is the sanctioned exception, because a switch has a position and one object moving is the entire point of the control. Cells are equal width by construction (`grid-auto-columns: 1fr`), which buys a translate-only animation: no width transition, no measurement pass, nothing to re-measure when a font loads late. First paint places the thumb rather than sliding it in. ### Scroll reveal ``` opacity 0→1, translateY 8px→0, 240ms --ease-out, once ``` One `IntersectionObserver` for the whole document, finding `[data-reveal]`. Sections stay server-rendered and carry an attribute; a `` wrapper would make every page that wanted one a client component for the sake of eight pixels. The hidden state is gated on an attribute the observer sets on `` after it mounts, so a reader whose JavaScript never arrives gets the page unanimated rather than blank. Content is never gated on a reveal — under reduced motion everything is revealed immediately and nothing is observed at all. --- ## Motion spec template Every component in the [catalog](../catalog/00-index.md) carries this block. If a field is empty, the component doesn't animate — and that's the expected answer. ``` Motion Frequency tier 0 | 1 | 2 | 3 | 4 Purpose spatial | state | explanation | feedback | anti-jarring Enter →, , origin Exit … Gesture … Reduced motion what is kept, what is removed Perf note why this is compositor-only, or why the exception is safe ``` --- # Component anatomy Every Lopifi component ships with this spec, filled in, in the order below. The spec is written **before** the code, reviewed at each layer, and lives in the component's `README.md` next to its source. The order is the point: you cannot choose a font size on line 1, because layer 4 doesn't exist yet. --- ## The template ````markdown # ComponentName > One sentence. What it is and when to reach for it. **Status:** draft | spec'd | built | stable **Depends on:** @lopifi/money · shadcn/button **Composes into:** BalanceCard, AccountList --- ## Layer 1 — Grid Base unit 8px Outer radius 24 Inset 24 Min width 240px Height auto | fixed Grid placement span 1 of 12 | full-bleed Breakpoint changes what re-ranks below 768 / 640 ## Layer 2 — Safe space & grouping ┌─ 24 ─────────────────────────────┐ │ [eyebrow] │ │ 8 │ │ [figure] │ │ 8 │ │ [delta] │ │ 16 │ │ ├─ full-bleed rule ────────────┤ │ │ 16 │ │ [actions] │ └─ 24 ─────────────────────────────┘ Groups (eyebrow + figure + delta) = one group, 8px internal actions = separate group, 16px away Full-bleed the rule only ## Layer 3 — Structure Slot Req? Lines Overflow Notes eyebrow opt 1 truncate max 3 words figure REQ 1 never — step down see numerics#overflow delta opt 1 truncate reserves sign glyph slot media opt — aspect-[3/1] full-bleed bottom actions opt — — max 2, max 1 primary Ceilings 3 text slots · 2 actions · 1 media ## Layer 4 — Type Slot Token Size/Line Weight Color eyebrow label 13/16 500 --text-secondary figure figure-lg 28/32 500 --text-primary delta caption 12/16 400 --positive | --negative | --text-secondary Count: 3 sizes, 2 weights ✓ --- ## Data contract ```ts interface ComponentNameProps extends React.ComponentPropsWithoutRef<"div"> { value: Money | null; polarity?: "standard" | "inverted" | "neutral"; // … } ``` Null handling value: null → "n/a" · value.amount === 0 → "$0.00" Loading skeleton matching the exact structure above Error inline, at the slot that failed ## Motion Frequency tier … Purpose … Enter / Exit … Gesture … Reduced motion … Perf note … ## Responsive ≥1024 full layout 768 … <640 … Touch … ## Accessibility Role / semantics … Accessible name … Keyboard … Focus … Announcements … Contrast … ## Edge cases ☐ Negative value ☐ Zero vs null vs redacted ☐ Very large value ☐ Very long label ☐ Non-USD / JPY (0dp) / KWD (3dp) ☐ Multi-currency ☐ RTL ☐ Loading · error · empty · offline/stale ☐ Reduced motion ☐ Forced colors ☐ 320px width ☐ 200% browser zoom ☐ Light + dark ☐ Signal palette ## Examples Default · with delta · loading · error · empty · dense · dark · RTL ```` --- ## Working the layers ### Layer 1 — Grid **Deliverable:** an 8px lattice with the component's bounding box, radius, and inset. Nothing else. Answers: how wide, how tall, what radius, what inset, how it sits in the page grid, what happens at each breakpoint. **Rejection criteria:** any off-grid value; a radius not on the scale; an inset that isn't 16 or 24; a fixed height where content is variable. ### Layer 2 — Safe space & grouping **Deliverable:** grey blocks showing padded regions and gaps. Still no content. Answers: what belongs with what, and how far apart. Every gap comes from the [proximity ladder](../foundations/01-grid-and-layout.md#the-proximity-ladder) — 4/8/16/24/32-48, no others. **Rejection criteria:** more than five distinct gap values; a gap off the ladder; content bleeding into the inset without one of the three sanctioned reasons. ### Layer 3 — Structure **Deliverable:** the slot table plus a wireframe. Grey boxes, no type, no color, no icons. This is where over-design gets caught, because it is very hard to justify a fourth grey rectangle. Every slot declares required/optional, line count, and overflow behavior. **Rejection criteria:** more than 3 text slots; a slot without declared overflow behavior; a figure slot that truncates; unbounded action count; a fixed-height media slot. ### Layer 4 — Type **Deliverable:** the type table, with the count at the bottom. Max 3 sizes, max 3 weights. The count is written out so the violation is visible in review rather than discovered later. **Rejection criteria:** 4+ sizes or weights; a size not on the scale; the display family on anything that could align with another figure; a monetary value at `--text-tertiary`; a label out-ranking its value. --- ## Composition rules - **Composition over configuration.** `` beats ``. Boolean props multiply; slots don't. - **Two levels of nesting, maximum.** A card in a card in a card is a hierarchy failure — the innermost should be a section with a rule line. - **A component owns its own padding, never its own margin.** Margins are the parent's business. This is what makes components droppable anywhere. - **Every component forwards `className` through `cn()`** and spreads the rest of its props to its root element. - **Layout components have no visual style; visual components have no layout opinion.** A `Card` doesn't know it's in a grid; a `Grid` doesn't know it holds cards. --- ## Props conventions ```ts interface Props extends React.ComponentPropsWithoutRef<"div"> { // 1. Data value: Money | null; previous?: Money | null; // 2. Semantics polarity?: "standard" | "inverted" | "neutral"; // 3. Presentation — enumerations, never booleans size?: "sm" | "md" | "lg"; variant?: "default" | "outline" | "ghost"; density?: "compact" | "default" | "comfortable"; // 4. State state?: "default" | "loading" | "error" | "empty"; // 5. Slots — ReactNode, not config objects action?: React.ReactNode; media?: React.ReactNode; // 6. Escape hatches className?: string; asChild?: boolean; } ``` Rules: - **No boolean props for visual variants.** `variant="outline"` not `outlined`. Booleans combine into invalid states; enums can't. - **No `style` prop passthrough for design values.** Consumers override via `className` and tokens. - **`asChild`** (Radix `Slot`) on anything that might need to become a link. - **`cva()`** for variants, so the class logic is data and can be read. - **`forwardRef` + `displayName`** on every primitive. - **Named exports only.** No default exports, no barrel files. --- ## The edge-case checklist This is why the library exists. Every component is tested against all of these before it's `stable`. | Case | The failure it prevents | |---|---| | Negative value | Layout shifting because of the paren; wrong color polarity | | Zero vs null vs redacted | Reporting `$0.00` for data we don't have | | Very large value (`$1,284,392,110.55`) | Truncation, wrapping, or a card blowing out | | Very long label ("Accounts Receivable — Intercompany") | Pushing the figure off-screen | | JPY (0 decimals) / KWD (3 decimals) | Hard-coded `toFixed(2)` | | Multi-currency in one list | A meaningless sum; ambiguous `$` | | RTL | Number direction, icon mirroring, statement indentation | | Loading | A skeleton that doesn't match, causing a jump | | Error | A component that renders nothing and looks broken | | Empty | An empty state with no next action | | Offline / stale | No "as of" indication, so a stale number reads as current | | Reduced motion | Motion sickness; blocked content | | Forced colors / high contrast | Invisible borders, invisible focus rings | | 320px width | Horizontal page scroll | | 200% browser zoom | Overlap, clipped content, unreachable actions | | Dark mode | Contrast failures, invisible hairlines | | Signal palette | Sign readable without red/green | | Greyscale | Every sign still readable — the ship gate | --- # Accessibility Priority ladder from `ui-skills/fixing-accessibility`, with the finance-specific rules that generic guidance doesn't cover. The framing that matters: in a financial product, an accessibility failure is frequently a **correctness** failure. A screen reader that drops the sign off `(1,234.00)` isn't giving a degraded experience — it's reading a different number. --- ## Priority ladder | P | Category | Impact | |---|---|---| | 1 | Accessible names | critical | | 2 | Keyboard access | critical | | 3 | Focus & dialogs | critical | | 4 | Semantics | high | | 5 | Forms & errors | high | | 6 | Announcements | medium-high | | 7 | Contrast & states | medium | | 8 | Media & motion | low-medium | Fix in order. A perfectly contrasted button nobody can reach with a keyboard is worth nothing. --- ## 1. Accessible names - Every interactive control has an accessible name. - **Icon-only buttons require `aria-label`.** The most common failure in dense financial toolbars, where a row can have five icon buttons. - Every `input` / `select` / `textarea` has a real `