Skip to content

Data model

The ledger is three tables, defined in openbooks-api/migrations/0001_init.sql.

create type account_type as enum ('asset', 'liability', 'equity', 'income', 'expense');
create table accounts (
id bigserial primary key,
name text not null unique,
kind account_type not null,
created_at timestamptz not null default now()
);

Every account has one of five kinds. The kind decides how balances read on a report — see Reports for the sign-flipping rule.

openbooks-api/migrations/0002_seed_accounts.sql seeds a starter chart of accounts: Checking Account and Cash Box (asset), Dues, Donations, and Fundraising (income), Supplies, Food, Rent, and Fees (expense), and Opening Balance (equity). You can add more with POST /accounts.

create table transactions (
id bigserial primary key,
occurred_on date not null,
description text not null,
created_at timestamptz not null default now()
);

A transaction is a dated, described event — “Meeting pizza,” “March dues collected.” It doesn’t hold any money by itself; the amounts live on its entries.

create table entries (
id bigserial primary key,
transaction_id bigint not null references transactions (id) on delete cascade,
account_id bigint not null references accounts (id),
amount_cents bigint not null check (amount_cents <> 0)
);

Each entry is one leg of a transaction: an account and a signed amount. Deleting a transaction cascades to its entries.

One row, holding instance-wide presentation settings. Created by migrations/0003_settings.sql.

Column Type Notes
id boolean primary key, default true
book_style text not null, default 'club'

Two check constraints do the work:

constraint settings_is_single_row check (id),
constraint settings_book_style_known check (book_style in ('club', 'personal'))

The boolean primary key with check (id) is the standard single-row trick: the only value satisfying both the check and uniqueness is true, so the table can never hold a second row to disagree with. Valid values live in the constraint rather than in app code, for the same reason the balance rule does — the database is the part that cannot be bypassed.

book_style is presentation only. It selects which vocabulary a client renders and touches nothing about the ledger, so flipping it is always safe and always reversible.

openbooks-api/migrations/0004_auth.sql adds eight tables and one column, all in one migration even though phase 1 only writes four of the tables — a single auth migration beats one per phase.

Table Written in phase 1? Purpose
users yes email (stored lowercased by the app, not citext), argon2 password_hash, failed_count and retry_after for login backoff
sessions yes id is the SHA-256 digest of the session cookie value, never the raw value, so a database dump yields no usable session; mfa_complete, expires_at (fixed at creation, not sliding), last_seen_at
oauth_clients yes (seeded rows only) pre-registered first-party clients — openbooks-cli and openbooks-desktop — inserted by the migration itself
oauth_tokens yes bearer tokens; token_hash is the digest, never the raw token; kind is access or refresh (only access is minted in phase 1); resource is the token’s audience; revoked_at
webauthn_credentials no one row per passkey; unused until phase 2
webauthn_challenges no in-flight WebAuthn ceremony state; unused until phase 2
oauth_codes no authorization codes for POST /oauth/authorize/approve; in use since phase 3, not phase 4
oauth_device_codes no device-flow codes (device grant); in use since phase 4

Phase 4’s own migration (0005_oauth_phase4.sql) adds one column and one index:

Change Table Purpose
device_attempts (int not null default 0) sessions the per-session guessing budget for a user code — a hard cap with no reset, see the device grant
oauth_tokens_parent_hash_idx (index on parent_hash) oauth_tokens revoke_family’s recursive family walk was scanning the whole table once per level with no index to use; phase 3 named this residual and this is the migration that fixes it

Like sessions.id and oauth_tokens.token_hash, every credential value this schema stores is a digest, not the secret itself — the raw session cookie value and the raw bearer token exist only in the response that issues them and in the client that holds them, never in the database.

transactions also gains a nullable created_by uuid references users(id). It’s attribution, not authorization: nothing reads it to make a decision, and there is still no RBAC. As of phase 5 it’s written by POST /transactions and by the record_transaction MCP tool, from the authenticated caller — both routes sit behind require_user, so a CurrentUser is always on hand. Anything posted before phase 5 is null, and stays representable rather than backfilled — every seeded transaction has an author, because every path that writes one now authenticates. GET /transactions resolves the column to an email with a left join, so a since-deleted author’s transactions don’t drop out of the ledger. The column has no on delete action, so becoming a since-deleted author is the job of user rm, which nulls it for that user’s rows in the same database transaction as the delete.

See Authentication for what actually happens with users, sessions, and oauth_tokens — login, backoff, sessions expiring on a fixed horizon, and tokens being revoked wholesale on a password change.

openbooks-api/src/oauth/reap.rs runs once an hour, spawned from main.rs on the serve path only — an admin command never starts it, and cargo test’s per-test router never sees it either. Five plain deletes, no transaction around them, each scoped by expires_at:

Table Deleted once
oauth_codes 24 hours past expires_at (kept a day so consumed_at is still visible to someone reading the database)
oauth_device_codes past expires_at, any status
oauth_tokens 7 days past expires_at
sessions past expires_at
webauthn_challenges past expires_at

The predicate is expiry, never revocation. Reuse detection walks oauth_tokens.parent_hash downward from whatever token a caller presents, so the row it starts at has to exist. A revoked-but-unexpired token is load-bearing: deleting it early would turn a replay into a plain unknown-token 400 and skip the family revocation that caps a stolen token at one use. The seven-day grace period only affects how late a theft can still be caught after the token itself has aged out — it changes nothing about whether a revoked token is deletable.

No lock, and two API processes reaping at the same time is fine — every delete is idempotent and scoped by timestamp, so the worst case is redundant work, never wrong data.

amount_cents is a bigint. There are no floating-point amounts anywhere in the money path — $45.00 is stored and moved around as 4500. The NewEntry and Entry structs in openbooks-api/src/lib.rs use i64 for the same field, so this holds all the way from Postgres to the JSON the API returns.

Signed entries: debit positive, credit negative

Section titled “Signed entries: debit positive, credit negative”

There’s no separate “debit or credit” column. The sign of amount_cents carries that information:

  • Debit an account by posting a positive amount.
  • Credit an account by posting a negative amount.

A transaction is balanced when its entries sum to zero. Paying $45 for pizza from checking is one entry of +4500 against the Food expense account and one entry of -4500 against Checking Account.

The balance invariant is a Postgres trigger, not app code

Section titled “The balance invariant is a Postgres trigger, not app code”

The rule that entries must sum to zero per transaction is enforced by a deferred constraint trigger in 0001_init.sql, not by application logic:

create function assert_balanced() returns trigger as $$
declare
txn bigint := coalesce(new.transaction_id, old.transaction_id);
total bigint;
begin
select coalesce(sum(amount_cents), 0) into total from entries where transaction_id = txn;
if total <> 0 then
raise exception 'unbalanced transaction %: entries sum to % cents, must be 0', txn, total;
end if;
return null;
end $$ language plpgsql;
create constraint trigger entries_balanced
after insert or update or delete on entries
deferrable initially deferred
for each row execute function assert_balanced();

It’s deferrable initially deferred because entries for one transaction are inserted one row at a time — the check only makes sense once every leg for that transaction has landed, at commit time.

0001_init.sql adds two indexes to support the common lookups: entries by transaction_id (used when assembling a transaction’s legs) and by account_id (used when computing an account’s balance).