# money Statement-driven personal finance tracker. A data directory holds one folder per account; statements dropped into those folders are parsed into a rebuildable SQLite index, categorised by ordered glob rules, and browsed in a Bubble Tea TUI. Read `README.md` first — it is the user-facing reference for every config key. This file covers what the code assumes and why. ## Layout ``` cmd/money/main.go subcommands; the TUI is the default internal/config rules.toml, account.toml, XDG config, data-root resolution internal/model Account, Transaction, amount formatting, description normalisation internal/glob the `*` / `?` matcher used by rules (linear time, no backtracking) internal/parser Parser interface + registry; csv, cmd, nlb, revolut, traderepublic internal/store SQLite index (modernc.org/sqlite, no cgo) internal/importer directory walk, dedupe, balance checks internal/rules applies ordered rules to rule_* columns only internal/report per-tag aggregation, transfers excluded internal/tui Bubble Tea models ``` ## Invariants **Rule verdicts and manual edits never share a column.** `rule_tag` / `rule_transfer` are rewritten wholesale on every retag; `manual_tag` / `manual_transfer` are only ever written by the user. Effective values are `COALESCE(manual_*, rule_*)`. This is what makes `money retag` safe to run at any time, and it is the first thing to preserve when touching the schema or the rules engine. Covered by `TestManualTagSurvivesRetag`. **Statements are the source of truth; the index is disposable.** Deleting `.money/index.db` and re-importing must reproduce everything except manual tags and manual transfer marks. **Dedupe is by fingerprint**: `sha256(date | amount | normalised description | ordinal)`, where the ordinal distinguishes identical lines *within one statement*. Two identical purchases on one day both survive; the same line in an overlapping statement does not duplicate. Unchanged files are skipped by checksum before parsing at all — so an import with nothing new finishes in milliseconds. That is the checksum skip working, not a failure. **Money is `int64` minor units**, never a float. Per-account currency, no conversion, and totals are never summed across currencies. **First matching rule wins**, so transfer rules belong above general tag rules. A rule setting several of `match` / `counterparty` / `type` requires all of them. `config.AppendRule` therefore appends — never prepends — so saving from the rule builder cannot shadow a rule the user wrote by hand. **The rule builder is a form, so the global keymap must not apply there.** `Update` routes to `updateRules` before `updateNormal` whenever the view is `viewRules`, or typing `q` would quit and `i` would start an import. Any new full-screen input needs the same treatment. **In the rule builder, `tab` completes first and moves focus second.** The account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all three already meant something here. So `updateRules` intercepts `tab` and calls `acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling. `SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets it afterwards or `ctrl+n` would offer candidates that no longer fit the value. **A rule's usage count is how many transactions it wins, not how many its glob could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an earlier one correctly reports zero. That is what makes the rules screen able to find dead rules at all. **A rule's `note` is documentation that round-trips.** It is a TOML key rather than a `#` comment so `LoadRules` can return it, the builder can write it and the rules screen can show it. It never takes part in matching — `rules.Engine` does not look at it — and it must stay that way. **`config.DeleteRules` edits rules.toml textually, never by re-serialising the parsed rules**, because comments and formatting are not recoverable from `[]Rule`. A rule owns the comment lines directly above it; a comment separated by a blank line is a heading for what follows and stays. Both writers go through `writeFileAtomic`, and deletion re-parses the result before replacing the file. ## Adding a bank parser Implement `parser.Parser` and call `parser.Register` from an `init`. Nothing else changes; the name becomes usable in an `account.toml`. Use `parser.ParseAmount` rather than hand-rolling decimal handling — it copes with `1.234,56`, trailing minus, parenthesised negatives, currency codes and both the ASCII hyphen and U+2212. Keep PDF text extraction separate from parsing: the bank parsers expose a pure `parseXText(text string, digits int)` so they can be tested against captured `pdftotext` output without a PDF fixture. `pdftotext -layout` (poppler-utils) is a runtime dependency of `nlb` and `traderepublic`; no Go library reconstructs column layout as well. Do not hardcode absolute column positions from a sample PDF. `pdftotext` compresses runs of spaces, so columns shift with font and page size — derive positions from the header line (`traderepublic`) or from the line being parsed (`nlb`). ## Verifying ``` go build ./... && go test ./... && go vet ./... && gofmt -l . ``` For end-to-end checks, build a throwaway data root under the scratchpad rather than touching real data: ``` money --root /tmp/.../demo import money --root /tmp/.../demo import # must report 0 new money --root /tmp/.../demo ls --wide ``` ### Driving the TUI in tests Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every existing TUI test works, and it covers the keymap without a terminal. If a real terminal is genuinely needed, note that piping into `script` does not deliver keystrokes. Use a pty and answer the two capability queries Bubble Tea sends on startup, or the program blocks before its first render: - `ESC]11;?` (background colour) → reply `ESC]11;rgb:1e1e/1e1e/1e1e ESC\` - `ESC[6n` (cursor position) → reply `ESC[1;1R` Also delete the index first, or the import you are trying to observe will be skipped by checksum and finish instantly. ## Conventions Comments explain why, not what. Errors name the file and the offending row or key so a bad statement is actionable. Per-file import failures are reported and the run continues; only a broken data root aborts.