# 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.

Source: https://lopifi.com/docs/foundations/06-numerics
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# 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
   <span aria-hidden className="invisible">)</span>
   ```
   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
  <span aria-label="1,234 dollars and 56 cents">$1,234.56</span>
  ```
  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 `<th scope="col">` 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.
