diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 754ca225..988fda22 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -9,8 +9,6 @@ module Family::AutoTransferMatchable JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND - inflow_candidates.amount = -outflow_candidates.amount AND - inflow_candidates.currency = outflow_candidates.currency AND inflow_candidates.account_id <> outflow_candidates.account_id AND inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 ) @@ -24,12 +22,26 @@ module Family::AutoTransferMatchable rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id )") + .joins("LEFT JOIN exchange_rates ON ( + exchange_rates.date = outflow_candidates.date AND + exchange_rates.from_currency = outflow_candidates.currency AND + exchange_rates.to_currency = inflow_candidates.currency + )") .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id") .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id") .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id) .where("inflow_accounts.is_active = true") .where("outflow_accounts.is_active = true") .where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") + .where(" + ( + inflow_candidates.currency = outflow_candidates.currency AND + inflow_candidates.amount = -outflow_candidates.amount + ) OR ( + inflow_candidates.currency <> outflow_candidates.currency AND + ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN 0.95 AND 1.05 + ) + ") .where(existing_transfers: { id: nil }) .order("date_diff ASC") # Closest matches first end diff --git a/test/models/family/auto_transfer_matchable_test.rb b/test/models/family/auto_transfer_matchable_test.rb index 77bb80f0..6f03ad85 100644 --- a/test/models/family/auto_transfer_matchable_test.rb +++ b/test/models/family/auto_transfer_matchable_test.rb @@ -18,6 +18,51 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase end end + test "auto-matches multi-currency transfers" do + load_exchange_prices + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test match within lower 5% bound + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000) + create_transaction(date: Date.current, account: @credit_card, amount: -1330, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test match within upper 5% bound + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1500) + create_transaction(date: Date.current, account: @credit_card, amount: -2189, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test no match outside of slippage tolerance + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000) + create_transaction(date: Date.current, account: @credit_card, amount: -1320, currency: "CAD") + + assert_difference -> { Transfer.count } => 0 do + @family.auto_match_transfers! + end + end + + test "only matches inflow with correct currency when duplicate amounts exist" do + load_exchange_prices + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -500, currency: "CAD") + create_transaction(date: Date.current, account: @credit_card, amount: -500) + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + end + # In this scenario, our matching logic should find 4 potential matches. These matches should be ranked based on # days apart, then de-duplicated so that we aren't auto-matching the same transaction across multiple transfers. test "when 2 options exist, only auto-match one at a time, ranked by days apart" do @@ -53,4 +98,51 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase @family.auto_match_transfers! end end + + test "does not match transactions outside the 4-day window" do + create_transaction(date: 10.days.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -500) + + assert_no_difference -> { Transfer.count } do + @family.auto_match_transfers! + end + end + + test "does not match multi-currency transfer with missing exchange rate" do + create_transaction(date: Date.current, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "GBP") + + assert_no_difference -> { Transfer.count } do + @family.auto_match_transfers! + end + end + + private + def load_exchange_prices + rates = { + 4.days.ago.to_date => 1.36, + 3.days.ago.to_date => 1.37, + 2.days.ago.to_date => 1.38, + 1.day.ago.to_date => 1.39, + Date.current => 1.40 + } + + rates.each do |date, rate| + # USD to CAD + ExchangeRate.create!( + from_currency: "USD", + to_currency: "CAD", + date: date, + rate: rate + ) + + # CAD to USD (inverse) + ExchangeRate.create!( + from_currency: "CAD", + to_currency: "USD", + date: date, + rate: (1.0 / rate).round(6) + ) + end + end end