Files
sure/app/models/balance_sheet/account_totals.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

83 lines
2.6 KiB
Ruby

class BalanceSheet::AccountTotals
def initialize(family, sync_status_monitor:)
@family = family
@sync_status_monitor = sync_status_monitor
end
def asset_accounts
@asset_accounts ||= account_rows.filter { |t| t.classification == "asset" }
end
def liability_accounts
@liability_accounts ||= account_rows.filter { |t| t.classification == "liability" }
end
private
attr_reader :family, :sync_status_monitor
AccountRow = Data.define(:account, :converted_balance, :is_syncing) do
def syncing? = is_syncing
# Allows Rails path helpers to generate URLs from the wrapper
def to_param = account.to_param
delegate_missing_to :account
end
def visible_accounts
@visible_accounts ||= family.accounts.visible.with_attached_logo
end
# Wraps each account in an AccountRow with its converted balance and sync status.
def account_rows
@account_rows ||= accounts.map do |account|
AccountRow.new(
account: account,
converted_balance: converted_balance_for(account),
is_syncing: sync_status_monitor.account_syncing?(account)
)
end
end
# Returns the cache key for storing visible account IDs, invalidated on data updates.
def cache_key
family.build_cache_key(
"balance_sheet_account_ids",
invalidate_on_data_updates: true
)
end
# Loads visible accounts, caching their IDs to speed up subsequent requests.
# On cache miss, loads records once and writes IDs; on hit, filters by cached IDs.
def accounts
@accounts ||= begin
ids = Rails.cache.read(cache_key)
if ids
visible_accounts.where(id: ids).to_a
else
records = visible_accounts.to_a
Rails.cache.write(cache_key, records.map(&:id))
records
end
end
end
# Batch-fetches today's exchange rates for all foreign currencies present in accounts.
# @return [Hash{String => Numeric}] currency code to rate mapping
def exchange_rates
@exchange_rates ||= begin
foreign_currencies = accounts.filter_map { |a| a.currency if a.currency != family.currency }
ExchangeRate.rates_for(foreign_currencies, to: family.currency, date: Date.current)
end
end
# Converts an account's balance to the family's currency using pre-fetched exchange rates.
# @return [BigDecimal] balance in the family's currency
def converted_balance_for(account)
return account.balance if account.currency == family.currency
rate = exchange_rates[account.currency]
account.balance * rate
end
end