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.

Running e2ed9cc https://books--forge-trevato-apps.open-platform.sh in production 21d 14h

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_PASSWORD in 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.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs