Link both sides of a transfer with a rule - #38
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=transferand 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
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_pairis recorded on both rows before they are moved totransfer— 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
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
transferwith 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 leftapply_countunchanged.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