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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+78
-9
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user