Skip to content

Link both sides of a transfer with a rule - #38

Merged
ShichengRao merged 1 commit into
mainfrom
claude/transfer-pairing-rules
Aug 13, 2026
Merged

ShichengRao merged 1 commit into
mainfrom
claude/transfer-pairing-rules

Conversation

@ShichengRao

Copy link
Copy Markdown
Owner

Summary

Pairing the two sides of a transfer becomes a rule action, run from the Rules page — the same shape as categorization.

Money moved between your own accounts leaves one and arrives in the other. Counting both is double-counting, which is why a linked pair is held at kind=transfer and the analytics skip it. But linking was only ever a by-hand action, one pair at a time, and that does not survive contact with a year of biweekly transfers between a checking account and a brokerage.

Type of Change

  • Importer support
  • Bug fix
  • Tests
  • Documentation
  • Frontend
  • Backend
  • Refactor / cleanup

Details

A rule can now name the account holding the other side, next to the category and kind it already sets, with a window for how far apart the two sides may fall. A Link transfers button beside "Re-apply to unreviewed" runs every such rule; it is disabled, with an explanatory title, until some rule actually configures pairing.

The pairing itself goes through the same steps as linking by hand — kind_before_pair is recorded on both rows before they are moved to transfer — so unlinking still restores exactly what it did before, and the two paths cannot drift apart in what they mean by "paired".

Matching is deliberately narrow, because a wrong pair silently removes real spending from every total. The other half must be the exact opposite amount, in the named account, within the configured number of days. Same-signed rows are never considered: a row of equal size in the same direction is a different transaction that merely happens to cost the same. Where several candidates fit, nearest in time wins and ties break on id, so a second run over the same data reaches the same answer. Rows already linked are skipped, which makes re-running a no-op rather than a reshuffle — re-pairing one side would strand its old partner pointing at a row that no longer points back.

It runs as its own pass rather than at import time. The two sides of a transfer usually arrive in different files, and often days apart, so at import the other half frequently does not exist yet.

New columns are additive and nullable, so a rule without them behaves exactly as before.

Privacy Checklist

  • I used only synthetic test/sample data.
  • I did not include real account numbers, balances, merchants, names, addresses, or other personal details.
  • This change does not add or expand outbound network access.

Testing

make test (133 passed), make api-contract-check, make typecheck.

Nine new tests cover the happy path and, more importantly, the ways it should decline: a counterpart outside the window, a same-signed row of equal size, a rule with no pairing action, a rule pointing at its own account, rows already linked, and a description pattern that should exclude a lookalike. Plus that the nearest date wins when several candidates fit, that each row is claimed only once, and that fires are counted against the rule.

Also driven end to end in the app against a scratch profile: created the rule in the editor, confirmed the button was disabled beforehand and enabled after, ran it, and checked the database directly — both real pairs linked and held at transfer with their previous kinds preserved, while a same-amount expense on the same account inside the same window was correctly left alone. A second click linked nothing further and left apply_count unchanged.

Screenshots

The Rules page gains a "Link transfers" button; a pairing rule shows a "pairs with " pill in the Sets column.

Related Issue

n/a

Money moved between your own accounts leaves one and arrives in the
other. Counting both is double-counting, so a linked pair is held at
kind=transfer and the analytics ignore it. Linking was only ever a
by-hand action, one pair at a time, which does not survive contact with
a year of biweekly transfers.

A rule can now name the account holding the other side, alongside the
category and kind it already sets, and a "Link transfers" button runs
them the way "Re-apply to unreviewed" runs the rest. The pairing itself
goes through the same code path as linking by hand, so unlinking still
restores both kinds.

Matching is deliberately narrow, because a wrong pair silently removes
real spending from every total: the other half must be the exact
opposite amount, in the named account, within a configurable window of
days. Same-signed rows are ignored — a row of equal size in the same
direction is a different transaction that merely happens to cost the
same. Where several candidates fit, nearest in time wins and ties break
on id, so a second run over the same data reaches the same answer. Rows
already linked are left alone, which makes re-running a no-op rather
than a reshuffle.

It runs as its own pass rather than at import: the two sides of a
transfer usually arrive in different files, and often days apart, so at
import time the other half frequently does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ShichengRao
ShichengRao merged commit 25ff70a into main Aug 13, 2026
5 checks passed
@ShichengRao
ShichengRao deleted the claude/transfer-pairing-rules branch August 13, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant