Double Entry Accounting DB Design
A Refined Database Schema for Double-Entry Accounting
Various blog posts have emerged introducing double-entry accounting to developers. In this article, a simple yet elegant database schema for recording and analyzing ledger entries in a way that aligns with conventional accounting practices.
Accounting Terminology in Development
Developers often sidestep accounting terminology, substituting terms like "debit" and "credit" with positive and negative numbers. While this seems simpler, it can lead to confusion. For example, the ledger-cli documentation notes:
"When you look at the balance totals for your ledger, you may be surprised to see that Expenses are a positive figure, and Income is a negative figure. It may take some getting used to, but…"
This confusion arises from representing credits as negative numbers, which doesn't match how financial statements are typically prepared. Let’s design a schema that avoids such inconsistencies and mirrors real-world accounting.
The Chart of Accounts
The first step is defining the list of accounts. This involves creating a table with the following columns:
- Name: The account name (e.g., Assets, Liabilities).
- Number: A hierarchical numeric identifier (e.g., 100 for Assets, 110 for Cash). This hierarchy allows easy roll-up of sub-account values.
- Normal Balance: Indicates 1 for debit and -1 for credit. This column is for internal computations only.
Here’s the table structure in SQLite:
CREATE TABLE "accounts" (
"name" TEXT,
"number" INTEGER,
"normal" INTEGER
);
Populate the table with some sample accounts:
Name | Number | Normal |
---|---|---|
Assets | 100 | 1 |
Cash | 110 | 1 |
Merchandise | 120 | 1 |
Liabilities | 200 | -1 |
Deferred Revenue | 210 | -1 |
Revenues | 300 | -1 |
Expenses | 400 | 1 |
Cost of Goods Sold | 410 | 1 |
Equity | 500 | -1 |
Capital | 510 | -1 |
Deriving the Accounting Equation
Using SQL, we can derive the accounting equation:
SELECT
group_concat(name, ' + ') AS expression
FROM accounts
GROUP BY normal;
Result:
- Liabilities + Revenues + Equity + Deferred Revenue + Capital
- Assets + Expenses + Cash + Merchandise + Cost of Goods Sold
To simplify, filter for high-level accounts:
SELECT
group_concat(name, ' + ') AS expression
FROM accounts
WHERE number % 100 = 0
GROUP BY normal;
Result:
- Liabilities + Revenues + Equity
- Assets + Expenses
Transactions
With the chart of accounts in place, we can define a transactions table:
CREATE TABLE "transactions" (
"id" INTEGER,
"date" TEXT,
"amount" REAL,
"account" INTEGER,
"direction" INTEGER
);
Columns:
- ID: Groups related entries into a single transaction.
- Date: Transaction date.
- Amount: Dollar amount (always positive).
- Account: References the account number.
- Direction: Indicates debit (1) or credit (-1).
Example Transactions
Here’s a set of sample transactions for setting up a business, buying inventory, and making a sale:
ID | Date | Account | DR | CR | Description |
---|---|---|---|---|---|
0 | 2022-01-01 | Assets:Cash | 500.0 | Deposit capital into account | |
0 | 2022-01-01 | Equity:Capital | 500.0 | ||
1 | 2022-01-15 | Assets:Merchandise | 100.0 | Purchase inventory | |
1 | 2022-01-15 | Assets:Cash | 100.0 | ||
2 | 2022-02-01 | Assets:Cash | 15.0 | Customer prepayment | |
2 | 2022-02-01 | Liabilities:Deferred Revenue | 15.0 |
These transactions ensure that total debits equal total credits, maintaining the integrity of double-entry accounting.
Querying Transactions
Verifying Debits and Credits
Ensure debits equal credits with this query:
SELECT
SUM(CASE WHEN direction = 1 THEN amount END) AS DR,
SUM(CASE WHEN direction = -1 THEN amount END) AS CR
FROM transactions;
Result:
- DR: 633.0
- CR: 633.0
To identify mismatches:
SELECT
id,
SUM(direction * amount) AS balance
FROM transactions
GROUP BY id
HAVING balance != 0;
Calculating Balances
Generate a balance sheet with this query:
SELECT
account,
name,
SUM(amount * direction * normal) AS balance
FROM transactions
LEFT JOIN accounts ON transactions.account = accounts.number
GROUP BY name
ORDER BY account, name;
Sample output:
Account | Name | Balance |
---|---|---|
110 | Cash | 415.0 |
120 | Merchandise | 97.0 |
300 | Revenues | 15.0 |
410 | Cost of Goods Sold | 3.0 |
510 | Capital | 500.0 |
To roll up sub-accounts into their main categories:
SELECT
(account / 100) * 100 AS parent_account,
name,
SUM(amount * direction * normal) AS balance
FROM transactions
LEFT JOIN accounts ON transactions.account = accounts.number
GROUP BY parent_account
ORDER BY parent_account;
This schema offers a clear and efficient way to implement double-entry accounting in a database. With proper SQL queries, it supports detailed and aggregated financial analysis consistent with standard accounting practices.