Keep the books for a club without learning what a debit is.
OpenBooks records what came in and what went out, then prints the statement for the monthly meeting. Underneath every entry is a real double-entry ledger that the database itself refuses to let go crooked.
- On hand
- $7,577.50
- Money in, 2026
- $7,565.00
- Money out, 2026
- $4,885.00
- Transactions
- 83
Real figures from the sample books just seed loads, not illustrations.

The books cannot go crooked
Every entry carries a signed amount in integer cents, debit positive and credit negative, and a transaction is balanced when its entries sum to zero. That rule is not enforced in application code, where a bug could get around it. It is a deferred constraint trigger inside Postgres.
You record one thing
A treasurer picks an amount, a date, where the money came from, and what it was for. Four fields, no vocabulary to learn.
The API writes two legs
The client composes the two-legged transaction. Amounts are integer cents and they sum to zero, which is the whole definition of balanced.
| Account | amount_cents |
|---|---|
| Checking Account | +68500 |
| Dues | −68500 |
| Sum | 0 |
Postgres refuses the rest
Deferred to the end of the transaction, because entries arrive one at a time. Post a single leg and the database throws it out, whatever the API believed.
create constraint trigger entries_balanced
after insert or update or delete on entries
deferrable initially deferred
for each row execute function assert_balanced();ERROR unbalanced transaction 41: entries sum to 68500 cents, must be 0
How double-entry works, for non-accountantsEvery invariant, and where it is enforced
Four ways in, one set of books
One Postgres database and one Rust API, with four clients over it. Pick whichever suits the person doing the job — they are all reading and writing the same ledger.
The native desktop app
Rust on ply-engine, with a local snapshot cache. When it cannot reach the API it keeps working from the last sync and goes read-only rather than pretending — the delete control is absent, not merely disabled.
The desktop app- Cache file
- snapshot.json, in the OS app-data directory
- Cached
- Accounts, transactions, balances, and both reports
- Written
- After each sync that completes online
- Read
- Once, at startup, before the first frame
- Offline
- Recording and deleting are removed, not disabled

The terminal client
A full-screen ratatui interface over four tabs, plus a scriptable subcommand mode with text and JSON output for shell pipelines. It parses dollars to integer cents with string arithmetic, never a float.
The terminal UI┌ OpenBooks ───────────────────────────────────────┐┌──────────────────────────┐
│ Dashboard │ History │ Reports │ Record ││ on hand $7,577.50│
└──────────────────────────────────────────────────┘└──────────────────────────┘
┌ Every account ───────────────────────────────────────────────────────────────┐
│ACCOUNT BALANCE│
│Cash Box $766.00│
│Checking Account $6,811.50│
│Donations $3,250.00│
│Dues $10,120.00│
│Fees $27.00│
│Field Trips $1,260.00│
│Food $1,888.00│
│Fundraising $2,690.00│
│Insurance $1,390.00│
│Opening Balance $2,700.00│
│Rent $5,875.00│
│Supplies $902.50│
│Unpaid Bills $160.00│
│ │
└──────────────────────────────────────────────────────────────────────────────┘
[Tab]view [j/k]move [r]efresh [?]help [q]uitStatements for the meeting
Balances, an income statement, and a balance sheet, over any period. Reports flip the sign on income, liability and equity accounts, so every line on the printed handout reads as a positive number.
Statements
An MCP server, on the same code
The API mounts a Model Context Protocol server at /mcp beside its HTTP routes, and every tool delegates to the same functions the handlers call. Add a report and the agent interface follows for free. Each result carries the same payload twice — once as text for the model to read, and once as structuredContent, shown here.
$ curl -s localhost:38081/mcp \
-H "Authorization: Bearer $OPENBOOKS_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"income_statement",
"arguments":{"from":"2026-01-01","to":"2026-06-30"}}}'
{"id":1,"jsonrpc":"2.0","result":{"structuredContent":{
"from": "2026-01-01", "to": "2026-06-30",
"income": [
{ "account": "Donations", "cents": 150000 },
{ "account": "Dues", "cents": 379500 },
{ "account": "Fundraising", "cents": 124500 }
],
"expenses": [
{ "account": "Fees", "cents": 0 },
{ "account": "Field Trips", "cents": 45500 },
{ "account": "Food", "cents": 71200 },
{ "account": "Insurance", "cents": 71000 },
{ "account": "Rent", "cents": 195000 },
{ "account": "Supplies", "cents": 65900 }
],
"total_income_cents": 654000,
"total_expenses_cents": 448600,
"net_cents": 205400
}}}Read the part written for you
The same system documented three ways, because a treasurer printing a statement and someone standing up a server need different pages, not the same page with more of it.
For treasurers
Record what came in and what went out, then print the statement for the meeting. No debits, no credits, no journal.
- Recording money
- Statements
- The desktop app
- Working offline
Running a server
Stand it up for your club: Postgres, the API, the environment it reads, and the users who can sign in.
- Install
- Postgres
- Configuration
- Users and tokens
For developers
One database, one API, four clients. The ledger model, every HTTP route, and the MCP tools built on the same code.
- Architecture
- Endpoints
- Data model
- MCP server
Run it tonight
Docker for Postgres, a Rust toolchain, Node for the web app, and just. The recipe waits for the database to be healthy, applies the migrations, and brings up the API and the UI together.
git clone --recurse-submodules \
https://github.com/hatchertechnology/openbooks.git
cd openbooks
just run # postgres, api, web
just seed # sample books, 2025 – mid-2026
just user-add you@example.comWhere it stands
A working proof of concept for a family or a club, not a product. Two things are worth knowing before you read further.
- Authentication is required. The web app uses a session cookie; every other client sends a bearer token. Users are created with
just user-add, never over HTTP. - There is deliberately no RBAC. Any authenticated user can do anything any other can. For one household or one club treasury that is the intended design, not a gap.