Files
money/internal/transfers/transfers.go
T
nikolaandClaude Opus 5 ebf7770569 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>
2026-08-15 19:19:12 +02:00

262 lines
8.9 KiB
Go

// Package transfers pairs the two legs of money moved between the user's own
// accounts, as described by the [[transfer]] blocks in rules.toml.
//
// A transfer is always a pair. One leg on its own is not a transfer, it is an
// unmatched leg: money that left an account and cannot be shown to have
// arrived. That distinction is the whole point of pairing here rather than
// flagging single transactions, because only a complete pair can be dropped
// from the report without unbalancing it.
package transfers
import (
"math"
"sort"
"time"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/glob"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/store"
)
// WindowDays is how far apart the two legs may be dated. A transfer between
// two banks is one movement seen twice, but the statements rarely agree on the
// day: the money leaves on Friday and lands on Monday.
const WindowDays = 5
// Engine pairs legs using the definitions in file order. As with rules, the
// first definition to claim a transaction keeps it, so an earlier definition
// can never have a leg stolen by a later one.
type Engine struct {
defs []config.Transfer
}
// New builds an engine from the parsed rules file.
func New(r *config.Rules) *Engine { return &Engine{defs: r.Transfer} }
// Transfers returns the ordered definitions, as loaded from rules.toml.
func (e *Engine) Transfers() []config.Transfer { return e.defs }
// Pair is one matched movement: the leg that left and the leg that arrived.
type Pair struct {
Def int // index of the definition that claimed it
Out model.Transaction
In model.Transaction
}
// Fee is what the movement lost on the way: the amount that left, less the
// amount that arrived. It is non-zero only for a definition carrying a
// tolerance_pct, and it is money genuinely spent — a pair leaves the report
// entirely, so this is the one number that has to be reported separately or it
// vanishes with the legs. Negative would mean more arrived than left.
//
// Across currencies it is always zero: the two amounts are in different units,
// so subtracting them would produce a number that means nothing.
func (p Pair) Fee() int64 {
if p.In.Currency != p.Out.Currency {
return 0
}
return -(p.Out.AmountMinor + p.In.AmountMinor)
}
// Leg is a transaction a definition caught on one side but could not pair.
type Leg struct {
Def int
Txn model.Transaction
// Out reports which side it was caught on: true for the leaving leg
// (from_account, from_desc), false for the arriving one.
Out bool
}
// Result is what a definition set makes of a set of transactions.
type Result struct {
Pairs []Pair
Unmatched []Leg
// Paired and Orphaned are per-definition counts, positionally matching the
// definitions. A definition with no pairs and no orphans matches nothing at
// all; one with orphans is catching transactions but not completing them.
Paired []int
Orphaned []int
}
// Analyze pairs every leg it can. Transactions are claimed at most once across
// the whole run, so the result is a partition, not a set of overlapping
// interpretations.
func (e *Engine) Analyze(txns []model.Transaction) Result {
res := Result{
Paired: make([]int, len(e.defs)),
Orphaned: make([]int, len(e.defs)),
}
// Deterministic input order: the index returns newest first, and pairing
// walks forward in time so the earliest leg gets the earliest counterpart.
ordered := append([]model.Transaction(nil), txns...)
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].Date != ordered[j].Date {
return ordered[i].Date < ordered[j].Date
}
return ordered[i].ID < ordered[j].ID
})
claimed := map[int64]bool{}
for d := range e.defs {
def := &e.defs[d]
var outs, ins []model.Transaction
for _, t := range ordered {
if claimed[t.ID] {
continue
}
switch {
case t.AmountMinor < 0 && matches(def.FromAccount, def.FromDesc, t):
outs = append(outs, t)
case t.AmountMinor > 0 && matches(def.ToAccount, def.ToDesc, t):
ins = append(ins, t)
}
}
used := map[int64]bool{}
for _, out := range outs {
j := bestCounterpart(out, ins, used, def.TolerancePct)
if j < 0 {
continue
}
in := ins[j]
used[in.ID], used[out.ID] = true, true
claimed[in.ID], claimed[out.ID] = true, true
res.Pairs = append(res.Pairs, Pair{Def: d, Out: out, In: in})
res.Paired[d]++
}
for _, t := range outs {
if !used[t.ID] {
res.Unmatched = append(res.Unmatched, Leg{Def: d, Txn: t, Out: true})
res.Orphaned[d]++
}
}
for _, t := range ins {
if !used[t.ID] {
res.Unmatched = append(res.Unmatched, Leg{Def: d, Txn: t})
res.Orphaned[d]++
}
}
}
return res
}
// matches reports whether a transaction is on the named account and its
// description fits the glob. Descriptions are normalised the same way the
// tagging rules normalise them, so one pattern behaves the same in both places.
func matches(account, pattern string, t model.Transaction) bool {
return t.AccountSlug == account && glob.Match(pattern, model.NormalizeDescription(t.Description))
}
// bestCounterpart finds the arriving leg for out, dated within the window. The
// closest date wins, so two identical monthly transfers pair up in order
// instead of crossing over.
//
// Within one currency the amount is the evidence: an exact opposite is near
// proof that two legs are one movement, so it is what is required by default.
// tolerancePct widens that, and only that, for a route where the bank takes a
// fee on the way and the two statements therefore disagree. The difference it
// admits is not forgiven — it is real money, it is reported as the pair's Fee,
// and report.Excluded carries it out of the report so it cannot be lost inside
// a transfer. Which is why the default stays zero: every percent of slack is
// also a percent more chance of pairing two unrelated movements.
//
// Across currencies there is no such evidence. The tool holds no exchange
// rates, so the two numbers are unrelated and the dates carry the pairing on
// their own. That is weaker, and it is meant to be: it pairs an exchange
// between your own accounts, and it will pick the wrong counterpart if the
// same route is used twice inside one window. A tolerance means nothing there
// and is ignored.
func bestCounterpart(out model.Transaction, ins []model.Transaction, used map[int64]bool, tolerancePct float64) int {
outDay, ok := day(out.Date)
if !ok {
return -1
}
allowed := allowance(out.AmountMinor, tolerancePct)
best, bestGap, bestOff := -1, 0, int64(0)
for j, in := range ins {
if used[in.ID] {
continue
}
off := int64(0)
if in.Currency == out.Currency {
off = in.AmountMinor + out.AmountMinor
if off < 0 {
off = -off
}
if off > allowed {
continue
}
}
inDay, ok := day(in.Date)
if !ok {
continue
}
gap := int(inDay.Sub(outDay).Hours() / 24)
if gap < 0 {
gap = -gap
}
if gap > WindowDays {
continue
}
// Strictly closer, so an equal gap keeps the candidate already found.
// ins is in date order, which makes that the earlier one. Under a
// tolerance an equal gap can still be decided on the amount, and the
// nearer amount is the better evidence; with no tolerance every
// candidate is exact and this never fires.
if best < 0 || gap < bestGap || (gap == bestGap && off < bestOff) {
best, bestGap, bestOff = j, gap, off
}
}
return best
}
// allowance is how far the arriving leg may miss the leaving one, in minor
// units. It is a share of the amount that left, not of the difference, so the
// same percentage means the same thing on a large transfer as on a small one.
//
// Rounded rather than truncated: at 1% of 10.00 a truncating allowance would be
// 0.09 and miss the 0.10 fee the percentage was chosen to admit.
func allowance(outMinor int64, pct float64) int64 {
if pct <= 0 {
return 0
}
if outMinor < 0 {
outMinor = -outMinor
}
return int64(math.Round(float64(outMinor) * pct / 100))
}
// day parses a statement date. An unparseable one cannot be windowed, so it
// simply never pairs rather than pairing wrongly.
func day(s string) (time.Time, bool) {
t, err := time.Parse("2006-01-02", s)
return t, err == nil
}
// Link recomputes the pairing over every transaction in the index and writes it
// back, replacing whatever was there. Like Retag, it is derived state rewritten
// wholesale, so it is safe to run at any time.
//
// It runs over the whole index deliberately: pairing inside a filtered view
// would let a movement count as a transfer in one report and not in another.
func (e *Engine) Link(db *store.DB) (pairs, unmatched int, err error) {
txns, err := db.Transactions(store.Filter{})
if err != nil {
return 0, 0, err
}
res := e.Analyze(txns)
links := make([]store.TransferLink, 0, len(res.Pairs))
for _, p := range res.Pairs {
links = append(links, store.TransferLink{DefIndex: p.Def, OutID: p.Out.ID, InID: p.In.ID})
}
if err := db.ReplaceTransfers(links); err != nil {
return 0, 0, err
}
return len(res.Pairs), len(res.Unmatched), nil
}