Files
sure/test/models/exchange_rate_test.rb
Mikael cc20e2c19c 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>
2026-02-19 18:07:47 +01:00

99 lines
3.1 KiB
Ruby

require "test_helper"
require "ostruct"
class ExchangeRateTest < ActiveSupport::TestCase
include ProviderTestHelper
setup do
@provider = mock
ExchangeRate.stubs(:provider).returns(@provider)
end
test "finds rate in DB" do
existing_rate = exchange_rates(:one)
@provider.expects(:fetch_exchange_rate).never
assert_equal existing_rate, ExchangeRate.find_or_fetch_rate(
from: existing_rate.from_currency,
to: existing_rate.to_currency,
date: existing_rate.date
)
end
test "fetches rate from provider without cache" do
ExchangeRate.delete_all
provider_response = provider_success_response(
OpenStruct.new(
from: "USD",
to: "EUR",
date: Date.current,
rate: 1.2
)
)
@provider.expects(:fetch_exchange_rate).returns(provider_response)
assert_no_difference "ExchangeRate.count" do
assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: false).rate
end
end
test "fetches rate from provider with cache" do
ExchangeRate.delete_all
provider_response = provider_success_response(
OpenStruct.new(
from: "USD",
to: "EUR",
date: Date.current,
rate: 1.2
)
)
@provider.expects(:fetch_exchange_rate).returns(provider_response)
assert_difference "ExchangeRate.count", 1 do
assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true).rate
end
end
test "returns nil on provider error" do
provider_response = provider_error_response(StandardError.new("Test error"))
@provider.expects(:fetch_exchange_rate).returns(provider_response)
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