Skip to content

Commit c274c5d

Browse files
authored
fix(recurring): match transfer pairs so Cleaner stops mis-retiring transfers (#2110)
Closes #1590. Implements Option A (the proper fix), replacing the interim skip. A recurring transfer's name is seeded as "Transfer to {dest}", but future occurrences carry arbitrary names (user free-text, importer wording, the auto-matcher), so the name-based matching_transactions returned [] and the Cleaner retired still-active transfers at the 6-month threshold. main worked around this by skipping transfer rows entirely (Option B) — which also meant a genuinely-stopped transfer never got retired. matching_transactions now detects the Transfer *pair* for transfer rows: an outflow on the source account paired with an inflow on the destination account, within the usual amount/cadence window. The Cleaner no longer skips transfers: - a transfer whose pair still occurs keeps surfacing recent matches → stays active - a transfer whose pair has stopped → correctly retired The amount / day-of-month scopes are extracted and shared between the name-based and pair-based paths. The Identifier's separate transfer skip (auto-identifying pairs from history) is intentionally untouched — that's the out-of-scope feature the issue defers.
1 parent e232818 commit c274c5d

3 files changed

Lines changed: 106 additions & 36 deletions

File tree

app/models/recurring_transaction.rb

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -260,29 +260,15 @@ def self.calculate_next_expected_date_for(from_date, expected_day)
260260

261261
# Find matching transactions for this recurring pattern
262262
def matching_transactions
263-
# For manual recurring with amount variance, match within range
264-
# For automatic recurring, match exact amount
265-
base = account.present? ? account.entries : family.entries
263+
# Recurring transfers can't be matched by single-account name/amount —
264+
# future occurrences carry arbitrary names — so match the Transfer pair.
265+
return transfer_matching_transactions if transfer?
266266

267-
entries = if manual? && has_amount_variance?
268-
base
269-
.where(entryable_type: "Transaction")
270-
.where(currency: currency)
271-
.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
272-
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
273-
[ expected_day_of_month - 2, 1 ].max,
274-
[ expected_day_of_month + 2, 31 ].min)
275-
.order(date: :desc)
276-
else
277-
base
278-
.where(entryable_type: "Transaction")
279-
.where(currency: currency)
280-
.where("entries.amount = ?", amount)
281-
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
282-
[ expected_day_of_month - 2, 1 ].max,
283-
[ expected_day_of_month + 2, 31 ].min)
284-
.order(date: :desc)
285-
end
267+
# Amount/cadence-scoped Transaction entries on this account (or family).
268+
base = account.present? ? account.entries : family.entries
269+
entries = day_of_month_scope(
270+
amount_window_scope(base.where(entryable_type: "Transaction").where(currency: currency))
271+
).order(date: :desc)
286272

287273
# Filter by merchant or name
288274
if merchant_id.present?
@@ -401,6 +387,47 @@ def projected_entry
401387
end
402388

403389
private
390+
# Issue #1590: a recurring transfer's future occurrences rarely share the
391+
# seed's name (user free-text, importer wording, the auto-matcher's
392+
# "Transfer to ..."), so name-based matching returns [] and the Cleaner
393+
# would wrongly inactivate a still-active transfer. Match the Transfer
394+
# *pair* instead — an outflow on the source account paired with an inflow
395+
# on the destination account, within the usual amount/cadence window — and
396+
# return the outflow entries (the occurrence-date carrier, consistent with
397+
# create_from_transfer).
398+
def transfer_matching_transactions
399+
return Entry.none unless account && destination_account
400+
401+
outflow_entries = day_of_month_scope(
402+
amount_window_scope(account.entries.where(entryable_type: "Transaction").where(currency: currency))
403+
).order(date: :desc)
404+
405+
paired_outflow_transaction_ids = Transfer
406+
.where(outflow_transaction_id: outflow_entries.select(:entryable_id))
407+
.where(inflow_transaction_id:
408+
destination_account.entries.where(entryable_type: "Transaction").select(:entryable_id))
409+
.pluck(:outflow_transaction_id)
410+
411+
outflow_entries.where(entryable_id: paired_outflow_transaction_ids)
412+
end
413+
414+
# Transaction entries whose amount fits the pattern: exact, or within the
415+
# configured variance band for manual recurring rows.
416+
def amount_window_scope(relation)
417+
if manual? && has_amount_variance?
418+
relation.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
419+
else
420+
relation.where("entries.amount = ?", amount)
421+
end
422+
end
423+
424+
# Entries whose day-of-month lands within ±2 days of the expected day.
425+
def day_of_month_scope(relation)
426+
relation.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
427+
[ expected_day_of_month - 2, 1 ].max,
428+
[ expected_day_of_month + 2, 31 ].min)
429+
end
430+
404431
def monetizable_currency
405432
currency
406433
end

app/models/recurring_transaction/cleaner.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,15 @@ def initialize(family)
99
# Mark recurring transactions as inactive if they haven't occurred recently
1010
# Uses 2 months for automatic recurring, 6 months for manual recurring.
1111
#
12-
# Transfer rows (destination_account_id present) are skipped: their
13-
# `matching_transactions` helper looks at single-account name/amount
14-
# which never matches a Transfer pair, so the Cleaner would
15-
# incorrectly mark a still-recurring transfer inactive at the
16-
# 6-month threshold. Issue #1590 tracks pair-detection-aware
17-
# matching for recurring transfers.
12+
# Transfer rows (destination_account_id present) are included: as of issue
13+
# #1590, `matching_transactions` detects the Transfer pair, so a still-active
14+
# transfer keeps surfacing recent matches and stays active, while one whose
15+
# pair has genuinely stopped is correctly retired.
1816
def cleanup_stale_transactions
1917
stale_count = 0
2018

2119
family.recurring_transactions
2220
.active
23-
.where(destination_account_id: nil)
2421
.find_each do |recurring_transaction|
2522
next unless recurring_transaction.should_be_inactive?
2623

test/models/recurring_transaction_test.rb

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -998,12 +998,9 @@ def setup
998998
assert_not RecurringTransaction.exists?(rt.id)
999999
end
10001000

1001-
test "Cleaner skips recurring transfers so they aren't mistakenly marked inactive" do
1002-
# `matching_transactions` is single-account name/amount-based and never
1003-
# matches a Transfer pair, so without the skip the recurring transfer
1004-
# would flip to inactive at the 6-month threshold even when the user
1005-
# is still doing the transfer monthly. Issue #1590 tracks the proper
1006-
# pair-detection fix.
1001+
test "Cleaner keeps a recurring transfer active when its pair still occurs (issue #1590)" do
1002+
# The seed name rarely matches future occurrences, so pair detection (not
1003+
# name matching) is what keeps a live transfer active past the threshold.
10071004
rt = @family.recurring_transactions.create!(
10081005
account: @account, destination_account: accounts(:credit_card),
10091006
name: "Transfer to CC", amount: 250, currency: "USD",
@@ -1012,12 +1009,61 @@ def setup
10121009
next_expected_date: 5.days.from_now.to_date,
10131010
manual: true
10141011
)
1015-
assert rt.should_be_inactive?, "guard sanity: row would be marked inactive without the skip"
1012+
assert rt.should_be_inactive?, "guard sanity: stale last_occurrence_date"
1013+
1014+
# A fresh transfer pair this cycle, carrying a *different* free-text name.
1015+
date = 1.month.ago.beginning_of_month + 4.days # day-of-month 5
1016+
outflow = @account.entries.create!(
1017+
date: date, amount: 250, currency: "USD", name: "rent transfer",
1018+
entryable: Transaction.new(kind: "funds_movement")
1019+
)
1020+
inflow = accounts(:credit_card).entries.create!(
1021+
date: date, amount: -250, currency: "USD", name: "rent transfer",
1022+
entryable: Transaction.new(kind: "funds_movement")
1023+
)
1024+
Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable)
10161025

10171026
RecurringTransaction.cleanup_stale_for(@family)
10181027
assert_equal "active", rt.reload.status
10191028
end
10201029

1030+
test "Cleaner retires a recurring transfer whose pair has stopped" do
1031+
# No matching Transfer pair → genuinely stale → should be retired. This is
1032+
# the correctness the pair-detection (vs the old blanket skip) buys us.
1033+
rt = @family.recurring_transactions.create!(
1034+
account: @account, destination_account: accounts(:credit_card),
1035+
name: "Transfer to CC", amount: 250, currency: "USD",
1036+
expected_day_of_month: 5,
1037+
last_occurrence_date: 7.months.ago.to_date,
1038+
next_expected_date: 5.days.from_now.to_date,
1039+
manual: true
1040+
)
1041+
1042+
RecurringTransaction.cleanup_stale_for(@family)
1043+
assert_equal "inactive", rt.reload.status
1044+
end
1045+
1046+
test "matching_transactions finds the transfer pair regardless of occurrence name" do
1047+
rt = @family.recurring_transactions.create!(
1048+
account: @account, destination_account: accounts(:credit_card),
1049+
name: "Transfer to CC", amount: 250, currency: "USD",
1050+
expected_day_of_month: 5, last_occurrence_date: Date.current,
1051+
next_expected_date: 1.month.from_now.to_date, manual: true
1052+
)
1053+
date = Date.current.beginning_of_month + 4.days # day-of-month 5
1054+
outflow = @account.entries.create!(
1055+
date: date, amount: 250, currency: "USD", name: "an importer's wording",
1056+
entryable: Transaction.new(kind: "funds_movement")
1057+
)
1058+
inflow = accounts(:credit_card).entries.create!(
1059+
date: date, amount: -250, currency: "USD", name: "an importer's wording",
1060+
entryable: Transaction.new(kind: "funds_movement")
1061+
)
1062+
Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable)
1063+
1064+
assert_includes rt.matching_transactions.map(&:id), outflow.id
1065+
end
1066+
10211067
test "Identifier#update_manual_recurring_transactions skips recurring transfers" do
10221068
# Same reasoning as the Cleaner skip. Without the guard, the helper
10231069
# would call find_matching_transaction_entries (single-account, by

0 commit comments

Comments
 (0)