Fix foreign currency accounts using wrong exchange rate in balance sheet totals (#1010)

Balance sheet totals and accountable type summaries used a SQL JOIN on
exchange_rates matching only today's date, which returned NULL (defaulting
to 1:1) when no rate existed for that exact date. This caused foreign
currency accounts to show incorrect totals.

Changes:
- Refactor BalanceSheet::AccountTotals to batch-fetch exchange rates via
  ExchangeRate.rates_for, with provider fallback, instead of a SQL join
- Refactor Accountable.balance_money to use the same batch approach
- Add ExchangeRate.rates_for helper for deduplicated rate lookups
- Fix net worth chart query to fall back to the nearest future rate when
  no historical rate exists for a given date
- Add composite index on accounts (family_id, status, accountable_type)
- Reuse nearest cached exchange rate within a 5-day lookback window
  before calling the provider, preventing redundant API calls on
  weekends and holidays when providers return prior-day rates

https://claude.ai/code/session_01GyssBJxQqdWnuYofQRjUu8

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mikael
2026-02-20 02:07:47 +09:00
committed by GitHub
parent a63e1c5a89
commit cc20e2c19c
8 changed files with 132 additions and 37 deletions

View File

@@ -52,11 +52,11 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
)
# Only 1 rate in DB. We'll be missing the first and last days in the series.
# This rate should be applied to 1 day ago and today, but not 2 days ago (will fall back to 1)
# This rate should be applied to all days: LOCF for future dates, nearest future rate for earlier dates.
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 2)
expected = [
1000, # No rate available, so fall back to 1:1 conversion (1000 USD = 1000 EUR)
2000, # No prior rate, so use nearest future rate (2:1 from 1 day ago): 1000 * 2 = 2000
2200, # Rate available, so use 2:1 conversion (1100 USD = 2200 EUR)
2400 # Rate NOT available, but LOCF will use the last available rate, so use 2:1 conversion (1200 USD = 2400 EUR)
]

View File

@@ -67,4 +67,32 @@ class ExchangeRateTest < ActiveSupport::TestCase
assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
end
test "reuses nearest cached rate within lookback window instead of calling provider" do
# Simulate a rate saved under Friday's date when Saturday is requested
friday = 1.day.ago.to_date
ExchangeRate.create!(from_currency: "USD", to_currency: "JPY", date: friday, rate: 150.5)
saturday = Date.current
@provider.expects(:fetch_exchange_rate).never
result = ExchangeRate.find_or_fetch_rate(from: "USD", to: "JPY", date: saturday)
assert_equal 150.5, result.rate
assert_equal friday, result.date
end
test "does not reuse cached rate outside lookback window" do
old_date = (ExchangeRate::NEAREST_RATE_LOOKBACK_DAYS + 1).days.ago.to_date
ExchangeRate.create!(from_currency: "USD", to_currency: "JPY", date: old_date, rate: 140.0)
provider_response = provider_success_response(
OpenStruct.new(from: "USD", to: "JPY", date: Date.current, rate: 155.0)
)
@provider.expects(:fetch_exchange_rate).returns(provider_response)
result = ExchangeRate.find_or_fetch_rate(from: "USD", to: "JPY", date: Date.current)
assert_equal 155.0, result.rate
end
end