Files
money/internal/transfers/transfers_test.go
T
nikolaandClaude Opus 5.5 7f5b4846ef Show which legs a transfer cannot pair, and what would pair them
The transfers screen counted unpaired legs per definition but never said
which transactions they were; finding them meant retyping the definition
into the builder. A ⚠ row now opens onto its legs, newest first, each
naming the missing side and what that side would have had to be:

  revolut → ?  arriving leg  +250.00 in other matching *FROM REVOLUT*,
                             dated 2026-02-01 to 2026-02-11

That description comes from transfers.Engine.Want, next to
bestCounterpart, so it is stated in the terms the pairing actually uses:
the other half of the definition, the window, and the amount -- exact,
widened only by the definition's own tolerance, and "any amount" across
currencies, where amounts are not compared. When the other account has
nothing imported at all, the line says so, since that is the usual cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:43:13 +02:00

393 lines
14 KiB
Go

package transfers
import (
"testing"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/model"
)
// txn builds a transaction the way the index hands them out.
func txn(id int64, account, date, desc string, amount int64) model.Transaction {
return model.Transaction{
ID: id,
AccountSlug: account,
Currency: "EUR",
MinorDigits: 2,
Date: date,
Description: desc,
AmountMinor: amount,
}
}
func engine(defs ...config.Transfer) *Engine {
return New(&config.Rules{Transfer: defs})
}
var topUp = config.Transfer{
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
ToAccount: "revolut", ToDesc: "*FROM NLB*",
}
// The two legs of one movement, dated a weekend apart, are one transfer.
func TestPairsTheTwoLegs(t *testing.T) {
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-09", "Top-up from NLB", 50000),
txn(3, "nlb", "2026-03-07", "LIDL SOFIA", -2000),
})
if len(res.Pairs) != 1 {
t.Fatalf("pairs = %+v, want exactly one", res.Pairs)
}
if res.Pairs[0].Out.ID != 1 || res.Pairs[0].In.ID != 2 {
t.Errorf("paired %d→%d, want 1→2", res.Pairs[0].Out.ID, res.Pairs[0].In.ID)
}
if len(res.Unmatched) != 0 {
t.Errorf("unmatched = %+v, want none; the shopping matches no side", res.Unmatched)
}
if res.Paired[0] != 1 || res.Orphaned[0] != 0 {
t.Errorf("counts = %d paired, %d orphaned; want 1 and 0", res.Paired[0], res.Orphaned[0])
}
}
// Money that left and never arrived is the case the whole feature exists to
// surface: it is not a transfer, it is one leg on its own.
func TestLegWithoutACounterpartIsUnmatched(t *testing.T) {
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
})
if len(res.Pairs) != 0 {
t.Fatalf("pairs = %+v, want none", res.Pairs)
}
if len(res.Unmatched) != 1 || res.Unmatched[0].Txn.ID != 1 || !res.Unmatched[0].Out {
t.Fatalf("unmatched = %+v, want the leaving leg", res.Unmatched)
}
if res.Paired[0] != 0 || res.Orphaned[0] != 1 {
t.Errorf("counts = %d paired, %d orphaned; want 0 and 1", res.Paired[0], res.Orphaned[0])
}
}
// A definition that catches nothing at all is dead, and reports as such
// separately from one that catches legs it cannot pair.
func TestDefinitionMatchingNothing(t *testing.T) {
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "LIDL SOFIA", -2000),
})
if res.Paired[0] != 0 || res.Orphaned[0] != 0 {
t.Errorf("counts = %d paired, %d orphaned; want both zero", res.Paired[0], res.Orphaned[0])
}
}
// Within one currency the amount is the evidence, so by default it must agree
// exactly: a movement that arrives short a fee is not the same movement
// leaving, unless the definition says the route charges one.
func TestAmountMustAgreeWithinACurrency(t *testing.T) {
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
})
if len(res.Pairs) != 0 {
t.Errorf("pairs = %+v, want none: the amounts differ", res.Pairs)
}
if len(res.Unmatched) != 2 {
t.Errorf("unmatched = %+v, want both legs reported", res.Unmatched)
}
}
// Across currencies the amounts are unrelated -- there are no exchange rates
// here -- so the dates carry the pairing on their own.
func TestCrossCurrencyPairsOnDateAlone(t *testing.T) {
arrived := txn(2, "revolut", "2026-03-07", "Top-up from NLB", 97790)
arrived.Currency = "BGN"
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
arrived,
})
if len(res.Pairs) != 1 {
t.Fatalf("pairs = %+v, want the exchange paired", res.Pairs)
}
if res.Pairs[0].In.Currency == res.Pairs[0].Out.Currency {
t.Error("expected the pair to span two currencies")
}
// The window still bounds it.
late := arrived
late.Date = "2026-03-20"
res = engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
late,
})
if len(res.Pairs) != 0 {
t.Errorf("pairs = %+v, want none beyond the window", res.Pairs)
}
}
// With no amount to go on, the nearest date decides, so two exchanges in flight
// at once pair in order rather than crossing over.
func TestCrossCurrencyPicksTheNearestDate(t *testing.T) {
arriving := func(id int64, date string, amount int64) model.Transaction {
in := txn(id, "revolut", date, "Top-up from NLB", amount)
in.Currency = "BGN"
return in
}
res := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-02", "TRANSFER TO REVOLUT LTD", -50000),
txn(3, "nlb", "2026-03-10", "TRANSFER TO REVOLUT LTD", -20000),
arriving(2, "2026-03-03", 97790),
arriving(4, "2026-03-11", 39116),
})
if len(res.Pairs) != 2 {
t.Fatalf("pairs = %+v, want both exchanges paired", res.Pairs)
}
for _, p := range res.Pairs {
if p.In.ID != p.Out.ID+1 {
t.Errorf("paired %d->%d, want each exchange with its own counterpart",
p.Out.ID, p.In.ID)
}
}
}
// Legs further apart than the window are not the same movement.
func TestWindowBoundsThePairing(t *testing.T) {
inside := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-06", "Top-up from NLB", 50000),
})
if len(inside.Pairs) != 1 {
t.Errorf("pairs = %+v, want one at exactly the window", inside.Pairs)
}
outside := engine(topUp).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
})
if len(outside.Pairs) != 0 {
t.Errorf("pairs = %+v, want none beyond the window", outside.Pairs)
}
}
// Two identical monthly transfers must pair in order rather than crossing over,
// or the dates in the report would be wrong even though the totals were right.
func TestIdenticalTransfersPairInOrder(t *testing.T) {
res := engine(topUp).Analyze([]model.Transaction{
txn(4, "revolut", "2026-04-02", "Top-up from NLB", 50000),
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
txn(3, "nlb", "2026-04-01", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-02", "Top-up from NLB", 50000),
})
if len(res.Pairs) != 2 {
t.Fatalf("pairs = %+v, want two", res.Pairs)
}
for _, p := range res.Pairs {
if p.In.ID != p.Out.ID+1 {
t.Errorf("paired %d→%d, want each transfer with its own month", p.Out.ID, p.In.ID)
}
}
}
// A transaction belongs to one transfer. The first definition to claim a leg
// keeps it, exactly as the first matching rule keeps a tag.
func TestFirstDefinitionClaimsTheLeg(t *testing.T) {
broad := config.Transfer{
FromAccount: "nlb", FromDesc: "*TRANSFER*",
ToAccount: "revolut", ToDesc: "*NLB*",
}
res := engine(topUp, broad).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
})
if len(res.Pairs) != 1 || res.Pairs[0].Def != 0 {
t.Fatalf("pairs = %+v, want one claimed by the first definition", res.Pairs)
}
if res.Paired[1] != 0 || res.Orphaned[1] != 0 {
t.Errorf("the shadowed definition reports %d paired, %d orphaned; want zero",
res.Paired[1], res.Orphaned[1])
}
}
// Direction is part of the definition: the arriving leg is an inflow and the
// leaving leg an outflow, so a definition written backwards pairs nothing.
func TestDirectionMatters(t *testing.T) {
backwards := config.Transfer{
FromAccount: "revolut", FromDesc: "*FROM NLB*",
ToAccount: "nlb", ToDesc: "*TO REVOLUT*",
}
res := engine(backwards).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
})
if len(res.Pairs) != 0 {
t.Errorf("pairs = %+v, want none: neither leg is on the side it was named for", res.Pairs)
}
if len(res.Unmatched) != 0 {
t.Errorf("unmatched = %+v, want none either", res.Unmatched)
}
}
// Money moved inside one account (a savings pocket, say) is still a pair.
func TestSameAccountBothSides(t *testing.T) {
pocket := config.Transfer{
FromAccount: "revolut", FromDesc: "*TO VAULT*",
ToAccount: "revolut", ToDesc: "*FROM VAULT*",
}
res := engine(pocket).Analyze([]model.Transaction{
txn(1, "revolut", "2026-03-06", "Move to Vault", -10000),
txn(2, "revolut", "2026-03-06", "Move from Vault", 10000),
})
if len(res.Pairs) != 1 {
t.Errorf("pairs = %+v, want one within the account", res.Pairs)
}
}
// tolerant is the same route on a bank that takes a fee on the way: 1% of the
// leaving leg, so a 500.00 transfer may arrive as little as 495.00.
var tolerant = config.Transfer{
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
ToAccount: "revolut", ToDesc: "*FROM NLB*",
TolerancePct: 1,
}
// A route that charges pairs anyway, and says what it cost.
func TestToleranceAdmitsAFee(t *testing.T) {
res := engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
})
if len(res.Pairs) != 1 {
t.Fatalf("pairs = %+v, want the fee tolerated", res.Pairs)
}
if fee := res.Pairs[0].Fee(); fee != 500 {
t.Errorf("fee = %d, want 500: the pair leaves the report, so the fee has to be reported", fee)
}
}
// The tolerance is a bound, not an invitation: past it the legs are still two
// separate things.
func TestToleranceStopsAtItsBound(t *testing.T) {
res := engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49499),
})
if len(res.Pairs) != 0 {
t.Errorf("pairs = %+v, want none: 5.01 is more than 1%% of 500.00", res.Pairs)
}
}
// The allowance is a share of the amount that left, so the same percentage
// means the same thing on a small transfer as on a large one -- and it is
// rounded, or 1% of 10.00 would admit 0.09 and miss the 0.10 fee it was
// chosen for.
func TestToleranceScalesWithTheAmount(t *testing.T) {
res := engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -1000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 990),
})
if len(res.Pairs) != 1 || res.Pairs[0].Fee() != 10 {
t.Fatalf("pairs = %+v, want a 0.10 fee on 10.00 tolerated", res.Pairs)
}
res = engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -1000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 950),
})
if len(res.Pairs) != 0 {
t.Errorf("pairs = %+v, want none: 0.50 is 5%% of 10.00", res.Pairs)
}
}
// A tolerance belongs to the definition that declares it and to no other, so
// one tolerant route cannot loosen a strict one written beside it.
func TestToleranceIsPerDefinition(t *testing.T) {
strict := config.Transfer{
FromAccount: "nlb", FromDesc: "*TO SAVINGS*",
ToAccount: "savings", ToDesc: "*FROM NLB*",
}
res := engine(tolerant, strict).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
txn(3, "nlb", "2026-03-06", "TRANSFER TO SAVINGS", -50000),
txn(4, "savings", "2026-03-07", "FROM NLB", 49500),
})
if len(res.Pairs) != 1 || res.Pairs[0].Def != 0 {
t.Fatalf("pairs = %+v, want only the tolerant definition to pair", res.Pairs)
}
if len(res.Unmatched) != 2 {
t.Errorf("unmatched = %+v, want both legs of the strict route reported", res.Unmatched)
}
}
// Across currencies the amounts are in different units, so a fee cannot be
// computed from them -- subtracting one from the other would be a number
// meaning nothing.
func TestNoFeeAcrossCurrencies(t *testing.T) {
arrived := txn(2, "revolut", "2026-03-07", "Top-up from NLB", 97790)
arrived.Currency = "BGN"
res := engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
arrived,
})
if len(res.Pairs) != 1 {
t.Fatalf("pairs = %+v, want the exchange paired", res.Pairs)
}
if fee := res.Pairs[0].Fee(); fee != 0 {
t.Errorf("fee = %d, want 0 across currencies", fee)
}
}
// With slack in the amount two candidates can sit the same number of days
// away, and then the nearer amount is the better evidence.
func TestEqualGapPrefersTheNearerAmount(t *testing.T) {
res := engine(tolerant).Analyze([]model.Transaction{
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
txn(2, "revolut", "2026-03-05", "Top-up from NLB", 49600),
txn(3, "revolut", "2026-03-07", "Top-up from NLB", 50000),
})
if len(res.Pairs) != 1 {
t.Fatalf("pairs = %+v, want one", res.Pairs)
}
if res.Pairs[0].In.ID != 3 {
t.Errorf("paired with %d, want 3: same gap, exact amount", res.Pairs[0].In.ID)
}
}
// Want describes the missing side in the terms the pairing judges it by: the
// other definition half, the window, and the amount — exact by default,
// widened only by the definition's own tolerance, unchecked across currencies.
func TestWantDescribesTheMissingSide(t *testing.T) {
eur := func(string) string { return "EUR" }
out := Leg{Def: 0, Out: true, Txn: txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT", -50000)}
got := engine(topUp).Want(out, eur)
want := Wanted{Account: "revolut", Desc: "*FROM NLB*", From: "2026-03-01", To: "2026-03-11", Amount: 50000}
if got != want {
t.Errorf("arriving side = %+v, want %+v", got, want)
}
in := Leg{Def: 0, Txn: txn(2, "revolut", "2026-03-09", "Top-up from NLB", 50000)}
got = engine(topUp).Want(in, eur)
want = Wanted{Account: "nlb", Desc: "*TO REVOLUT*", From: "2026-03-04", To: "2026-03-14", Amount: -50000}
if got != want {
t.Errorf("leaving side = %+v, want %+v", got, want)
}
fee := topUp
fee.TolerancePct = 1.5
if got := engine(fee).Want(out, eur); got.TolerancePct != 1.5 || got.AnyAmount {
t.Errorf("tolerant = %+v, want the definition's 1.5%%", got)
}
usd := func(slug string) string {
if slug == "revolut" {
return "USD"
}
return "EUR"
}
if got := engine(fee).Want(out, usd); !got.AnyAmount || got.TolerancePct != 0 {
t.Errorf("cross-currency = %+v, want any amount and no tolerance", got)
}
}