Files
sure/app/models/concerns/accountable.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

107 lines
2.6 KiB
Ruby

module Accountable
extend ActiveSupport::Concern
TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability]
# Define empty hash to ensure all accountables have this defined
SUBTYPES = {}.freeze
def self.from_type(type)
return nil unless TYPES.include?(type)
type.constantize
end
included do
include Enrichable
has_one :account, as: :accountable, touch: true
end
class_methods do
def classification
raise NotImplementedError, "Accountable must implement #classification"
end
def icon
raise NotImplementedError, "Accountable must implement #icon"
end
def color
raise NotImplementedError, "Accountable must implement #color"
end
# Given a subtype, look up the label for this accountable type
# Uses i18n with fallback to hardcoded SUBTYPES values
def subtype_label_for(subtype, format: :short)
return nil if subtype.nil?
label_type = format == :long ? :long : :short
fallback = self::SUBTYPES.dig(subtype, label_type)
I18n.t(
"#{name.underscore.pluralize}.subtypes.#{subtype}.#{label_type}",
default: fallback
)
end
# Convenience method for getting the short label
def short_subtype_label_for(subtype)
subtype_label_for(subtype, format: :short)
end
# Convenience method for getting the long label
def long_subtype_label_for(subtype)
subtype_label_for(subtype, format: :long)
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
def display_name
self.name.pluralize.titleize
end
# Sums the balances of all active accounts of this type, converting foreign currencies to the family's currency.
# @return [BigDecimal] total balance in the family's currency
def balance_money(family)
accounts = family.accounts.active.where(accountable_type: self.name).to_a
foreign_currencies = accounts.filter_map { |a| a.currency if a.currency != family.currency }
rates = ExchangeRate.rates_for(foreign_currencies, to: family.currency, date: Date.current)
accounts.sum(BigDecimal(0)) { |account|
if account.currency == family.currency
account.balance
else
account.balance * (rates[account.currency] || 1)
end
}
end
end
def display_name
self.class.display_name
end
def balance_display_name
"account value"
end
def opening_balance_display_name
"opening balance"
end
def icon
self.class.icon
end
def color
self.class.color
end
def classification
self.class.classification
end
end