mirror of
https://github.com/we-promise/sure.git
synced 2026-04-10 15:54:48 +00:00
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>
107 lines
2.6 KiB
Ruby
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
|