// 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 }