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:
+751
-52
File diff suppressed because it is too large
Load Diff
+772
-5
@@ -18,6 +18,7 @@ import (
|
||||
"git.petrovv.com/nikola/money/internal/report"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
)
|
||||
|
||||
// newTestModel builds a model over an index holding two transactions, one of
|
||||
@@ -59,7 +60,7 @@ func newTestModel(t *testing.T) (*Model, *store.DB) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(t.TempDir(), db, nil, engine)
|
||||
m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{}))
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -363,7 +364,7 @@ func newRuleModel(t *testing.T) (*Model, *store.DB, string) {
|
||||
m := New(root, db, []*config.Account{
|
||||
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||||
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||||
}, rules.New(&config.Rules{}))
|
||||
}, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -670,7 +671,7 @@ func TestRuleBuilderCompletesTag(t *testing.T) {
|
||||
// other candidates there are.
|
||||
m.ruleTag.SetValue("gro")
|
||||
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
|
||||
if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
|
||||
if hint := completionHint(&m.ruleTag, true, "applied to matches"); !strings.Contains(hint, "tab") ||
|
||||
!strings.Contains(hint, "1 more") {
|
||||
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
|
||||
}
|
||||
@@ -994,6 +995,7 @@ func TestHelpLinesMentionEveryScreen(t *testing.T) {
|
||||
{"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}},
|
||||
{"report", "3", []string{"4 new rule", "5 rules", "q quit"}},
|
||||
{"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}},
|
||||
{"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "q quit"}},
|
||||
} {
|
||||
key(t, m, tc.key)
|
||||
help := m.help()
|
||||
@@ -1004,11 +1006,16 @@ func TestHelpLinesMentionEveryScreen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The rule builder is a form, so it advertises its own keys instead.
|
||||
// The builders are forms, so they advertise their own keys instead.
|
||||
key(t, m, "4")
|
||||
if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") {
|
||||
t.Errorf("rule builder help = %q", help)
|
||||
}
|
||||
key(t, m, "esc")
|
||||
key(t, m, "6")
|
||||
if help := m.help(); !strings.Contains(help, "enter save transfer") || !strings.Contains(help, "esc back") {
|
||||
t.Errorf("transfer builder help = %q", help)
|
||||
}
|
||||
}
|
||||
|
||||
// A help line longer than the window must wrap, not be cut off.
|
||||
@@ -1037,7 +1044,7 @@ func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
m := New("/data/root", db, accounts, rules.New(&config.Rules{}))
|
||||
m := New("/data/root", db, accounts, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1145,3 +1152,763 @@ func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction {
|
||||
t.Fatalf("no transaction with description %q", desc)
|
||||
return model.Transaction{}
|
||||
}
|
||||
|
||||
// newTransferModel builds a model over a real data root holding one complete
|
||||
// movement between two accounts, one leg whose counterpart never arrived, and
|
||||
// ordinary spending that is neither.
|
||||
func newTransferModel(t *testing.T) (*Model, *store.DB, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
db, err := store.Open(config.IndexPath(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
checking, err := db.UpsertAccount(model.Account{
|
||||
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
savings, err := db.UpsertAccount(model.Account{
|
||||
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceID, err := db.SourceFile(checking, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seed := []struct {
|
||||
account int64
|
||||
date string
|
||||
desc string
|
||||
amount int64
|
||||
}{
|
||||
{checking, "2026-03-01", "TRANSFER TO SAVINGS", -50000},
|
||||
{savings, "2026-03-02", "TRANSFER FROM CHECKING", 50000},
|
||||
{checking, "2026-04-01", "TRANSFER TO SAVINGS", -50000}, // never arrived
|
||||
{checking, "2026-03-05", "LIDL SOFIA 4412", -2000},
|
||||
}
|
||||
for i, s := range seed {
|
||||
if _, err := db.InsertTransaction(model.Transaction{
|
||||
AccountID: s.account,
|
||||
SourceFileID: sourceID,
|
||||
Fingerprint: fmt.Sprintf("xfer-%d", i),
|
||||
Date: s.date,
|
||||
Description: s.desc,
|
||||
AmountMinor: s.amount,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
m := New(root, db, []*config.Account{
|
||||
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||||
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||||
}, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Update(tea.WindowSizeMsg{Width: 140, Height: 40})
|
||||
return m, db, root
|
||||
}
|
||||
|
||||
// fillTransfer types a whole definition into the builder's fields.
|
||||
func fillTransfer(m *Model, from, fromDesc, to, toDesc string) {
|
||||
m.transferFrom.SetValue(from)
|
||||
m.transferFromDesc.SetValue(fromDesc)
|
||||
m.transferTo.SetValue(to)
|
||||
m.transferToDesc.SetValue(toDesc)
|
||||
m.refreshTransferPreview()
|
||||
}
|
||||
|
||||
// The preview answers the question the form is asking: what would this pair,
|
||||
// and what would it catch and fail to pair?
|
||||
func TestTransferBuilderPreviewsPairsAndOrphans(t *testing.T) {
|
||||
m, _, _ := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
|
||||
if m.view != viewTransfers {
|
||||
t.Fatal("expected 6 to open the transfer builder")
|
||||
}
|
||||
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
if m.previewPairs != 1 || m.previewUnmatched != 1 {
|
||||
t.Fatalf("preview = %d pairs, %d unpaired; want 1 and 1", m.previewPairs, m.previewUnmatched)
|
||||
}
|
||||
|
||||
var paired, orphaned int
|
||||
for _, row := range m.transferTable.Rows() {
|
||||
switch row[0] {
|
||||
case "▸":
|
||||
paired++
|
||||
if row[3] != "checking → savings" {
|
||||
t.Errorf("movement = %q, want the direction spelled out", row[3])
|
||||
}
|
||||
case "⚠":
|
||||
orphaned++
|
||||
if row[1] != "2026-04-01" {
|
||||
t.Errorf("unpaired row = %v, want the April leg", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if paired != 1 || orphaned != 1 {
|
||||
t.Errorf("rows = %d paired, %d unpaired; want 1 and 1", paired, orphaned)
|
||||
}
|
||||
|
||||
// A glob that catches nothing pairs nothing, rather than pairing loosely.
|
||||
fillTransfer(m, "checking", "*NOTHING LIKE THIS*", "savings", "*FROM CHECKING*")
|
||||
if m.previewPairs != 0 {
|
||||
t.Errorf("pairs = %d, want none", m.previewPairs)
|
||||
}
|
||||
}
|
||||
|
||||
// Saving writes the definition and pairs immediately, and the report stops
|
||||
// counting the pair as spending.
|
||||
func TestTransferBuilderSavesAndPairs(t *testing.T) {
|
||||
m, db, root := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
m.setTransferFocus(4)
|
||||
m.transferNote.SetValue("monthly saving")
|
||||
key(t, m, "enter")
|
||||
|
||||
loaded, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 {
|
||||
t.Fatalf("rules.toml holds %+v", loaded.Transfer)
|
||||
}
|
||||
if got := loaded.Transfer[0]; got.FromAccount != "checking" || got.ToDesc != "*FROM CHECKING*" ||
|
||||
got.Note != "monthly saving" {
|
||||
t.Errorf("saved transfer = %+v", got)
|
||||
}
|
||||
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legs := 0
|
||||
for _, txn := range txns {
|
||||
if txn.IsTransferLeg() {
|
||||
legs++
|
||||
}
|
||||
// The April leg never arrived, so it is not part of a transfer.
|
||||
if txn.Date == "2026-04-01" && txn.IsTransferLeg() {
|
||||
t.Error("an unpaired leg must not be recorded as a transfer")
|
||||
}
|
||||
}
|
||||
if legs != 2 {
|
||||
t.Errorf("%d legs paired in the index, want 2", legs)
|
||||
}
|
||||
|
||||
// The paired movement is out of the report; the unpaired leg is not.
|
||||
var out int64
|
||||
for _, r := range report.ByTag(txns) {
|
||||
out += r.Out
|
||||
}
|
||||
if want := int64(52000); out != want {
|
||||
t.Errorf("outflow in the report = %d, want %d: the pair excluded, the orphan kept", out, want)
|
||||
}
|
||||
|
||||
// The two accounts were kept for the next definition, the globs cleared.
|
||||
if m.transferFrom.Value() != "checking" || m.transferFromDesc.Value() != "" {
|
||||
t.Errorf("after saving: from = %q, from_desc = %q",
|
||||
m.transferFrom.Value(), m.transferFromDesc.Value())
|
||||
}
|
||||
if !strings.Contains(m.status, "1 leg(s) unpaired") {
|
||||
t.Errorf("status = %q, want the unpaired leg reported", m.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A half-written definition is refused, with the missing side named.
|
||||
func TestTransferBuilderRejectsIncomplete(t *testing.T) {
|
||||
m, _, root := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
|
||||
m.transferFrom.SetValue("checking")
|
||||
m.transferFromDesc.SetValue("*TO SAVINGS*")
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.err == nil {
|
||||
t.Fatal("expected saving half a transfer to fail")
|
||||
}
|
||||
if !strings.Contains(m.err.Error(), "arrives") {
|
||||
t.Errorf("error = %v, want it to name the missing side", m.err)
|
||||
}
|
||||
|
||||
// An unknown account is refused too, before anything is written.
|
||||
m.err = nil
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "nosuchaccount", "*FROM CHECKING*")
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") {
|
||||
t.Errorf("error = %v, want the unknown account named", m.err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||||
t.Error("a rejected transfer must not write rules.toml")
|
||||
}
|
||||
}
|
||||
|
||||
// The builder is a form, so single-letter global keys are ordinary text.
|
||||
func TestTransferBuilderSwallowsGlobalKeys(t *testing.T) {
|
||||
m, _, _ := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
|
||||
typeText(t, m, "q1i")
|
||||
if m.view != viewTransfers {
|
||||
t.Fatal("typing must not switch views")
|
||||
}
|
||||
if got := m.transferFrom.Value(); got != "q1i" {
|
||||
t.Errorf("from account = %q, want the typed characters", got)
|
||||
}
|
||||
if m.importing {
|
||||
t.Error("typing i must not start an import")
|
||||
}
|
||||
}
|
||||
|
||||
// The list is the screen that finds the problems: what pairs, what does not,
|
||||
// and what matches nothing at all.
|
||||
func TestTransferListShowsPairingAndUnpaired(t *testing.T) {
|
||||
m, _, root := newTransferModel(t)
|
||||
if err := config.AppendTransfer(root, config.Transfer{
|
||||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := config.AppendTransfer(root, config.Transfer{
|
||||
FromAccount: "checking", FromDesc: "*TO NOWHERE*",
|
||||
ToAccount: "savings", ToDesc: "*FROM NOWHERE*", Note: "dead",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.reloadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key(t, m, "7")
|
||||
if m.view != viewTransferList {
|
||||
t.Fatal("expected 7 to open the transfer list")
|
||||
}
|
||||
|
||||
rows := m.transferListTable.Rows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %v, want one per definition", rows)
|
||||
}
|
||||
if rows[0][1] != "⚠" || rows[0][4] != "1" || rows[0][5] != "1" {
|
||||
t.Errorf("first row = %v, want 1 pair, 1 unpaired and a warning marker", rows[0])
|
||||
}
|
||||
if rows[1][1] != "✗" || rows[1][4] != "0" || rows[1][5] != "0" {
|
||||
t.Errorf("second row = %v, want it marked as matching nothing", rows[1])
|
||||
}
|
||||
if !strings.Contains(m.title(), "1 leg(s) unpaired") {
|
||||
t.Errorf("title = %q, want the unpaired leg counted", m.title())
|
||||
}
|
||||
|
||||
// Pruning takes the definition that matches nothing, and leaves the one
|
||||
// that is merely unpaired: that one is doing something.
|
||||
if got := m.unusedTransfers(); len(got) != 1 || got[0] != 1 {
|
||||
t.Fatalf("unused = %v, want only the dead definition", got)
|
||||
}
|
||||
key(t, m, "p")
|
||||
if m.confirm != confirmPruneTransfers {
|
||||
t.Fatal("prune must ask first")
|
||||
}
|
||||
key(t, m, "y")
|
||||
|
||||
loaded, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO SAVINGS*" {
|
||||
t.Errorf("transfers = %+v, want the working one kept", loaded.Transfer)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a definition takes its pairing with it, so the legs count again.
|
||||
func TestTransferListDeleteUnpairs(t *testing.T) {
|
||||
m, db, root := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
key(t, m, "enter")
|
||||
|
||||
// The builder is a form, so leaving it takes esc rather than a view key.
|
||||
key(t, m, "esc")
|
||||
key(t, m, "7")
|
||||
key(t, m, "d")
|
||||
if m.confirm != confirmDeleteTransfer {
|
||||
t.Fatal("delete must ask first")
|
||||
}
|
||||
if !strings.Contains(m.status, "checking → savings") {
|
||||
t.Errorf("prompt = %q, want it to name the transfer", m.status)
|
||||
}
|
||||
key(t, m, "y")
|
||||
|
||||
loaded, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 0 {
|
||||
t.Fatalf("transfers = %+v, want it gone", loaded.Transfer)
|
||||
}
|
||||
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, txn := range txns {
|
||||
if txn.IsTransferLeg() {
|
||||
t.Fatalf("%s is still recorded as a transfer leg", txn.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retagging re-derives both halves of what rules.toml decides.
|
||||
func TestRetagAlsoRepairsThePairing(t *testing.T) {
|
||||
m, db, root := newTransferModel(t)
|
||||
if err := config.AppendTransfer(root, config.Transfer{
|
||||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.reloadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key(t, m, "r")
|
||||
if !strings.Contains(m.status, "1 transfers matched") {
|
||||
t.Errorf("status = %q, want the pairing reported", m.status)
|
||||
}
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legs := 0
|
||||
for _, txn := range txns {
|
||||
if txn.IsTransferLeg() {
|
||||
legs++
|
||||
}
|
||||
}
|
||||
if legs != 2 {
|
||||
t.Errorf("%d legs paired after retag, want 2", legs)
|
||||
}
|
||||
}
|
||||
|
||||
// Six fields need more room than four, so the transfer form gives up its
|
||||
// spacing, then its hints, then the borders on the fields not being edited. No
|
||||
// field may disappear at any height, and the one with the cursor in it keeps
|
||||
// its box however tight things get.
|
||||
func TestTransferFormFitsShortTerminals(t *testing.T) {
|
||||
m, _, _ := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
|
||||
for _, height := range []int{50, 44, 38, 34, 30, 28} {
|
||||
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
|
||||
form := m.transferFormView()
|
||||
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
|
||||
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
|
||||
}
|
||||
for _, label := range []string{
|
||||
"from account", "from desc", "to account", "to desc", "tolerance %", "note",
|
||||
} {
|
||||
if !strings.Contains(form, label) {
|
||||
t.Errorf("at height %d the %q field disappeared", height, label)
|
||||
}
|
||||
}
|
||||
boxes := strings.Count(form, "╭")
|
||||
if boxes != 6 && boxes != 1 {
|
||||
t.Errorf("at height %d there are %d input boxes, want 6 or just the focused 1", height, boxes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A paired leg has been accounted for, so it is not waiting for a tag: it must
|
||||
// not turn up in the untagged view, nor in the rule builder's preview, which is
|
||||
// the list of things still asking to be tagged.
|
||||
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||||
m, _, _ := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
key(t, m, "enter")
|
||||
key(t, m, "esc")
|
||||
|
||||
key(t, m, "u")
|
||||
var seen []string
|
||||
for _, txn := range m.txns {
|
||||
seen = append(seen, txn.Date+" "+txn.Description)
|
||||
}
|
||||
want := []string{"2026-04-01 TRANSFER TO SAVINGS", "2026-03-05 LIDL SOFIA 4412"}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("untagged = %v, want %v: only the unpaired leg and the shopping", seen, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if seen[i] != w {
|
||||
t.Errorf("untagged[%d] = %q, want %q", i, seen[i], w)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule builder previews the same set.
|
||||
key(t, m, "4")
|
||||
all, _ := previewRows(m)
|
||||
if len(all) != 2 {
|
||||
t.Errorf("preview = %v, want the paired legs left out", all)
|
||||
}
|
||||
for _, desc := range all {
|
||||
if desc == "TRANSFER FROM CHECKING" {
|
||||
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An exchange between two of your own accounts pairs on the dates alone, and
|
||||
// the preview shows both amounts: they are the only place the rate appears.
|
||||
func TestTransferBuilderShowsBothSidesOfAnExchange(t *testing.T) {
|
||||
m, db, _ := newTransferModel(t)
|
||||
|
||||
bgn, err := db.UpsertAccount(model.Account{
|
||||
Slug: "revolut", Name: "Revolut BGN", Currency: "BGN", MinorDigits: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, err := db.SourceFile(bgn, "revolut/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := db.Accounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var checking int64
|
||||
for _, a := range accounts {
|
||||
if a.Slug == "checking" {
|
||||
checking = a.ID
|
||||
}
|
||||
}
|
||||
for _, leg := range []struct {
|
||||
account int64
|
||||
fp string
|
||||
date string
|
||||
desc string
|
||||
amount int64
|
||||
}{
|
||||
{checking, "x-out", "2026-05-01", "TRANSFER TO REVOLUT", -50000}, // EUR
|
||||
{bgn, "x-in", "2026-05-02", "TOP-UP FROM CHECKING", 97790},
|
||||
} {
|
||||
if _, err := db.InsertTransaction(model.Transaction{
|
||||
AccountID: leg.account,
|
||||
SourceFileID: source,
|
||||
Fingerprint: leg.fp,
|
||||
Date: leg.date,
|
||||
Description: leg.desc,
|
||||
AmountMinor: leg.amount,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*TO REVOLUT*", "revolut", "*FROM CHECKING*")
|
||||
|
||||
if m.previewPairs != 1 || m.previewUnmatched != 0 {
|
||||
t.Fatalf("preview = %d pairs, %d unpaired; want the exchange paired",
|
||||
m.previewPairs, m.previewUnmatched)
|
||||
}
|
||||
var amount string
|
||||
for _, row := range m.transferTable.Rows() {
|
||||
if row[0] == "▸" {
|
||||
amount = row[2]
|
||||
}
|
||||
}
|
||||
if amount != "500.00 → 977.90" {
|
||||
t.Errorf("amount = %q, want both sides of the exchange", amount)
|
||||
}
|
||||
|
||||
// Saving it keeps both legs out of the report, each in its own currency.
|
||||
key(t, m, "enter")
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excluded := report.Excluded(txns)
|
||||
if len(excluded) != 2 {
|
||||
t.Fatalf("excluded = %+v, want a row per currency", excluded)
|
||||
}
|
||||
for _, x := range excluded {
|
||||
if x.Legs != 1 {
|
||||
t.Errorf("%s row = %+v, want the one leg it saw", x.Currency, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A paired leg reads as accounted for in the tag column rather than as a blank,
|
||||
// but the label is display only: rule_tag stays what rules.toml made it, so
|
||||
// retagging is still safe to run at any time.
|
||||
func TestTransferLegsDisplayAsTags(t *testing.T) {
|
||||
m, db, _ := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
key(t, m, "enter")
|
||||
key(t, m, "esc")
|
||||
|
||||
var tags []string
|
||||
for _, row := range m.txnTable.Rows() {
|
||||
tags = append(tags, row[3])
|
||||
}
|
||||
// Newest first: the unpaired April leg, the shopping, then the two legs of
|
||||
// the March movement.
|
||||
want := []string{"", "", model.TransferTag, model.TransferTag}
|
||||
if len(tags) != len(want) {
|
||||
t.Fatalf("tags = %v, want %v", tags, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if tags[i] != w {
|
||||
t.Errorf("row %d (%s) tag = %q, want %q", i, m.txns[i].Description, tags[i], w)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing was written: the index still holds no tag for either leg, and the
|
||||
// label is not offered as a tag to complete against.
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, txn := range txns {
|
||||
if txn.RuleTag != "" {
|
||||
t.Errorf("%s has rule_tag %q; a transfer must never write one",
|
||||
txn.Description, txn.RuleTag)
|
||||
}
|
||||
}
|
||||
known, err := m.knownTags()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tag := range known {
|
||||
if tag == model.TransferTag {
|
||||
t.Error("the transfer label must not be offered as a tag to write rules with")
|
||||
}
|
||||
}
|
||||
|
||||
// A rule tag wins where there is one, since it is the user's own word.
|
||||
key(t, m, "4")
|
||||
m.ruleGlob.SetValue("*TO SAVINGS*")
|
||||
m.setRuleFocus(2)
|
||||
m.ruleTag.SetValue("saving")
|
||||
key(t, m, "enter")
|
||||
key(t, m, "esc")
|
||||
for i, txn := range m.txns {
|
||||
if txn.Description == "TRANSFER TO SAVINGS" && m.txnTable.Rows()[i][3] != "saving" {
|
||||
t.Errorf("tag = %q, want the rule's own tag", m.txnTable.Rows()[i][3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A route where the bank takes a fee is the reason tolerance_pct exists: the
|
||||
// builder must write it, pair on it, and say what it is admitting -- and the
|
||||
// report must then account for the difference, since the pair leaves it.
|
||||
func TestTransferBuilderTolerance(t *testing.T) {
|
||||
m, db, root := newTransferModel(t)
|
||||
|
||||
checking, err := db.UpsertAccount(model.Account{
|
||||
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
savings, err := db.UpsertAccount(model.Account{
|
||||
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceID, err := db.SourceFile(checking, "checking/wire.csv", "sha2", "2026-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 500.00 leaves, 495.00 arrives: the bank kept 5.00 on the way.
|
||||
fee := []struct {
|
||||
account int64
|
||||
date string
|
||||
desc string
|
||||
amount int64
|
||||
}{
|
||||
{checking, "2026-05-01", "WIRE TO SAVINGS", -50000},
|
||||
{savings, "2026-05-02", "WIRE FROM CHECKING", 49500},
|
||||
}
|
||||
for i, s := range fee {
|
||||
if _, err := db.InsertTransaction(model.Transaction{
|
||||
AccountID: s.account,
|
||||
SourceFileID: sourceID,
|
||||
Fingerprint: fmt.Sprintf("wire-%d", i),
|
||||
Date: s.date,
|
||||
Description: s.desc,
|
||||
AmountMinor: s.amount,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*WIRE TO SAVINGS*", "savings", "*WIRE FROM CHECKING*")
|
||||
if m.previewPairs != 0 {
|
||||
t.Fatalf("previewPairs = %d, want 0 before a tolerance is given", m.previewPairs)
|
||||
}
|
||||
|
||||
m.transferTolerance.SetValue("1")
|
||||
m.refreshTransferPreview()
|
||||
if m.previewPairs != 1 || m.previewFees != 500 {
|
||||
t.Fatalf("preview = %d pairs, %d in fees; want 1 and 500",
|
||||
m.previewPairs, m.previewFees)
|
||||
}
|
||||
// Both amounts show, exactly as they do for an exchange: the fee is the
|
||||
// thing to eyeball before saving.
|
||||
if want := "500.00 → 495.00"; !strings.Contains(m.transferTable.View(), want) {
|
||||
t.Errorf("preview table does not show %q:\n%s", want, m.transferTable.View())
|
||||
}
|
||||
|
||||
key(t, m, "enter")
|
||||
if m.err != nil {
|
||||
t.Fatalf("saving: %v", m.err)
|
||||
}
|
||||
loaded, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].TolerancePct != 1 {
|
||||
t.Fatalf("transfers = %+v, want the tolerance written", loaded.Transfer)
|
||||
}
|
||||
// Saving clears it: a tolerance carried into the next definition would
|
||||
// loosen a route that never asked for one.
|
||||
if v := m.transferTolerance.Value(); v != "" {
|
||||
t.Errorf("tolerance field = %q after saving, want it cleared", v)
|
||||
}
|
||||
|
||||
// The pair is gone from the report, so the fee it took has to be named.
|
||||
var rows []string
|
||||
for _, r := range m.reportTable.Rows() {
|
||||
rows = append(rows, strings.Join(r, " "))
|
||||
}
|
||||
joined := strings.Join(rows, "\n")
|
||||
if !strings.Contains(joined, transfersRow) {
|
||||
t.Errorf("report has no transfers row:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, feesRow) || !strings.Contains(joined, "5.00") {
|
||||
t.Errorf("report does not account for the 5.00 fee:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// A tolerance that is not a number is refused on save rather than written out
|
||||
// as a silent zero -- but it must not stop the preview updating as it is typed.
|
||||
func TestTransferBuilderRejectsABadTolerance(t *testing.T) {
|
||||
m, _, root := newTransferModel(t)
|
||||
key(t, m, "6")
|
||||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||
|
||||
m.transferTolerance.SetValue("1.")
|
||||
m.refreshTransferPreview() // must not panic or wipe the preview
|
||||
if m.previewPairs != 1 {
|
||||
t.Errorf("previewPairs = %d, want the preview to survive a half-typed number", m.previewPairs)
|
||||
}
|
||||
|
||||
m.transferTolerance.SetValue("a lot")
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // not key(), which fails on m.err
|
||||
if m.err == nil {
|
||||
t.Fatal("expected saving to be refused")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||||
t.Error("a rejected transfer must not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// The list is where a definition is judged, and a tolerance changes what its
|
||||
// counts mean: those pairs were matched on slack rather than on the amount, so
|
||||
// the column has to say which ones.
|
||||
func TestTransferListShowsTheTolerance(t *testing.T) {
|
||||
m, _, root := newTransferModel(t)
|
||||
if err := config.AppendTransfer(root, config.Transfer{
|
||||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := config.AppendTransfer(root, config.Transfer{
|
||||
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
|
||||
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
|
||||
TolerancePct: 1.5,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.reloadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key(t, m, "7")
|
||||
rows := m.transferListTable.Rows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %v, want one per definition", rows)
|
||||
}
|
||||
// Blank, not "0%": every definition has the default, and printing it down
|
||||
// the column would bury the row where amounts may actually disagree.
|
||||
if rows[0][6] != "" {
|
||||
t.Errorf("strict row shows tolerance %q, want it blank", rows[0][6])
|
||||
}
|
||||
if rows[1][6] != "1.5%" {
|
||||
t.Errorf("tolerant row shows %q, want 1.5%%", rows[1][6])
|
||||
}
|
||||
}
|
||||
|
||||
// Once a glob is typed the preview is the rule's answer, not a list to search
|
||||
// by eye: the rows that do not match go, and the count says what they were
|
||||
// chosen out of, since the rows on screen can no longer say it themselves.
|
||||
func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
key(t, m, "4")
|
||||
|
||||
// With no glob there is nothing to filter by, so the screen answers its
|
||||
// other question: everything still waiting for a rule.
|
||||
if all, _ := previewRows(m); len(all) != 4 {
|
||||
t.Fatalf("preview = %v, want every untagged description before a glob", all)
|
||||
}
|
||||
if !strings.Contains(m.ruleFormView(), "4 untagged descriptions") {
|
||||
t.Errorf("summary = %q, want the untagged count", m.ruleFormView())
|
||||
}
|
||||
|
||||
typeText(t, m, "*LIDL*")
|
||||
all, matched := previewRows(m)
|
||||
if len(all) != 2 || len(matched) != 2 {
|
||||
t.Fatalf("preview = %v (matched %v), want only the two LIDL rows", all, matched)
|
||||
}
|
||||
for _, desc := range all {
|
||||
if !strings.Contains(desc, "LIDL") {
|
||||
t.Errorf("preview kept a non-matching row %q", desc)
|
||||
}
|
||||
}
|
||||
// Two of the four still in view, not two of the two left on screen.
|
||||
if m.ruleCandidates != 4 {
|
||||
t.Errorf("candidates = %d, want 4", m.ruleCandidates)
|
||||
}
|
||||
if !strings.Contains(m.ruleFormView(), "2 of 4 descriptions match") {
|
||||
t.Errorf("summary = %q, want the glob's selectivity", m.ruleFormView())
|
||||
}
|
||||
|
||||
// A glob that matches nothing empties the list, and says so rather than
|
||||
// leaving rows on screen that the rule would not claim.
|
||||
m.ruleGlob.SetValue("*NOTHING*")
|
||||
m.refreshRulePreview()
|
||||
if all, _ := previewRows(m); len(all) != 0 {
|
||||
t.Errorf("preview = %v, want nothing", all)
|
||||
}
|
||||
if !strings.Contains(m.ruleFormView(), "0 of 4 descriptions match") {
|
||||
t.Errorf("summary = %q, want 0 of 4", m.ruleFormView())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user