Pair transfers from rules.toml
The boolean transfer flag went two commits ago because a one-sided verdict let half a movement vanish and left the report unbalanced. This is what replaces it: a [[transfer]] block names both legs, and only a matched pair is dropped from the report -- both legs together, never one. Legs pair within five days, nearest date first, and a transaction belongs to at most one transfer, so the first definition to claim a leg keeps it, exactly as the first matching rule keeps a tag. The pairing is derived state like the tags: Engine.Link rewrites the whole transfers table from rules.toml, which is why retag re-derives both halves of what that file decides, and why it runs over the whole index rather than a filtered view -- pairing inside one would let a movement count as a transfer in one report and not in another. An unmatched leg is not a transfer and keeps counting, surfaced as a warning instead. Within one currency the amount is the evidence and must be the exact opposite. Across currencies it is not checked at all: there are no rates here, so the two numbers are unrelated and the dates carry the pairing alone. tolerance_pct is the one exception, per definition, for a route where the bank takes a fee and the two statements genuinely disagree. It defaults to zero and belongs on the one definition that charges; a global or default tolerance would loosen every route that does not. The difference it admits is not forgiven -- the pair leaves the report entirely, so a fee hidden inside one would be spending that appears nowhere. Pair.Fee is what left less what arrived, and report.Excluded carries it out per currency alongside the legs. It counts only pairs whose legs are both in view, for the same reason it counts legs and not transfers: half a pair cannot say what the other half received. The screens: - 6 builds a definition against the index as you type, showing the pairs it would form and the legs it would catch but leave unpaired. Six fields need more room than the rule builder's four, so the form sheds its spacing, then its hints, then the borders on unfocused fields. - 7 lists every definition with what it pairs. Two counts, because they mean different things: an unpaired leg is a definition doing something and not finishing it, no pairs at all is dead weight. Tol names the tolerance, blank where amounts must agree. - 3 grows a (transfers) row under TOTAL, and a fees row beneath it, or the report silently disagrees with the account balances. Two things that are not part of transfers but are the same day's work: - ls --uniq lists each account and description once, normalised the way a glob sees them, which is the shape of "what still needs a rule?" -- fifty visits to one shop are one pattern to write, not fifty rows to read. - The rule builder's preview now filters to what the glob matches instead of marking matches in a full list. The count carries the context the rows no longer can: 2 of 7, measured against everything still in view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,14 @@ This file covers what the code assumes and why.
|
||||
|
||||
```
|
||||
cmd/money/main.go subcommands; the TUI is the default
|
||||
internal/config rules.toml, account.toml, XDG config, data-root resolution
|
||||
internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
||||
internal/model Account, Transaction, amount formatting, description normalisation
|
||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||
internal/parser Parser interface + registry; nlb, revolut, traderepublic
|
||||
internal/store SQLite index (modernc.org/sqlite, no cgo)
|
||||
internal/importer directory walk, dedupe, balance checks
|
||||
internal/rules applies ordered rules, writing rule_tag
|
||||
internal/transfers pairs the two legs of a movement, writing the transfers table
|
||||
internal/report per-tag aggregation
|
||||
internal/tui Bubble Tea models
|
||||
```
|
||||
@@ -30,6 +31,68 @@ is what makes `money retag` safe to run at any time. Nothing may write a tag
|
||||
from anywhere else — the moment something does, the index stops being
|
||||
reproducible and retag stops being safe. Covered by `TestRetagRewritesEveryTag`.
|
||||
|
||||
**A transfer is a pair or it is nothing.** A `[[transfer]]` block names both
|
||||
legs (`from_account` + `from_desc`, `to_account` + `to_desc`), and only a
|
||||
matched pair is dropped from the report — both legs together, never one. This
|
||||
is the whole reason the earlier boolean `transfer` flag was removed and this
|
||||
replaced it: a one-sided verdict let half a movement vanish and left the report
|
||||
unbalanced. An unmatched leg therefore keeps counting, and is surfaced as a
|
||||
warning instead: `⚠` on the transfers screen, on stderr from `import`/`retag`.
|
||||
Nothing may start excluding a single leg on the strength of one side matching.
|
||||
|
||||
Legs pair within `transfers.WindowDays`, nearest date first. **Within one
|
||||
currency the amount is the evidence and must be the exact opposite**, unless
|
||||
the definition sets `tolerance_pct` — a per-definition opt-in for a route where
|
||||
the bank takes a fee on the way, so the two statements genuinely disagree. It
|
||||
defaults to zero and belongs on the one definition that charges: a global or
|
||||
default tolerance would loosen every route that does not. **Across currencies
|
||||
the amount is not checked at all**: there are no exchange rates here, so the two
|
||||
numbers are unrelated and the dates carry the pairing alone. A tolerance means
|
||||
nothing there and is ignored. That asymmetry is the design, not an oversight —
|
||||
do not "fix" the cross-currency case by inventing a rate, and do not turn the
|
||||
tolerance into the default. Like rules, the first definition claims a leg and a
|
||||
transaction belongs to at most one transfer.
|
||||
|
||||
**A tolerated mismatch is a fee, and a fee is money, so it is reported rather
|
||||
than forgiven.** That is the condition on the tolerance existing at all: the
|
||||
pair leaves the report entirely, so a difference swallowed inside one would be
|
||||
spending that never appears anywhere. `Pair.Fee` is what left less what
|
||||
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
||||
named on the `money report` line and on its own row under the TUI's report.
|
||||
Nothing may pair on a mismatch without that difference reaching `Excluded`.
|
||||
It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it,
|
||||
and it must not be turned into a synthetic transaction to make the total
|
||||
reconcile. `Excluded` counts it only for pairs whose legs are *both* in view,
|
||||
for the same reason it counts legs and not pairs — half a pair cannot say what
|
||||
the other half received.
|
||||
|
||||
**`(transfer)` in a tag column is display only.** `Transaction.DisplayTag`
|
||||
falls back to `model.TransferTag` for a paired leg so the column does not read
|
||||
as a blank waiting for a rule, but nothing writes it: `rule_tag` stays what
|
||||
rules.toml made it, `store.Tags` never returns it, and no rule can be built
|
||||
from it. It is the same kind of label as `report.Untagged`. Do not "persist"
|
||||
it — that is precisely the second verdict this design exists to avoid.
|
||||
|
||||
**A paired leg is not untagged.** `store.Filter{Untagged: true}` means "no tag
|
||||
*and* no transfer", so a leg never turns up in `money ls --untagged`, the `u`
|
||||
view or the rule builder's preview asking to be tagged — it is spoken for, and
|
||||
the report drops it regardless. Covered by `TestPairedLegsAreNotUntagged`. An
|
||||
*unpaired* leg is untagged like anything else, which is how it gets noticed.
|
||||
|
||||
**The pairing is derived state, exactly like the tags.** `Engine.Link` rewrites
|
||||
the whole `transfers` table from rules.toml, so `money retag` re-derives both
|
||||
halves of what that file decides. It runs over the whole index deliberately —
|
||||
pairing within a filtered view would let a movement count as a transfer in one
|
||||
report and not in another. Note the asymmetry with a schema *column*: a new
|
||||
table is created by `CREATE TABLE IF NOT EXISTS` in `store.Open`, so adding one
|
||||
does not force an index rebuild.
|
||||
|
||||
**Rules and transfers share rules.toml, so textual deletion is block-aware.**
|
||||
`config.deleteBlocks` finds every `[[...]]` header, not only the kind being
|
||||
deleted, because a block ends where the *next* block of any kind begins —
|
||||
otherwise deleting a rule would swallow a transfer that follows it. Covered by
|
||||
`TestDeleteLeavesTheOtherKindAlone`.
|
||||
|
||||
**Statements are the source of truth; the index is disposable.** Deleting
|
||||
`index.db` at the root of the data directory and re-importing must reproduce
|
||||
everything, with nothing lost.
|
||||
@@ -66,10 +129,11 @@ A rule setting both `match` and `type` requires both 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.
|
||||
**The builders are forms, so the global keymap must not apply there.**
|
||||
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
||||
whenever the view is `viewRules` or `viewTransfers`, 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`
|
||||
|
||||
Reference in New Issue
Block a user