diff --git a/README.md b/README.md index 5463b9a..b452b82 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,23 @@ definition catching legs it cannot complete: a wrong glob on the other side, a statement not imported yet, or a movement that genuinely went missing. `✗` is a definition matching nothing at all, which is just dead weight. +Click a `⚠` row to see the legs behind it, newest first. Each one says which +side is missing and what that side would have had to look like: + +``` +Date Amount Movement Missing Would pair with +2026-04-01 500.00 checking → ? arriving leg +500.00 in savings matching *FROM CHECKING*, + dated 2026-03-27 to 2026-04-06 +``` + +That is the search to run on the other statement. If it is not there, the +statement probably is not imported yet — when nothing at all from that account +is, the line says so. If something close is there, the difference tells you +which condition failed: a date outside the window, an amount off by a fee (see +[When the bank takes a fee](#when-the-bank-takes-a-fee)), or a description the +glob does not match. Across currencies the line says *any amount*, since only +the dates are checked there. + `Tol` is the definition's `tolerance_pct`, and it qualifies the counts beside it: row 3's two pairs were matched on slack rather than on the amount agreeing. It is blank for every definition that requires the exact amount — which is the diff --git a/internal/transfers/transfers.go b/internal/transfers/transfers.go index 690a8df..250c61d 100644 --- a/internal/transfers/transfers.go +++ b/internal/transfers/transfers.go @@ -214,6 +214,41 @@ func bestCounterpart(out model.Transaction, ins []model.Transaction, used map[in return best } +// Wanted describes the counterpart an unpaired leg is missing, in the terms +// bestCounterpart judges it by, so a person can go and look for it. +type Wanted struct { + Account string // where the other leg would be + Desc string // the glob it would have to match + From string // the window it would have to fall in, inclusive + To string + // Amount is the other leg's signed amount, exact unless TolerancePct + // widens it. AnyAmount is set across currencies, where the amount is not + // checked at all and the dates carry the pairing alone. + Amount int64 + TolerancePct float64 + AnyAmount bool +} + +// Want says what would have paired l. currencyOf names an account's currency, +// or "" for one the index holds nothing for. +func (e *Engine) Want(l Leg, currencyOf func(slug string) string) Wanted { + def := e.defs[l.Def] + w := Wanted{Account: def.ToAccount, Desc: def.ToDesc, Amount: -l.Txn.AmountMinor} + if !l.Out { + w.Account, w.Desc = def.FromAccount, def.FromDesc + } + if c := currencyOf(w.Account); c != "" && c != l.Txn.Currency { + w.AnyAmount = true + } else { + w.TolerancePct = def.TolerancePct + } + if d, ok := day(l.Txn.Date); ok { + w.From = d.AddDate(0, 0, -WindowDays).Format("2006-01-02") + w.To = d.AddDate(0, 0, WindowDays).Format("2006-01-02") + } + return w +} + // 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. diff --git a/internal/transfers/transfers_test.go b/internal/transfers/transfers_test.go index c1f1658..a113b7d 100644 --- a/internal/transfers/transfers_test.go +++ b/internal/transfers/transfers_test.go @@ -353,3 +353,40 @@ func TestEqualGapPrefersTheNearerAmount(t *testing.T) { 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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index f05f71e..658bad3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -892,6 +892,20 @@ type transferRow struct { Tolerance string `json:"tolerance"` // blank for the exact-amount default Paired int `json:"paired"` Orphaned int `json:"orphaned"` + // Legs are the transactions behind Orphaned, newest first, so a ⚠ can be + // opened to see which movement is missing its other side. + Legs []unpairedLeg `json:"legs"` +} + +type unpairedLeg struct { + Date string `json:"date"` + Amount string `json:"amount"` + Movement string `json:"movement"` // "checking → ?" or "? → savings" + Description string `json:"description"` + // Missing names the side not found, "arriving" or "leaving", and Want + // what it would have had to look like to pair. + Missing string `json:"missing"` + Want string `json:"want"` } func (s *Server) transferList(*http.Request) (any, error) { @@ -899,18 +913,80 @@ func (s *Server) transferList(*http.Request) (any, error) { if err != nil { return nil, err } + accounts, err := s.db.Accounts() + if err != nil { + return nil, err + } + currency := map[string]string{} + digits := map[string]int{} + for _, a := range accounts { + currency[a.Slug], digits[a.Slug] = a.Currency, a.MinorDigits + } + defs := s.links.Transfers() res := s.links.Analyze(txns) rows := make([]transferRow, 0, len(defs)) for i, t := range defs { rows = append(rows, transferRow{ transferJSON: toTransferJSON(t), Pos: i, Tolerance: formatTolerance(t.TolerancePct), - Paired: res.Paired[i], Orphaned: res.Orphaned[i], + Paired: res.Paired[i], Orphaned: res.Orphaned[i], Legs: []unpairedLeg{}, }) } + for _, l := range res.Unmatched { + row := legRow(l) + w := s.links.Want(l, func(slug string) string { return currency[slug] }) + d, imported := digits[w.Account] + if !imported { + d = l.Txn.MinorDigits + } + leg := unpairedLeg{ + Date: row.Date, Amount: row.Amount, Movement: row.Movement, Description: row.Description, + Missing: "arriving", Want: describeWant(w, d), + } + if !l.Out { + leg.Missing = "leaving" + } + if !imported { + // The commonest cause of all, so it is named outright. + leg.Want += fmt.Sprintf(" — nothing from %s is imported", w.Account) + } + rows[l.Def].Legs = append(rows[l.Def].Legs, leg) + } + for i := range rows { + sort.SliceStable(rows[i].Legs, func(a, b int) bool { return rows[i].Legs[a].Date > rows[i].Legs[b].Date }) + } return map[string]any{"transfers": rows, "unpaired": len(res.Unmatched)}, nil } +// describeWant puts transfers.Wanted into the words the transfers screen uses. +// The amount is signed as the other statement would show it. +func describeWant(w transfers.Wanted, digits int) string { + amount := model.FormatMinor(w.Amount, digits) + if w.Amount > 0 { + amount = "+" + amount + } + switch { + case w.AnyAmount: + amount = "any amount (another currency)" + case w.TolerancePct > 0: + amount += " ±" + formatTolerance(w.TolerancePct) + } + return fmt.Sprintf("%s in %s matching %s, dated %s to %s", amount, w.Account, w.Desc, w.From, w.To) +} + +// legRow is an unpaired leg as the builder's preview and the transfers screen +// both show it: the amount that moved, and the side that has no partner. +func legRow(l transfers.Leg) transferPreviewRow { + amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug + if l.Out { + amount, movement = -amount, l.Txn.AccountSlug+" → ?" + } + return transferPreviewRow{ + Marker: "⚠", Date: l.Txn.Date, Amount: model.FormatMinor(amount, l.Txn.MinorDigits), + Movement: movement, Description: l.Txn.Description, + } +} + // formatTolerance leaves the default blank, so the one or two definitions // actually pairing on slack are what the column shows. func formatTolerance(pct float64) string { @@ -1009,14 +1085,7 @@ func (s *Server) transferPreview(r *http.Request) (any, error) { if l.Def != mine { continue } - amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug - if l.Out { - amount, movement = -amount, l.Txn.AccountSlug+" → ?" - } - out.Rows = append(out.Rows, transferPreviewRow{ - Marker: "⚠", Date: l.Txn.Date, Amount: model.FormatMinor(amount, l.Txn.MinorDigits), - Movement: movement, Description: l.Txn.Description, - }) + out.Rows = append(out.Rows, legRow(l)) } sort.SliceStable(out.Rows, func(i, j int) bool { return out.Rows[i].Date > out.Rows[j].Date }) if fees != 0 { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f6deb9d..2d20d95 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -426,3 +426,38 @@ to_desc = "*FROM CHECKING*" } } } + +// A ⚠ on the transfers screen opens onto the legs behind it: which side each +// is missing, and what that side would have to be. +func TestTransferListNamesTheMissingLegs(t *testing.T) { + _, h := newTestServer(t, ` +[[transfer]] +from_account = "checking" +from_desc = "*TO SAVINGS*" +to_account = "savings" +to_desc = "*FROM CHECKING*" +`, + fixtureTxn{"checking", "2026-02-01", "TO SAVINGS", -1000}, + fixtureTxn{"savings", "2026-02-02", "FROM CHECKING", 1000}, + fixtureTxn{"checking", "2026-02-20", "TO SAVINGS", -2500}, + fixtureTxn{"savings", "2026-01-10", "FROM CHECKING", 700}, + ) + var list struct { + Transfers []transferRow + Unpaired int + } + call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list) + legs := list.Transfers[0].Legs + if list.Unpaired != 2 || len(legs) != 2 { + t.Fatalf("unpaired = %d, legs = %+v; want the two lone legs", list.Unpaired, legs) + } + newest, oldest := legs[0], legs[1] + if newest.Movement != "checking → ?" || newest.Missing != "arriving" || + newest.Want != "+25.00 in savings matching *FROM CHECKING*, dated 2026-02-15 to 2026-02-25" { + t.Errorf("newest leg = %+v", newest) + } + if oldest.Movement != "? → savings" || oldest.Missing != "leaving" || + oldest.Want != "-7.00 in checking matching *TO SAVINGS*, dated 2026-01-05 to 2026-01-15" { + t.Errorf("oldest leg = %+v", oldest) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index c175f00..c2bb57a 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -889,6 +889,9 @@ function mountTransfers(main) { h('button', { type: 'button', onclick: () => refresh().then(() => setStatus('pairing refreshed')) }, 'Refresh pairing')), body); let defs = []; + let unpaired = 0; + // Which ⚠ rows are open, by position, so a refresh does not fold them away. + const open = new Set(); async function del(positions, question) { if (!confirm(question)) return; @@ -897,6 +900,7 @@ function mountTransfers(main) { positions, expect: positions.map((p) => transferExpect(defs[p])), }); setStatus(res.status); + open.clear(); // positions shift under a delete await refreshAll(); } catch (e) { setError(e); } } @@ -907,8 +911,13 @@ function mountTransfers(main) { res = await api('GET', '/api/transfers'); } catch (e) { setError(e); return; } defs = res.transfers; - title.replaceChildren('Transfers ', h('span', { class: 'sub' }, res.unpaired - ? `· ${defs.length} definitions · ${res.unpaired} leg(s) unpaired` + unpaired = res.unpaired; + draw(); + } + + function draw() { + title.replaceChildren('Transfers ', h('span', { class: 'sub' }, unpaired + ? `· ${defs.length} definitions · ${unpaired} leg(s) unpaired — open a ⚠ row to see them` : `· ${defs.length} definitions · every leg paired`)); // Only definitions catching nothing at all are pruned. One with unpaired // legs is doing something, and deleting it would hide the problem. @@ -928,9 +937,17 @@ function mountTransfers(main) { h('th', {}, 'Note'), h('th', {}, ''))), h('tbody', {}, defs.map((d) => { let mark = '', cls = 'mark', tip = null; - if (d.orphaned > 0) [mark, cls, tip] = ['⚠', 'mark warn', 'Catches legs it cannot pair']; + if (d.orphaned > 0) [mark, cls, tip] = [open.has(d.pos) ? '▾' : '⚠', 'mark warn', 'Show the legs it cannot pair']; else if (d.paired === 0) [mark, cls, tip] = ['✗', 'mark neg', 'Matches nothing at all']; - return h('tr', {}, + const toggle = () => { + if (open.has(d.pos)) open.delete(d.pos); else open.add(d.pos); + draw(); + }; + return [h('tr', { + class: d.orphaned > 0 ? 'clickable' : null, + title: d.orphaned > 0 ? 'Show the legs it cannot pair' : null, + onclick: d.orphaned > 0 ? (e) => { if (!e.target.closest('button')) toggle(); } : null, + }, h('td', { class: 'num muted' }, d.pos + 1), h('td', { class: cls, title: tip }, mark), h('td', {}, d.fromAccount, ' ', h('span', { style: 'font-family: var(--mono)' }, d.fromDesc)), @@ -942,9 +959,22 @@ function mountTransfers(main) { h('td', { class: 'actions' }, h('button', { type: 'button', class: 'danger', onclick: () => del([d.pos], `Delete transfer ${d.pos + 1} (${d.fromAccount} → ${d.toAccount}), ${d.paired} matched?`), - }, 'Delete'))); + }, 'Delete'))), + open.has(d.pos) && d.orphaned > 0 ? h('tr', { class: 'legs' }, h('td', { colspan: 9 }, legsTable(d))) : null]; }))))); } + + // legsTable lists a definition's unpaired legs: which side each is missing, + // and what that side would have had to look like to pair. + function legsTable(d) { + return h('table', { class: 'sub' }, + h('thead', {}, h('tr', {}, h('th', {}, 'Date'), h('th', { class: 'num' }, 'Amount'), h('th', {}, 'Movement'), + h('th', {}, 'Description'), h('th', {}, 'Missing'), h('th', {}, 'Would pair with'))), + h('tbody', {}, d.legs.map((l) => h('tr', {}, + h('td', {}, l.date), h('td', { class: 'num' }, l.amount), h('td', {}, l.movement), + h('td', { class: 'desc' }, l.description), h('td', { class: 'warn' }, l.missing + ' leg'), + h('td', { class: 'desc muted' }, l.want))))); + } refresh(); return { refresh }; } diff --git a/internal/web/static/style.css b/internal/web/static/style.css index bacd1f1..7a2eb15 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -145,6 +145,9 @@ tr.total td { font-weight: 600; border-top: 1px solid var(--border); } tr.excluded td { color: var(--muted); } tr.excluded.first td { border-top: 1px dashed var(--border); } td.actions { text-align: right; } +tr.legs > td { padding: 0 10px 10px 36px; background: var(--bg); } +tr.legs:hover { background: none; } +table.sub th { background: var(--bg); font-weight: 500; color: var(--muted); } td.actions button { padding: 1px 8px; font-size: 12px; } .empty { padding: 32px 16px; color: var(--muted); white-space: pre-line; text-align: center; }