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 OpenBooks desktop client showing money on hand, a form for recording something, and every account with its balance.

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.

What happenedMember dues
Date2026-07-08
Amount$685.00
Paid intoChecking Account

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.

Accountamount_cents
Checking Account+68500
Dues−68500
Sum0

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 desktop client's transactions screen listing real seeded entries with dates, descriptions and amounts.

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

Statements 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
The reports screen showing money in and money out for 2026, each broken down by category with totals.

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.

MCP server
$ 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.

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.com

Where 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.