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