Transfer Matching and Pairing#
Sure provides three independent mechanisms to match existing transactions as transfer pairs: automatic (post-sync heuristic), manual (user-initiated via UI), and rule-based (Transaction Rule Engine action). All three resolve to the same Transfer join record linking an outflow transaction to an inflow transaction .
Automatic Matching#
Family::AutoTransferMatchable is mixed into the Family model and drives the auto-matching pipeline.
Trigger: auto_match_transfers! runs automatically after every account sync via Family::Syncer#perform_post_sync .
Candidate detection: transfer_match_candidates issues a UNION SQL query that finds transaction pairs across the family's accounts satisfying:
- Opposite-signed amounts (one positive outflow, one negative inflow)
- Dates within the configured
date_window(default 4 days) - A second branch handles multi-currency pairs using
exchange_rate_tolerance(default 0.1) - Excludes already-matched transactions and pairs recorded in
rejected_transfers
Rejection memory: Calling Transfer#reject! writes a RejectedTransfer record so the same pair is never re-proposed .
Manual Matching#
TransferMatchesController handles user-initiated pairing.
newaction presents candidate target accounts and pre-filtered match suggestions for the selected transaction .createaction callsbuild_transfer, which supports two modes:"existing"— links to an already-discovered matching transaction"new"— creates a placeholder transaction in the target account for pairing when no match exists
Rule-Based Matching#
Rule::ActionExecutor::SetAsTransferOrPayment is the Transaction Rule Engine action that converts matching transactions to transfers automatically.
- Its
executemethod builds a transfer to a configured target account and creates a missing counterpart transaction if needed . - Naming adapts based on whether the target is a liability account .
⚠️ Known issue: Pending auto-matched transfers can block rule execution. Because
Transferenforces uniqueness oninflow_transaction_id/outflow_transaction_id, an existing auto-match prevents a rule from reassigning the transaction .
Validation Rules#
The Transfer model enforces four constraints on all three pairing paths :
| Validation | Rule |
|---|---|
transfer_has_different_accounts | Inflow and outflow must be on different accounts |
transfer_has_opposite_amounts | Inflow must be negative, outflow positive; same-currency transfers must sum to zero |
transfer_has_same_family | Both accounts must belong to the same family |
transfer_within_date_range | Pending: max 4-day gap; Confirmed: max 30-day gap |
The 4-day default in transfer_match_candidates aligns with the pending-transfer date validation; the 30-day limit for confirmed transfers allows user-confirmed matches more flexibility .
Kind Assignment Across All Three Paths#
All three matching paths call Transfer.kind_for_account with the destination (inflow) account to set the outflow transaction's kind:
| Destination account type | Outflow kind |
|---|---|
loan? | loan_payment |
liability? (credit card, etc.) | cc_payment |
investment? or crypto? | investment_contribution |
| everything else | funds_movement |
The inflow transaction always receives kind: "funds_movement" regardless of account type .
⚠️ Known bug (investment↔investment misclassification):
Transfer.kind_for_accountonly accepts the destination account. It lacks the!source_is_investment?guard present inTransfer::Creator#outflow_transaction_kind, causing investment-to-investment transfers matched via auto-match, manual match, or rules to be misclassified asinvestment_contribution(counted as a budget expense) instead offunds_movement. Suggested fix: add an optionalsource_account:keyword toTransfer.kind_for_account.
Known Issues Summary#
| Issue | Reference |
|---|---|
| Full-table scan on every sync (O(N²) for large families) | #2447 |
| Concurrent sync race condition aborts PostgreSQL transaction | #2471 |
| Investment↔investment misclassification | #2541 |
| Auto-match blocks rule-based transfer creation | #2596 |
| ~16.5% of transfers fall in ambiguous groups (same amount + date) |
Key Files#
| File | Role |
|---|---|
app/models/family/auto_transfer_matchable.rb | transfer_match_candidates SQL + auto_match_transfers! |
app/controllers/transfer_matches_controller.rb | Manual match UI (new, create, build_transfer) |
app/models/rule/action_executor/set_as_transfer_or_payment.rb | Rule action that creates rule-based transfers |
app/models/transfer.rb | Model validations, kind_for_account, reject!, destroy! |