---
title: "Numerics"
description: "These are the rules that make Lopifi a finance system instead of a dashboard kit. The rest of the library enforces them on the consumer's behalf."
canonical: "https://lopifi.com/docs/foundations/numerics"
---

# Numerics

> These are the rules that make Lopifi a finance system instead of a dashboard kit. The rest of the library enforces them on the consumer's behalf.

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

---

# Numerics

These are the rules that make Lopifi a finance system instead of a dashboard kit. The rest of the library enforces them 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's a reconciliation that never balances and a support ticket you can't reproduce. Use integers or a decimal library (`dinero.js` v2, `decimal.js`), *never* raw floats, and don't do arithmetic in the component layer at all.

**Lopifi components never do arithmetic.** They format and lay out. The caller passes totals in, already computed, and 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-coded `toFixed(2)` is wrong for all but the 2-decimal ones.

```ts
const digits = new Intl.NumberFormat(locale, { style: "currency", currency })
  .resolvedOptions().maximumFractionDigits;
```

Let `Intl` answer for most currencies, plus one small override table. `Intl` reports CLDR *display* digits, and in V8 (Chrome, Edge, Node) about seventeen currencies, including HUF, IDR, COP, PKR and IQD, 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.

<!-- plate:numerics-decimals -->

---

## 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 do most of the work:

- **`currencyDisplay: "narrowSymbol"`** gives `$` instead of `US$` and `£` instead of `GB£`. Use `"symbol"` (which disambiguates) only where two dollar currencies coexist, and even then prefer rendering the code as a separate, subordinate element: `$1,200.00 USD`.
- **`currencySign: "accounting"`** gives you parentheses natively, correct for the locale.

### Locale ≠ currency
`en-US` formats a `EUR` amount as `€1,234.56`. `de-DE` gives `1.234,56 €`. Both are correct answers to 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's 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 has a different width from the plus sign, so `+12.4%` and `-12.4%` don't align in a column. `Intl` already emits U+2212. The bug shows up when someone hand-builds a string.

### Negative alignment {#negative-alignment}

In a right-aligned column that mixes `1,234.00` and `(1,234.00)`, the closing paren shifts the digits ~4px left and breaks decimal alignment.

<!-- plate:numerics-paren-column -->

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, and works everywhere. Copy-paste stays clean if you strip `aria-hidden` content on copy.

2. **Grid the cell.** `display: grid; grid-template-columns: auto 1fr auto;` for open paren / figure / close paren. Most reliable 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.

Do the same for the `+`/`−` prefix in a delta list: reserve the glyph slot so values align even when some are unchanged.

### Sign is not sentiment
From [color](https://lopifi.com/md/docs/foundations/color#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

This is 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"` |

<!-- plate:numerics-states -->

> **Rendering `$0.00` for a value you don't have is a lie**, and in a financial product that lie costs money. Make it impossible in the type system: `Money | null | undefined` with three explicit render branches, never `value ?? 0`.

The em dash for statement zeroes is the accounting convention. It means "computed, and nil." A blank means "not applicable here." Lopifi's `StatementRow` implements all three.

---

## 5. Abbreviation {#abbreviation}

`$1.2M` is for charts and KPIs. It's **forbidden** on 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** via `title`, tooltip, or 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. It's the most reported "bug" in finance UI, and it's just 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, percent-of-total columns and pie-chart labels. **Lopifi provides this helper and uses it in `CategoryBreakdown` and `PlByDimension`.**

4. **When a rounded column 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 one you used, in the export, the tooltip, or 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`), a **25% increase**. Both numbers are correct, and they're different. Components must be told which one they're showing:

```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 undefined, not `∞`. Render it as `n/a`, never `Infinity`, `NaN` or `100%`. Guard at the format boundary. Growth from a zero base is unknowable, so say so.

---

## 8. Rule lines {#rule-lines}

Accounting typography has used rule lines for four centuries, and almost no web UI implements them. Two CSS declarations buy a lot of 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
```

<!-- plate:numerics-rules -->

| 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. That's the real convention, and it's what makes a rule read as accounting instead of a generic divider. A rule under "Total revenue" that runs 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 in a single declaration.
- In a multi-period statement, the rule spans **each period column independently**, with the column gap unruled.
- Never put both a rule and a background fill on a total row. Use the rule, per convention. A gray "total" row is a spreadsheet habit, not an accounting one.

---

## 9. Overflow {#overflow}

> **A number never truncates.** A truncated figure is *wrong*, not degraded. `$1,234,5…` is unreadable and dangerous.

Escalate in this 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 on 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 doesn't.

Also **reserve the maximum width.** A live-updating figure should take 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](https://lopifi.com/md/docs/catalog/swaps-fx).

---

## 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.** Separate debit and credit columns are the point of double-entry. Collapsing them takes away 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**: `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 it's required. Several engines read `(1,234.00)` as "1,234.00" and silently drop the sign, which is a correctness failure. 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, because 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, that's the difference between usable and not.
