trevato/books
Public: anyone can read this; the crew can change it.
Double-entry bookkeeping for a small business: a chart of accounts, journal entries that must balance, invoices and bills with payments applied, bank import from CSV with rule-based matching, trial balance, income statement and balance sheet for any period, an audit log of every change, and a year-end close. Money is integer cents; every report reconciles to the ledger in the tests.
main
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 2.5 KB · ~636 tok
- forge.json 111 B · ~37 tok
- package.json 142 B · ~48 tok
- README.md 5.7 KB · ~1.5k tok
README.md
books
Double-entry bookkeeping for a small business: a chart of accounts, journal entries that must balance, invoices and bills with payments applied, bank import from CSV with rule-based matching, trial balance, income statement and balance sheet for any period, an audit log of every change, and a year-end close. Money is integer cents; every report reconciles to the ledger in the tests.
PORT=8080 FORGE_APP_DATA=./data BOOKS_PASSWORD=… bun run src/server.ts
bun test # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
Signing in
These are one business's books, so nothing is public. The first sign-in cannot be claimed by whoever visits first:
- set
BOOKS_PASSWORDin the environment and sign in with it, or - leave it unset, and the app mints a one-time token at boot and prints it
to its run log. It is spent the moment it is used. The terminal prints a
fresh one with token. A token is never shown on a page.
A session lives in an HttpOnly, SameSite=Lax cookie for fourteen days, and carries a CSRF token that every form echoes back. Five wrong guesses from one client buys a minute of silence.
How it keeps the books honest
- Cents, never floats. Text is parsed to an integer at the boundary and
formatted back out; nothing in between is a float.
- Entries balance or they are refused. An entry needs two or more lines,
each a debit or a credit on a live account, and its debits must equal its credits to the cent. The check runs inside the transaction that writes it.
- Nothing is edited or deleted. A mistake is corrected by a reversing
entry, which is the same lines the other way round. Voiding an invoice reverses it and every payment on it. Deleting a bank rule leaves the entries it posted alone.
- Idempotency where a double click would cost money. Every form that
posts carries a one-shot nonce; a repeat lands on the same entry. Importing the same statement twice adds nothing the second time.
- A closed period is closed. Closing posts one entry that empties income
and expenses into retained earnings and locks every day up to that date. Reversing something inside a closed period lands on the first open day. Reopening reverses the closing entry rather than deleting it.
- Every change is a row in the audit log with who did it, what it touched
and enough detail to read it back.
Reports
Trial balance and balance sheet are cumulative as of a day; the income
statement covers a period. All three are summed from entry_line and nothing
else, so they can only say what the journal says. Because every entry
balances, assets = liabilities + equity + (income − expenses) is an identity
rather than a hope — the balance sheet prints both sides and the tests assert
they are equal after ordinary trading, after a close, and after a reopen. Each
report has a CSV twin at &format=csv, and the journal at /journal.csv.
Bank import
Give it a CSV with a date, a description, and either one signed amount
column or separate debit/credit columns; id, reference or fitid is
used as the row's identity when present, and otherwise a row is identified by
its date, description, amount and how many identical rows came before it in
the file. Rows that cannot be read are reported, not guessed.
A rule matches a description by plain case-insensitive substring — no regular expressions, so no pattern a person can get wrong and nothing to run away with a large file — optionally narrowed to money in or money out. The lowest priority number that fits wins, and matching rows are posted as they land. A row can also settle an open invoice or bill, which records a payment.
The terminal
src/tty.ts is a line of commands, not a cursor, because bookkeeping is
typing. Everything the web pages do is there: post, reverse, invoice,
bill, pay, void, import, rule, match, settle, tb, is, bs,
ledger, audit, preview, close, reopen, token. help lists them.
The exact command that reaches it is in FORGE_APP_SSH when the app runs, and
the dashboard prints that variable verbatim; when it is unset the page says
the terminal is off.
What is real and what is not
All of it is real: real double-entry postings in a real SQLite file under
FORGE_APP_DATA. Nothing is simulated, there is no payment gateway and no
money leaves anywhere — a "payment" is a bookkeeping entry against a bank
account you keep here, and a bank import is a file you hand it. The chart of
accounts a fresh database starts with is a plausible default, not advice.
Shape
One process, no dependencies beyond Bun itself. src/server.ts is the edge
and holds the security floor, the session and the same-origin check;
src/routes.ts is the table of paths; src/pages/* render and escape;
src/db.ts is the schema; and the ledger itself is one module per concept —
accounts, journal, invoices, bank, reports, close, audit,
money, csv, dates. The modules know nothing about HTTP, which is why the
terminal can use them directly. No JavaScript is served at all; the one
stylesheet under static/ is named by its content hash and cached for a year.
This is an app: the forge runs it. Production follows the default branch;
every open pull gets a preview at its own address with its own copy of the
data. Everything the app keeps lives under FORGE_APP_DATA, which is what
makes "production data" and "preview data" two different things.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV
(production or preview), FORGE_APP_URL, FORGE_APP_SSH and
FORGE_APP_COMMIT when it starts the app. forge.json says how.