mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-06 14:05:20 +02:00
Use new balance components in activity feed (#2511)
* Balance reconcilations with new components * Fix materializer and test assumptions * Fix investment valuation calculations and recon display * Lint fixes * Balance series uses new component fields
This commit is contained in:
parent
3f92fe0f6f
commit
f7f6ebb091
17 changed files with 723 additions and 539 deletions
|
@ -2,7 +2,7 @@
|
|||
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
|
||||
# activity feed component in controllers and background jobs that refresh it.
|
||||
class Account::ActivityFeedData
|
||||
ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers)
|
||||
ActivityDateData = Data.define(:date, :entries, :balance, :transfers)
|
||||
|
||||
attr_reader :account, :entries
|
||||
|
||||
|
@ -17,9 +17,7 @@ class Account::ActivityFeedData
|
|||
ActivityDateData.new(
|
||||
date: date,
|
||||
entries: date_entries,
|
||||
balance_trend: balance_trend_for_date(date),
|
||||
cash_balance_trend: cash_balance_trend_for_date(date),
|
||||
holdings_value_trend: holdings_value_trend_for_date(date),
|
||||
balance: balance_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
)
|
||||
end
|
||||
|
@ -27,193 +25,61 @@ class Account::ActivityFeedData
|
|||
end
|
||||
|
||||
private
|
||||
def balance_trend_for_date(date)
|
||||
build_trend_for_date(date, :balance_money)
|
||||
end
|
||||
|
||||
def cash_balance_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate cash change from transaction entries only
|
||||
transactions = date_entries.select { |e| e.transaction? }
|
||||
cash_change = sum_entries_with_exchange_rates(transactions, date) * -1
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
Trend.new(
|
||||
current: start_balance.cash_balance_money + cash_change,
|
||||
previous: start_balance.cash_balance_money
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date, :cash_balance_money)
|
||||
end
|
||||
end
|
||||
|
||||
def holdings_value_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate holdings change from trade entries only
|
||||
trades = date_entries.select { |e| e.trade? }
|
||||
holdings_change = sum_entries_with_exchange_rates(trades, date)
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
start_holdings = start_balance.balance_money - start_balance.cash_balance_money
|
||||
Trend.new(
|
||||
current: start_holdings + holdings_change,
|
||||
previous: start_holdings
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date) do |balance|
|
||||
balance.balance_money - balance.cash_balance_money
|
||||
end
|
||||
end
|
||||
def balance_for_date(date)
|
||||
balances_by_date[date]
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
return [] if date_entries.empty?
|
||||
|
||||
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
|
||||
return [] if date_transaction_ids.empty?
|
||||
|
||||
# Convert to Set for O(1) lookups
|
||||
date_transaction_id_set = Set.new(date_transaction_ids)
|
||||
|
||||
transfers.select { |txfr|
|
||||
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
|
||||
date_transaction_id_set.include?(txfr.outflow_transaction_id)
|
||||
}
|
||||
transfers_by_date[date] || []
|
||||
end
|
||||
|
||||
def build_trend_for_date(date, method = nil)
|
||||
start_balance = start_balance_for_date(date)
|
||||
end_balance = end_balance_for_date(date)
|
||||
|
||||
if block_given?
|
||||
Trend.new(
|
||||
current: yield(end_balance),
|
||||
previous: yield(start_balance)
|
||||
)
|
||||
else
|
||||
Trend.new(
|
||||
current: end_balance.send(method),
|
||||
previous: start_balance.send(method)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def start_balance_for_date(date)
|
||||
@start_balance_for_date ||= {}
|
||||
@start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def end_balance_for_date(date)
|
||||
@end_balance_for_date ||= {}
|
||||
@end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
RequiredExchangeRate = Data.define(:date, :from, :to)
|
||||
|
||||
def grouped_entries
|
||||
@grouped_entries ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
def needs_exchange_rates?
|
||||
entries.any? { |entry| entry.currency != account.currency }
|
||||
end
|
||||
def balances_by_date
|
||||
@balances_by_date ||= begin
|
||||
return {} if entries.empty?
|
||||
|
||||
def required_exchange_rates
|
||||
multi_currency_entries = entries.select { |entry| entry.currency != account.currency }
|
||||
|
||||
multi_currency_entries.map do |entry|
|
||||
RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency)
|
||||
end.uniq
|
||||
end
|
||||
|
||||
# If the account has entries denominated in a different currency than the main account, we attach necessary
|
||||
# exchange rates required to "roll up" the entry group balance into the normal account currency.
|
||||
def exchange_rates
|
||||
return [] unless needs_exchange_rates?
|
||||
|
||||
@exchange_rates ||= begin
|
||||
rate_requirements = required_exchange_rates
|
||||
return [] if rate_requirements.empty?
|
||||
|
||||
# Use ActiveRecord's or chain for better performance
|
||||
conditions = rate_requirements.map do |req|
|
||||
ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to)
|
||||
end.reduce(:or)
|
||||
|
||||
conditions.to_a
|
||||
dates = grouped_entries.keys
|
||||
account.balances
|
||||
.where(date: dates, currency: account.currency)
|
||||
.index_by(&:date)
|
||||
end
|
||||
end
|
||||
|
||||
def exchange_rate_for(date, from_currency, to_currency)
|
||||
return 1.0 if from_currency == to_currency
|
||||
def transfers_by_date
|
||||
@transfers_by_date ||= begin
|
||||
return {} if transaction_ids.empty?
|
||||
|
||||
rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency }
|
||||
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
|
||||
end
|
||||
transfers = Transfer
|
||||
.where(inflow_transaction_id: transaction_ids)
|
||||
.or(Transfer.where(outflow_transaction_id: transaction_ids))
|
||||
.to_a
|
||||
|
||||
def sum_entries_with_exchange_rates(entries, date)
|
||||
return Money.new(0, account.currency) if entries.empty?
|
||||
# Group transfers by the date of their transaction entries
|
||||
result = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
entries.sum do |entry|
|
||||
amount = entry.amount_money
|
||||
if entry.currency != account.currency
|
||||
rate = exchange_rate_for(date, entry.currency, account.currency)
|
||||
Money.new(amount.amount * rate, account.currency)
|
||||
else
|
||||
amount
|
||||
entries.each do |entry|
|
||||
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
|
||||
|
||||
transfers.each do |transfer|
|
||||
if transfer.inflow_transaction_id == entry.entryable_id ||
|
||||
transfer.outflow_transaction_id == entry.entryable_id
|
||||
result[entry.date] << transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
|
||||
def balances
|
||||
@balances ||= begin
|
||||
return [] if entries.empty?
|
||||
|
||||
min_date = entries.min_by(&:date).date.prev_day
|
||||
max_date = entries.max_by(&:date).date
|
||||
|
||||
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
|
||||
# Remove duplicates
|
||||
result.transform_values(&:uniq)
|
||||
end
|
||||
end
|
||||
|
||||
def transaction_ids
|
||||
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
|
||||
end
|
||||
|
||||
def transfers
|
||||
return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty?
|
||||
return [] if transaction_ids.empty?
|
||||
|
||||
@transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a
|
||||
end
|
||||
|
||||
# Use binary search since balances are sorted by date
|
||||
def last_observed_balance_before_date(date)
|
||||
idx = balances.bsearch_index { |b| b.date > date }
|
||||
|
||||
if idx
|
||||
idx > 0 ? balances[idx - 1] : nil
|
||||
else
|
||||
balances.last
|
||||
end
|
||||
end
|
||||
|
||||
def generate_fallback_balance(date)
|
||||
Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: 0,
|
||||
currency: account.currency
|
||||
)
|
||||
@transaction_ids ||= entries
|
||||
.select(&:transaction?)
|
||||
.map(&:entryable_id)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
|
|
|
@ -82,8 +82,8 @@ class Account::ReconciliationManager
|
|||
balance_record = account.balances.find_by(date: date, currency: account.currency)
|
||||
|
||||
{
|
||||
cash_balance: balance_record&.cash_balance,
|
||||
balance: balance_record&.balance
|
||||
cash_balance: balance_record&.end_cash_balance,
|
||||
balance: balance_record&.end_balance
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue