Skip to content

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:

  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

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

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

Built against this document

The surfaces that implement what this argues — running, computing, or shaped by it.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.