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.
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:
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:
- Components render
presentationAmount. They never negateledgerAmountthemselves, and never infer the sign from the account type at render time. - 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.
LedgerTable,JournalEntry,AccountRegister, andTrialBalancerender the ledger representation (debit/credit columns). Everything else renders the presentation representation.- 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
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.
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.
requiredDimensionsis enforced at the account level, not globally. Revenue accounts may require a customer; rent may require a location. TheJournalEntryform 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.
StatementTablesupports agroupBy: DimensionKeythat 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
AlertDialogwith 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.
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<string, Partial<MaterialityPolicy>>;
};
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 |
| Periods, close states, cutoff | 02-periods-and-close.md |
| Statement structure and consolidation | 03-statement-semantics.md |
| Schedules, accruals, roll-forwards | 04-schedules-and-accruals.md |
| Controls, approvals, audit trail | 08-controls-and-audit.md |
| Terminology | 11-lexicon.md |