finfry
finfry
A command-line budget & expense tracker written in Crystal, built on the Jargon CLI shard. It keeps a proper double-entry ledger stored as plain JSON — every transaction moves money between accounts and always balances to zero.
Installation
shards install
shards build --release
The finfry binary is produced in ./bin.
Concepts
Accounts are hierarchical, colon-separated names. By convention:
Assets:*— what you own (Assets:Checking,Assets:Savings)Liabilities:*— what you owe (Liabilities:CreditCards:ChasePlatinum)Income:*— where money comes from (Income:Salary)Expenses:*— where it goes (Expenses:Food:Coffee)
Every transaction is a set of postings (an account + a signed amount) that sum to zero. You rarely write that by hand — the spend/earn/transfer commands build the balanced pair for you.
Chart of accounts
An account is known if it's been declared in the chart or already used by a posting. A new ledger is seeded with a small starter chart (Assets:Checking, Expenses:Food, …). How finfry reacts to an unknown account is set per ledger:
strict(default) — recording to an unknown account is rejected; declare it first withfinfry accounts add. The error suggests close matches, so a typo (Expenses:Foood) is caught instead of silently becoming a new account.guard— prompts ("Expenses:Foood— did you meanExpenses:Food? Create it? [y/N]") and declares it on confirmation.off— any referenced account is created silently.
finfry accounts # list known accounts (declared + used)
finfry accounts add Expenses:Food:Coffee Assets:Brokerage
finfry accounts rename Expenses:Foood Expenses:Food # rewrites postings; merges if target exists
finfry accounts rm Assets:Brokerage # remove from the chart
finfry accounts set Liabilities:CreditCards:Chase apr 19.99 # account metadata (apr, limit, due-day, …)
finfry accounts info Liabilities:CreditCards:Chase # balance + metadata
finfry accounts unset Liabilities:CreditCards:Chase apr
finfry accounts policy guard # strict | guard | off (no arg prints current)
Accounts can carry free-form metadata (accounts set <account> <key> <value>) — e.g. a credit card's apr, limit, due-day, bank. It shows in accounts list/info, is carried across rename, and is visible to the AI so it can reason about rates and terms. (Secret metadata — account numbers etc. kept out of the AI's view — is a planned follow-up; for now treat metadata as non-secret.)
The AI path respects the policy too: a proposal that introduces a new account shows it as New account: …, and your confirmation is the deliberate "yes" that declares it — even in strict mode.
Undo & corrections
There are two ways to take something back, matching how bookkeeping actually works:
finfry undoremoves the most recent change outright — as if it never happened. Safe because nothing has been recorded on top of it yet (like backspacing before it's part of the record). Repeatedundopops back through recent changes.finfry undo <id>corrects an older change (find the id withfinfry history) by posting a reversing entry — a mirror-image transaction (Reversal of #N) so the two net to zero. The original is kept and the audit trail is preserved, because you can't un-happen history that later entries sit on top of.
finfry redo brings back the change undo just removed (single level; any new change invalidates it). finfry history lists changes and marks which have been reversed or undone. Change numbers are the log's commit numbers, so they have gaps where bookkeeping commits (cleared marks, due staging, rules, metadata) sit between ledger changes — history --all shows those too.
Recurring entries
Define a recurring commitment, and finfry generates a queue of due occurrences you review and approve — nothing posts automatically.
finfry recurring add 15.49 Expenses:Subscriptions:Netflix -m Netflix -e monthly
finfry recurring add 1200 Expenses:Housing -m Rent -e monthly -c Assets:Savings # -c/--counter: the other account
finfry recurring list
finfry recurring off 2 # stop generating new occurrences
recurring add works for expenses, income (--kind income), or transfers (--kind transfer), with --every <cadence> and an optional --start date (back-date it to catch up missed cycles).
Credit-card interest is a rule whose amount is computed each cycle from the card's apr metadata applied to what's owed:
finfry accounts set Liabilities:CreditCard apr 19.99 # (or pass --apr below)
finfry recurring interest Liabilities:CreditCard --every monthly
Each cycle, due shows the computed charge (apr × balance owed, for that cadence's slice of the year) for you to review/adjust/post like any other; if nothing's owed, no charge is generated.
Then review what's due and apply it in a stage-then-post cycle:
finfry due # list due occurrences (catches up since last time)
finfry due ok 1 3 # stage entries to post (or: due ok all)
finfry due skip 2 # stage to drop (won't reappear)
finfry due edit 4 --amount 64.20 # adjust a variable bill (marks it ok)
finfry due reset 3 # clear a staged decision back to pending
finfry due post # apply: post the ok'd, drop the skipped
Unstaged entries just stay pending for next time. Each posted occurrence is a normal undoable transaction (tagged with its cadence, so it shows in report daily).
Reconciliation
Reconciling proves your ledger agrees with a bank or card statement. It works in two tiers, per account (so checking and a credit card reconcile independently) — the same stage-then-commit shape as due:
- cleared — staged. "I think this hit the bank." You toggle it while working through the statement; it stays visible and is freely reversible.
- reconciled — committed. Locked in by a finished, statement-balanced reconciliation. These drop off the working list.
finfry reconcile Assets:Checking # working list + cleared/ledger balances
finfry reconcile Assets:Checking clear 1 2 # stage: mark transactions that hit the statement (or 'all')
finfry reconcile Assets:Checking unclear 2 # unstage (or 'all')
finfry reconcile Assets:Checking balance 2738.00 # check cleared balance against the statement total
finfry reconcile Assets:Checking commit 2738.00 # finalize (only if it balances)
The account always comes first — every form is reconcile <account> <action> …, where each action owns its trailing argument: clear/unclear take ids, balance/commit take the statement balance. (Keeping that slot a fixed set of verbs means a forgotten action — reconcile A 1 2 — fails loudly instead of misreading 1 as a balance.) The status view lists every not-yet-reconciled transaction touching the account, marking staged-cleared ones with *, alongside the cleared balance (reconciled + staged — what should match a statement) and the full ledger balance. balance <amount> tells you whether they agree or are off by some amount.
commit finalizes: it locks the staged transactions into the reconciled tier — but only if the cleared balance matches the statement, so you can never reconcile to a wrong number. Each commit records the statement it balanced against and is shown as "last reconciled" thereafter. Pass --as-of <date> with the statement's closing date: a reconciliation asserts "as of this date the bank said the balance was this", so the date that matters is the statement's, not the day you got around to it (the finalized date is kept too, as an audit stamp):
finfry reconcile Assets:Checking commit 2738.00 --as-of 2026-08-31
finfry reconcile Assets:Checking history
# 2026-08-31 $2,738.00 (14 txns) finalized 2026-09-20
If you're off by a small unexplained amount you can't track down, add --adjust:
finfry reconcile Assets:Checking commit 2738 --adjust
This posts the residual as a normal, balanced, undoable transaction to Expenses:ShortsAndOverages (the classic "cash over and short" account) — one leg closes the gap on the account, the other records the difference — then commits. It never edits an existing entry or breaks double-entry. This is only for small residuals; a real, explainable difference (a forgotten bill, a fee) should be entered manually to its proper account, then reconciled normally.
Clearing and committing are bookkeeping metadata, not ledger changes — they never alter balances. The AI can read reconciliation status, but staging and committing stay a human, statement-in-hand task.
Sign convention
Amounts are signed cents internally. Assets/Expenses are debit-normal (positive), Income/Liabilities/Equity are credit-normal. Reports flip the sign on credit-normal accounts so they read as positive numbers.
Usage
AI assistant
finfry ai lets you ask questions or make changes in plain English. Under the hood every finfry command is exposed to Claude as a tool, so it can read the ledger to answer you and propose changes for you to approve.
export ANTHROPIC_API_KEY=sk-ant-...
# Ask — read-only, answered directly
finfry ai "what did I spend on food last month?"
# Record — one or many changes, gathered into a plan you approve
finfry ai "spent $50 at Starbucks yesterday on my Chase Platinum CC"
finfry ai "set next month's food budget 10% under what I spent in May"
echo "netflix 15.49 monthly" | finfry ai --yes
How it works:
- Read tools (
register,general_ledger,balance,report,balance-sheet,daily,accounts,history,recurring,due,reconcile) run immediately so the AI can answer and gather context. - Write tools (
spend,earn,transfer,budget,accounts add/rename,recurring add/interest/off,due ok/skip/edit/post) don't take effect right away — they're collected into a plan that finfry shows you and applies only once you approve it (--yesskips the prompt). The whole plan applies as one undoable change. - It reuses your existing accounts for consistency and resolves relative dates. finfry assembles the balanced postings and enforces the account policy, so the AI can't throw off the books.
deleteandaccounts policyare withheld from the AI on purpose.
Set FINFRY_MODEL to override the model (default claude-opus-4-8).
Use finfry inside an agent harness (MCP)
finfry mcp runs finfry as an MCP server over stdio, exposing the same safe command surface as tools so any MCP client (Claude Code, Claude Desktop, …) can read and update the ledger — with the harness providing the chat, history, and approval UI.
The easiest path is finfry init (above) — it writes a .mcp.json into the book directory, so any MCP client opened there picks up a finfry server pinned to that book automatically. To register one manually instead (standard config shape):
{
"mcpServers": {
"finfry": { "command": "finfry", "args": ["mcp"] }
}
}
Or with the Claude Code CLI — note the scopes: --scope project writes a shared .mcp.json in the directory (what finfry init does), --scope local is a private per-directory entry in your user config, and --scope user is global:
claude mcp add finfry --scope project -- finfry mcp
Notes:
- The client (not finfry) gates each tool call, so writes execute when invoked — each still records its own undoable change, and the account policy + balance guards still apply.
deleteandaccounts policyare not exposed over MCP (same withholding as the built-in agent).- Set
FINFRY_DATAin the server's env if you want it pointed at a specific ledger.
Web UI
finfry serve opens the same book in a browser — every report and the register as pages, plus forms for the entry commands and the two stage-then- commit workflows (due queue, reconciliation), which are where clicking beats typing:
finfry serve # http://127.0.0.1:4747 (Ctrl-C to stop)
finfry serve -p 8080 -o # another port, and open it in your browser
It is a plain multi-page app: pages are rendered server-side, forms POST and redirect, and a few lines of JavaScript let the due and reconcile pages save a toggle without a reload (everything still works with JS off). Conventions set the defaults, never the limits: there is one Record form — money moves from one account to another, and the kind (expense, income, transfer) is inferred from the accounts, so any pair works (a card payment, a refund, a reclassification). A memo you've used before fills in the blanks from its last entry (never what you've typed), account suggestions are ordered by how recently you used them, and every note about a change you just made carries an Undo — which only fires if that change is still the latest. Reconcile opens on "what needs reconciling" (every asset and liability account with its cleared balance and what's outstanding); an account's page follows the GnuCash shape — statement date and closing balance first, lines split into withdrawals and deposits (charges and payments for a card), the difference to the statement front and center, and commit only when it's zero. Every write goes through the same command path as the CLI, so the account policy, the balance guards and the undo journal apply exactly as on the command line — the command's own output shows as a note on the next page. Unknown accounts under guard are rejected (there's no prompt to answer); declare them first on the Accounts page.
finfry serve --dev (or just dev) adds a ✎ button (and Alt+N): click it, then click any element on the page — a button or input is picked, not triggered — and describe what's wrong. The note lands in dev/ui-notes.md with the page, the view's template file, a path to the element and its table context, so whoever fixes it can go straight to the spot.
The server re-reads the book whenever another process writes it, so you can keep using the CLI or an MCP session alongside it. It binds 127.0.0.1 only and has no authentication — it's for you, on your machine. Splits (add), account metadata/renames and recurring-rule definitions stay CLI-only for now.
Manual entry
# Spend (default funding account is Assets:Checking)
finfry spend 50 Expenses:Food:Coffee -m "Starbucks" -d 2026-06-15
# Spend on a credit card (any account works as the source)
finfry spend 89.99 Expenses:Shopping -f Liabilities:CreditCards:ChasePlatinum
# Income
finfry earn 3000 Income:Salary
# Tag recurring items with a cadence (daily/weekly/biweekly/monthly/quarterly/yearly)
finfry spend 15.49 Expenses:Subscriptions:Netflix -m Netflix -r monthly
finfry earn 4000 Income:Salary -m Salary -r monthly
# Move money between accounts
finfry transfer 500 --from Assets:Checking --to Assets:Savings
# Split one purchase across several accounts. A trailing lone account is
# inferred so the transaction balances:
finfry add -m "market run" \
Expenses:Food:Groceries 42.00 \
Expenses:Food:Snacks 8.00 \
Assets:Checking # amount inferred: -50.00
# Reports
finfry register [-a Expenses:Food] [-m 2026-06] [-n 10] # transactions (the ledger register)
finfry register -a Assets:Checking # account view: compact, with a running balance
finfry register -s 2026-06-01 -u 2026-06-30 --min 100 # filter by date range / amount...
finfry register -q rent # ...or memo text (--match)
finfry balance [Assets] # account balances (quick lookup)
finfry ledger [Assets] [-s 2026-09-01] [-u 2026-09-30] # general ledger: each account's page, balance brought forward, running balance
finfry report # income statement (default)
finfry report income [-m 2026-06] # income statement
finfry report balance-sheet [-d 2026-06-30] # balance sheet + integrity check
finfry report daily # per-day cost of recurring items
finfry report balance [Assets] # account balances (also under report)
finfry accounts # accounts in use
finfry history [-n 10] [--all] # change history (--all: every commit in the log)
finfry undo # remove the most recent change
finfry undo 4 # reverse an older change (correcting entry)
finfry redo # bring back the change undo just removed
finfry init [dir] # create a per-directory book
finfry path # print the active ledger file
finfry books # books opened before, most recent first
finfry version # print the finfry version
finfry serve [-p 4747] [-o] # web UI for this book (local only)
# Reconcile an account against a bank/card statement (account always comes first)
finfry reconcile Assets:Checking # working list + cleared/ledger balances
finfry reconcile Assets:Checking clear 1 2 5 # stage transactions as cleared (or 'all')
finfry reconcile Assets:Checking unclear 5 # unstage (or 'all')
finfry reconcile Assets:Checking balance 2738.00 # check cleared balance against the statement
finfry reconcile Assets:Checking commit 2738.00 # finalize (locks staged, only if it balances)
finfry reconcile Assets:Checking commit 2738.00 --adjust # ...or book a small residual to Shorts&Overages
finfry reconcile Assets:Checking commit 2738.00 --as-of 2026-08-31 # stamp the statement's closing date
finfry reconcile Assets:Checking history # past finalized reconciliations (audit trail)
# Budgets (per account, rolled up over the subtree)
finfry budget set Expenses:Food 400
finfry budget list
finfry budget rm Expenses:Food
# Delete a transaction by id (shown in `register`)
finfry delete 3
Run finfry --help, or finfry <command> --help, for full options.
Recurring items & daily cost
Tag a transaction with -r <cadence> to mark it recurring. finfry report daily then shows what each commitment costs per day, derived from its cadence (e.g. a $15.49/month subscription is 15.49 ÷ (365.25/12) ≈ $0.51/day), plus a total burn rate projected to monthly and yearly equivalents:
Recurring expenses
Rent monthly $1,200.00 → $39.43/day
Netflix monthly $15.49 → $0.51/day
Prime yearly $119.88 → $0.33/day
Total $41.58/day ($1,265.48/mo, $15,185.76/yr)
A recurring stream is identified by its description (or, if blank, the accounts it touches), and only its most recent occurrence counts — so recording the same monthly bill repeatedly doesn't inflate the daily figure.
Amounts
Amounts accept a $ and thousands separators ($1,234.56) and are stored as integer cents, so there is no floating-point rounding error.
Data storage & books
A book is two files with one base name: finfry.log, the truth — an append-only log of every change, as commits of small actions — and finfry.json, a checkpoint of the state those commits fold to, regenerable at any time (delete it and the next command rebuilds it from the log). The log is a C0DATA stream-mode file kept by the keep shard: each commit is one crash-safe block, so an interrupted write can never be mistaken for a complete one, and several processes (the CLI, finfry serve, an MCP session) can write to the same book safely. Because nothing is ever erased — undo appends a mark that the fold skips — the log is the backup and the audit trail: finfry history --all reads it end to end, with who (cli / web / mcp / ai) did what, when, and why.
An older single-file book is migrated the first time it's opened: its journal becomes commits, the rest of its state one migrated commit, and the original is kept beside them as finfry.json.pre-log.
finfry finds the active book by, in order:
FINFRY_DATA— an explicit path override (the.json; the log sits beside it).- The nearest
finfry.jsonfound by walking up from the current directory (like git's.git) — this is a per-directory book. - The global ledger at
$XDG_DATA_HOME/finfry/data.json(typically~/.local/share/finfry/data.json).
finfry path prints whichever is active, and finfry books lists every book finfry has opened, most recent first — a registry that fills itself as a side effect of use (kept under $XDG_STATE_HOME/finfry/books.json, i.e. ~/.local/state/finfry/books.json), so a book you set up months ago is one cd or FINFRY_DATA= away:
$ finfry books
● ~/Documents/my-money/finfry.json last opened 2026-09-21 16:26 (active)
~/.local/share/finfry/data.json last opened 2026-09-21 13:14 (global)
In the web UI the book's path under the brand is a switcher over the same list: pick another and the running server opens it (nothing is copied; it only opens books already in the list, never an arbitrary path).
Create a per-directory book with finfry init (defaults to the current directory; pass a path to use another):
mkdir ~/finances && cd ~/finances && finfry init
# Initialized finfry book at /home/you/finances/finfry.json
# Wrote /home/you/finances/.mcp.json (finfry MCP server pinned to this book)
Running finfry anywhere under that directory then uses that book. init also writes a .mcp.json registering a finfry MCP server pinned to that book — so an MCP client (Claude Code, …) opened in that directory gets AI access to that book only, with no per-book setup. (Pass --no-mcp to skip it; an existing .mcp.json is merged, not overwritten.) Without a book in scope, finfry uses the global ledger, so casual single-book use still works anywhere.
Checkpoint writes are atomic (temp file + rename); log writes are single appends. The oldest single-entry ledger format is migrated to double-entry, then to a log, on first load.
Development
just dev serves an example book (dev/finfry.json, built by eg/seed.sh on first run — three months of a typical household, budgets, recurring rules, a reconciled July statement) with the UI note picker on. just seed rebuilds it from scratch. Your real books are never touched: put them in a directory of their own with finfry init and run finfry serve there.
crystal spec # run the test suite
shards build # build a debug binary
crystal tool format # format the source
Layout:
src/finfry/money.cr— parse/format money as integer centssrc/finfry/models.cr—Posting,Transaction,Databaserecordssrc/finfry/recurrence.cr— cadence→per-day amortization and recurring-item rollupsrc/finfry/store.cr— JSON persistence, queries, legacy migrationsrc/finfry/ai.cr— theFinfry::AIseam: a tool-use conversation loop over the Claude API (raw HTTP, no SDK); swappable behind one modulesrc/finfry/mcp.cr— a stdio MCP server exposing the safe command surface as tools, reusing the same registry and executor as the built-in agentsrc/finfry/app.cr— Jargon CLI definition and command handlers; the sharedcommit/render/postings_forcore that the manual and AI entry paths sharesrc/finfry/queries.cr— the read side split from its rendering: view structs (RegisterView,IncomeStatement,ReconcileView, …) and theAppquery methods that build them, shared by the CLI renderers and the web pagessrc/finfry/web.cr+src/finfry/web/— the web UI: anHTTP::Serverrouter, ECR page templates, and the stylesheet/script (compiled into the binary, sofinfry serveis self-contained)src/cli.cr— executable entry point
Roadmap
- Standard accounting names. The views don't yet use the textbook words consistently. In bookkeeping terms: the unfiltered
registeris the journal (every transaction with its debits and credits);register -a <account>is a ledger account page — and "register" is the right end-user word for that one (a checkbook register is exactly one account with a running balance);ledgeris the general ledger;balanceis roughly the trial balance. Decide the vocabulary once and rename across the CLI, the AI/MCP tool names, and the web nav together — not piecemeal. - A lightweight built-in chat fallback (a thin REPL over
finfry ai) for when no external harness is available - AI support for split transactions (multiple categories in one entry)
Contributing
- Fork it (https://github.com/transfire/finfry/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Thomas Sawyer - creator and maintainer
finfry
- 0
- 0
- 0
- 0
- 2
- 11 days ago
- June 18, 2026
MIT License
Tue, 22 Sep 2026 01:51:41 GMT