Double-Entry Bookkeeping
Every financial transaction in the system uses double-entry bookkeeping: each transaction debits one or more accounts and credits one or more accounts, with total debits always equaling total credits. This invariant — the accounting equation — ensures the ledger is always balanced:
Assets = Liabilities + Equity
No single-sided entries are permitted. Every journal entry is validated before persistence: SUM(debit_amounts) = SUM(credit_amounts) must hold exactly. If this check fails, the entry is rejected.
Chart of Accounts
The chart of accounts is a hierarchical taxonomy of all accounts in the organization:
accounts {
account_id UUID PK
org_id UUID FK
parent_account_id UUID FK nullable
name VARCHAR(255)
code VARCHAR(20) -- e.g. "1010"
type ENUM(ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE)
normal_balance ENUM(DEBIT, CREDIT)
is_leaf BOOLEAN
active BOOLEAN DEFAULT true
}
Example hierarchy: Assets (1000) → Current Assets (1010) → Cash (1011); Liabilities (2000) → Accounts Payable (2010). Normal balance is DEBIT for assets and expenses; CREDIT for liabilities, equity, and revenue. This determines how increases and decreases are recorded.
Journal Entry Schema
A journal entry records one complete transaction across two or more accounts:
journal_entries {
journal_id UUID PK
org_id UUID FK
description VARCHAR(500)
date DATE
reference VARCHAR(100) -- invoice number, receipt, etc.
created_by UUID FK users
created_at TIMESTAMP
}
journal_lines {
line_id UUID PK
journal_id UUID FK
account_id UUID FK
debit_amount DECIMAL(18,4) DEFAULT 0
credit_amount DECIMAL(18,4) DEFAULT 0
description VARCHAR(255)
}
A journal entry with two lines (one debit, one credit) is the simplest case. Complex transactions can span many lines, as long as the total debit equals total credit.
Journal Entry Immutability
Once posted, journal entries are never modified or deleted. This is a core accounting principle — altering historical records destroys the audit trail and violates accounting standards. Corrections use a two-step process:
- Reversing entry: create a new journal entry that exactly inverts the original (debits and credits swapped) to cancel its effect
- Correcting entry: create a new journal entry with the correct amounts
The original entry, the reversing entry, and the correcting entry all remain in the ledger, linked by a reversal_of reference field.
Account Balance Calculation
The current balance of an account is derived by summing all journal lines for that account:
-- For DEBIT-normal accounts (Assets, Expenses):
balance = SUM(debit_amount) - SUM(credit_amount)
-- For CREDIT-normal accounts (Liabilities, Equity, Revenue):
balance = SUM(credit_amount) - SUM(debit_amount)
For performance, a materialized account_balances table stores running totals, updated transactionally on each journal entry insert. Point-in-time balances are computed by summing lines up to a given date.
Period Closing
At the end of each accounting period (month, quarter, year):
- Calculate net income: SUM(revenue account balances) – SUM(expense account balances)
- Post a closing journal entry: debit all revenue accounts to zero, credit all expense accounts to zero, with the net income flowing to Retained Earnings (equity account)
- Mark the period as CLOSED in the
accounting_periodstable - Reject any new journal entries dated within a closed period
After closing, revenue and expense account balances reset to zero for the new period, while asset, liability, and equity balances carry forward.
Balance Sheet
The balance sheet is a point-in-time report showing financial position. It aggregates account balances as of a specific date:
- Assets section: sum of all ASSET account balances grouped by sub-category
- Liabilities section: sum of all LIABILITY account balances
- Equity section: sum of all EQUITY account balances including retained earnings
- Validation: Total Assets must equal Total Liabilities + Total Equity
Profit and Loss Report
The P&L (income statement) covers a date range and shows:
- Revenue: sum of all REVENUE account activity for the period
- Expenses: sum of all EXPENSE account activity, broken down by category
- Net Income: Revenue – Expenses
Both reports support drill-down: clicking a line item shows the individual journal entries that compose it.
Multi-Currency Support
All amounts are stored in the organization's functional currency. Foreign currency transactions are recorded with the exchange rate at transaction date:
journal_lines + { foreign_currency CHAR(3), foreign_amount DECIMAL(18,4), exchange_rate DECIMAL(12,6) }
At period end, a revaluation run recalculates the functional currency value of foreign-denominated balances at the current exchange rate. The difference is posted as an unrealized FX gain or loss to a dedicated equity account.
Audit Trail
Every journal entry is immutable and carries the creator's user_id and creation timestamp. No UPDATE or DELETE is permitted on journal_entries or journal_lines — enforced at the database level via trigger or application-level constraint. The full history of every transaction is always recoverable, making the ledger suitable for external audit and regulatory compliance.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How does double-entry accounting work in a ledger system design?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Every financial transaction produces two journal entries that balance to zero: a debit on one account and a credit on another. In a ledger system, you model this as an immutable `entries` table with columns (transaction_id, account_id, amount, direction ENUM(‘debit’,’credit’), created_at). The transaction record ties the pair together; the sum of all entry amounts per transaction_id must equal zero. This immutability is critical — you never update or delete entries; corrections are made with compensating entries. At query time, an account's balance is computed as SUM(amount) WHERE direction=’credit’ minus SUM(amount) WHERE direction=’debit’, or maintained as a running materialized balance updated within the same DB transaction as the entries.”
}
},
{
“@type”: “Question”,
“name”: “How would you model account hierarchy (e.g. assets > cash > checking) in the schema?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use an adjacency-list or closure-table approach on an `accounts` table with (id, parent_id, type ENUM(‘asset’,’liability’,’equity’,’revenue’,’expense’), name, currency, owner_id). For deep roll-up reporting, a closure table — storing every ancestor-descendant pair with depth — lets you compute subtree balances with a single JOIN rather than recursive CTEs. If hierarchy depth is bounded (e.g., ≤ 5 levels), a materialized path column (e.g., ‘/root/assets/cash/’) is simpler and indexable. Account type drives the normal balance convention: assets and expenses have debit normal balances; liabilities, equity, and revenue have credit normal balances.”
}
},
{
“@type”: “Question”,
“name”: “How do you generate consistent financial reports (P&L, balance sheet) without locking the ledger?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use a reporting snapshot pattern: at period close, run a background job that reads all entries up to a cutoff timestamp using a repeatable-read or serializable snapshot isolation level, aggregates balances per account, and writes the results into a `period_snapshots` table. Reports read from snapshots — never from live entries — so they are consistent and fast regardless of ongoing transaction volume. For real-time dashboards, maintain a `account_balances` table updated atomically with each entry insertion (same DB transaction), accepting that it reflects the latest committed state rather than a period-closed view. Pair with event sourcing so the full entry log can always regenerate any historical snapshot.”
}
},
{
“@type”: “Question”,
“name”: “How do you prevent double-spend or duplicate transaction recording in a high-throughput ledger?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Enforce idempotency with a client-supplied idempotency key stored in a `transactions` table with a UNIQUE constraint on (idempotency_key). The API layer checks for an existing transaction with that key before inserting; if found, it returns the original result without re-processing. At the DB layer, wrap entry insertion and balance update in a single ACID transaction. For account-level concurrency, use optimistic locking: store a `version` integer on `account_balances`, and UPDATE … WHERE version = :expected_version; if zero rows are affected, retry. For fraud-critical paths (e.g., payout), add a distributed lock (Redis SETNX with TTL) scoped to the source account ID to serialize concurrent debit attempts before entering the DB transaction.”
}
}
]
}
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
See also: Coinbase Interview Guide
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering