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:
2026-08-15 19:19:12 +02:00
co-authored by Claude Opus 5
parent ef6a231d98
commit ebf7770569
16 changed files with 3472 additions and 177 deletions
+95
View File
@@ -25,11 +25,25 @@ func (t TagTotal) Net() int64 { return t.In - t.Out }
const Untagged = "(untagged)"
// ByTag returns totals sorted by currency, then by largest outflow first.
//
// Matched transfer legs are left out entirely. Both legs of a transfer are
// dropped together, so a report over everything is unchanged in total by money
// the user moved between their own accounts; a report scoped to one account or
// one month may see only one leg, and dropping it is still right, since the
// money was not spent. What was left out is available from Excluded.
//
// A pair whose definition sets a tolerance_pct is dropped the same way, fee and
// all — so with such a definition in play the report *is* short by the fee, and
// callers are expected to show Excluded alongside it rather than leave that
// difference unexplained.
func ByTag(txns []model.Transaction) []TagTotal {
type key struct{ currency, tag string }
acc := map[key]*TagTotal{}
for _, t := range txns {
if t.IsTransferLeg() {
continue
}
tag := t.RuleTag
if tag == "" {
tag = Untagged
@@ -96,6 +110,87 @@ func Totals(rows []TagTotal) []CurrencyTotal {
return out
}
// TransferTotal is what ByTag left out for one currency.
//
// It counts legs rather than pairs on purpose: a report scoped to one account
// or month usually holds only one side of a movement, and claiming a whole
// transfer was excluded when only half of it was in view would be a lie. The
// same goes for the two directions, which is why they are separate totals: an
// exchange between currencies has its legs in different columns entirely, so
// one currency can see only what left and the other only what arrived.
type TransferTotal struct {
Currency string
Out int64 // sum of the leaving legs in view, positive
In int64 // sum of the arriving legs in view
Legs int
Digits int
// Fee is what those movements cost: the amount that left less the amount
// that arrived, for pairs whose legs are *both* in view. A definition with a
// tolerance_pct pairs legs that disagree, and since the pair leaves the
// report entirely, this is the only place that difference is still money
// rather than nothing. Zero when no definition allows a mismatch.
Fee int64
// Pairs counts the movements Fee was computed from — complete pairs in this
// currency, not legs. A report scoped to one account or month usually holds
// one side of a movement, and half a pair cannot say what the other half
// received, so it contributes to Legs and Out/In but not here.
Pairs int
}
// Excluded summarises the transfer legs ByTag dropped, so a report can account
// for the difference between its total and the account balances.
func Excluded(txns []model.Transaction) []TransferTotal {
// Both legs of a pair carry the same TransferID, so the ones whose partner
// is also in view can be found without going back to the index.
byID := map[int64][]model.Transaction{}
for _, t := range txns {
if t.IsTransferLeg() {
byID[*t.TransferID] = append(byID[*t.TransferID], t)
}
}
acc := map[string]*TransferTotal{}
row := func(t model.Transaction) *TransferTotal {
r, ok := acc[t.Currency]
if !ok {
r = &TransferTotal{Currency: t.Currency, Digits: t.MinorDigits}
acc[t.Currency] = r
}
return r
}
for _, t := range txns {
if !t.IsTransferLeg() {
continue
}
r := row(t)
r.Legs++
if t.AmountMinor < 0 {
r.Out += -t.AmountMinor
} else {
r.In += t.AmountMinor
}
}
for _, legs := range byID {
// An exchange has its legs in two currencies, and there is no rate here
// to subtract one from the other, so it has no fee to report.
if len(legs) != 2 || legs[0].Currency != legs[1].Currency {
continue
}
r := row(legs[0])
r.Pairs++
r.Fee -= legs[0].AmountMinor + legs[1].AmountMinor
}
out := make([]TransferTotal, 0, len(acc))
for _, r := range acc {
out = append(out, *r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency })
return out
}
// Months lists the distinct YYYY-MM present in txns, most recent first.
func Months(txns []model.Transaction) []string {
seen := map[string]bool{}