The accounting model
Every Lopifi component sits on one data model, and if the model is wrong no amount of design saves the product. This page defines it, from the journal line up to basis and materiality.
Most of the UI decisions below follow from one fact:
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, and debits equal credits 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#
It's the most important rendering rule in the accounting family. A signed column (-1,240.00) folds two facts into one. Double-entry exists so a person can check the arithmetic by eye, and a single column takes that check away.
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. It doesn't get saved as a draft without comment, and the difference is never rounded away.
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 (product, services)
Other other income · gains (interest income, FX gain, gain on disposal)
Expense COGS · opex · other · tax (direct costs | SG&A, R&D | interest, FX loss | income tax)
In the UI, an AccountPicker groups by type and sub-classification, shows the account number and shows the normal balance, because someone posting to an unfamiliar account needs to know which side increases it. It costs one glyph (Dr / Cr) and prevents a whole class of mistakes.
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
Showing Accumulated depreciation 218,400 as a positive asset is a classic mistake, and every reviewer spots it.
3. The sign problem#
This is what 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 -60050000 — minor units, so cents included. On a P&L, it must display as 600,500.
That leaves you with two signs, and they differ:
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 went the other way, such as a contra-revenue, a credit to an expense account or a negative gross margin. It never comes from the sign convention, and it renders in parentheses like any other negative.
That's why Money takes accounting (parentheses) as an explicit prop instead of inferring it. The component can't 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. Anything deeper is a chart-of-accounts problem. Say so in the empty state instead of 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#
The account is one axis of a modern ledger. Entity, department, customer and the rest 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 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 the control that makes a ledger auditable, and the main reason accounting software behaves differently 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, disabled or otherwise. 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 Sep 30, 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 basis toggle is an always-visible control on every statement, never a setting buried in a menu. The two numbers can differ by an order of magnitude, and a reader who can't tell which basis they're looking at is being misled.
What the components do with it:
- 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).
- Comparative columns must be on the same basis. Mixing is blocked at the type level.
9. Materiality#
A variance report is useful only if it encodes which differences deserve a look. Two different things share the word, and the UI keeps them apart. Materiality is a judgment about whether a misstatement or omission would change the decision of someone relying on the financial statements — it is qualitative as well as quantitative, and no fixed number decides it (SEC SAB 99; FASB Concepts Statement No. 8, ch. 3). A flux threshold is a house rule for which period-over-period variances get an explanation in the close. The policy below is a flux threshold. It's set with materiality in mind, and the UI calls it a threshold so nobody mistakes one for the other. (The component that edits it ships as MaterialityPolicy; the name is historical, the behavior is the one described here.)
type FluxThresholdPolicy = {
/** Absolute floor. Differences below this are ignored entirely. */
trivial: Money;
/** A variance is flagged if it exceeds BOTH thresholds. */
absolute: Money;
relative: number; // e.g. 0.05 for 5%
/** Optional per-account overrides — cash is flagged at any amount. */
overrides?: Record<string, Partial<FluxThresholdPolicy>>;
};
Rules:
- Both thresholds must be exceeded. A 200% swing on $12 clears the percentage but not the amount — not news. A $40,000 swing on a $40M account clears the amount but is 0.1% — usually noise. A $50,000 swing that is also 12% of the balance clears both, and gets an explanation. Flagging on percentage alone produces a report nobody reads.
- The thresholds are 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.
- A variance under the threshold can still be material. A small misstatement can matter for qualitative reasons — it turns a loss into a profit, breaches a covenant, involves a related party. That is why cash and related-party accounts commonly override to zero tolerance. Support per-account overrides.
10. GAAP vs IFRS#
Components take a framework prop wherever the framework changes presentation, and take no position anywhere else.
| 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 Jan 1, 2027) imposes operating, investing and financing categories, required subtotals, and disclosure of management-defined performance measures |
Statement titles, section ordering and the presence of certain lines (revaluation surplus) follow the framework, and the statement header names it. Lopifi doesn't convert between frameworks. That's an accounting judgment, and a component only renders.
Cross-references#
| Topic | Doc |
|---|---|
| How numbers are formatted, signed, ruled | foundations/numerics.md |
| Periods, close states, cutoff | periods-and-close.md |
| Statement structure and consolidation | statement-semantics.md |
| Schedules, accruals, roll-forwards | schedules-and-accruals.md |
| Controls, approvals, audit trail | controls-and-audit.md |
| Terminology | lexicon.md |