The ledger
Books, accounts, transactions and entries, edit history, and several devices at once.
In one line: a transaction is a header the user sees, plus one or two entries that carry the money; an account’s balance is the sum of its entries, and nothing else.
users ──< book_members >── books ─┬─< accounts ─────────< entries >──── transactions >── categories
(owner/editor/viewer) ├─< categories (header: type, date, payee, note)
├─< contacts
├─< budgets
└─< transactions
- transactions are what the user sees: type, date, category, payee, note.
- entries are the money: a signed amount in minor units, one row per account touched.
| Type | Entries |
|---|---|
| expense | 1 entry, negative |
| income | 1 entry, positive |
| refund | 1 entry, positive; the app gives it an expense category, so it reduces that category’s spending (the database doesn’t check the category’s kind) |
| transfer | 2 entries on two accounts, one out and one in; they must sum to 0 when both are in the same currency |
| opening_balance | 1 entry, any sign: an account’s starting balance |
| adjustment | 1 entry, any sign: reconcile to a statement |
- Opening balances can’t have a category. A transfer can have only a transfer category (
category_kinderror otherwise), and only a transfer can have one; a category used by transactions can’t switch to or from the transfer kind. Transfer categories are labels: spending, budgets and AI summaries all select by transaction type, so they never count. - An account’s balance is
SUM(entries.amount)(account_balancesview). There’s no balance column, so it can’t drift from history, and two devices adding at once can’t race. - Liabilities are negative. A credit card with $450 owed has balance -45000. Buying on the card is an expense on the card; paying it off is a transfer from checking. A loan starts with a negative opening balance. A book’s net worth is the sum of its balances.
- Books can’t reference each other. Every row carries
book_id, and the foreign keys include it, so the database rejects a transfer between books or a category from another book. Moving money from a personal book to a household book is an expense in one and income in the other. - Deleting a book deletes everything in it, its audit-log rows included. An account with history can only be archived. A category with transactions can’t be deleted either: the API archives it instead and says so (as the old app did). A category with sub-categories can’t be deleted (
has_subcategories); move or delete them first (default). - Net worth toggle:
accounts.in_net_worth(on by default) leaves an account out ofbook_net_worth, for money held for someone else. It still shows (greyed out) with its balance, and its spending and income still count. - Running balance on an account’s page: a window
SUM(amount)over its entries ordered by date thencreated_at, computed per request. - The shape rules (a transfer has two entries, an expense is negative) are checked by the database when the transaction commits, so the app writes the header and entries together in any order.
- Why entries rather than
from_account/to_accountcolumns: every balance is the sameSUM, whatever the type, and “money doesn’t appear from nowhere” is a check on one transaction’s entries.
Examples: coffee 45,000 ₫ from Cash is one entry of -45000 on Cash. Checking → Savings 1,000,000 ₫ is
-1000000 on Checking and +1000000 on Savings. A credit card that starts owing $450 is an opening balance
of -45000.
Transaction edits and history
created_at,modified_atandversionare on every transaction; the database sets the last two on every edit.transaction_history: one full snapshot per saved version (date, type, category, payee, note, entries) with who saved it. The edit history screen reads it: “created”, “edited” for each save, “deleted” at the end. It stays after a transaction is deleted, and goes only with its book. Members can read it; nobody can change it.audit_logis separate: a raw record of every change to books, members, people, accounts, categories, transactions, entries, budgets and exchange rates, for security investigations. Only owners can read it, nobody can change it, and it goes with its book.- Editing rewrites the transaction. If you ever want accounting-style immutability instead, an edit would add a reversing transaction.
Several devices at once
| Problem | Fix |
|---|---|
| Two devices edit the same transaction and the second silently overwrites the first | Books, accounts, people, categories, transactions, budgets and own exchange rates have a version. The device sends the version it loaded; if it changed, the API returns 409 and the device reloads. Profile, member roles and AI settings are last-write-wins. |
| A retry or double tap saves an expense twice | The device creates the transaction’s id when the user taps Save and reuses it on retry. Saving the same id twice does nothing. |
| Two devices add at once and a balance goes wrong | Can’t happen: balances are sums, not a stored number. |
| A device shows stale data | Refetch when the app regains focus and every minute or so (TanStack Query). A 409 also reloads. |
| Lost or stolen phone | “Sign out other devices” on Profile and security ends every other session at once; changing the password does too. The admin can also disable the account. Listing each device isn’t built yet (web#17, moving to the monorepo). |