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