Document the invariants and testing recipes in CLAUDE.md
Records what the code assumes rather than what it does: why rule verdicts and manual edits live in separate columns, how the dedupe fingerprint works, why column positions must not be hardcoded from a sample PDF, and the pty handshake needed to drive the TUI in a real terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user