1
0
Fork 0
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:
Zach Gollwitzer 2025-07-23 18:15:14 -04:00 committed by GitHub
parent 3f92fe0f6f
commit f7f6ebb091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 723 additions and 539 deletions

View file

@ -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

View file

@ -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