diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb index 0d7f5057..6efa7e96 100644 --- a/app/components/UI/account/activity_date.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -17,7 +17,7 @@
- <%= balance_trend.current.format %> + <%= end_balance_money.format %> <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %> @@ -25,73 +25,12 @@
-
-
-
- Start of day balance - <%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %> -
-
-
<%= start_balance_money.format %>
-
- - <% if account.balance_type == :investment %> -
-
- Δ Cash - <%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %> -
-
-
<%= cash_change_money.format %>
-
- -
-
- Δ Holdings - <%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %> -
-
-
<%= holdings_change_money.format %>
-
+
+ <% if balance %> + <%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %> <% else %> -
-
- Δ Cash - <%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %> -
-
-
<%= cash_change_money.format %>
-
+

No balance data available for this date

<% end %> - -
-
- End of day balance - <%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %> -
-
-
<%= end_balance_before_adjustments_money.format %>
-
- -
- -
-
- Δ Value adjustments - <%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %> -
-
-
<%= adjustments_money.format %>
-
- -
-
- Closing balance - <%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %> -
-
-
<%= end_balance_money.format %>
-
diff --git a/app/components/UI/account/activity_date.rb b/app/components/UI/account/activity_date.rb index 17fa2255..9de67f8f 100644 --- a/app/components/UI/account/activity_date.rb +++ b/app/components/UI/account/activity_date.rb @@ -1,7 +1,7 @@ class UI::Account::ActivityDate < ApplicationComponent attr_reader :account, :data - delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data + delegate :date, :entries, :balance, :transfers, to: :data def initialize(account:, data:) @account = account @@ -16,28 +16,8 @@ class UI::Account::ActivityDate < ApplicationComponent account end - def start_balance_money - balance_trend.previous - end - - def cash_change_money - cash_balance_trend.value - end - - def holdings_change_money - holdings_value_trend.value - end - - def end_balance_before_adjustments_money - balance_trend.previous + cash_change_money + holdings_change_money - end - - def adjustments_money - end_balance_money - end_balance_before_adjustments_money - end - def end_balance_money - balance_trend.current + balance&.end_balance_money || Money.new(0, account.currency) end def broadcast_refresh! diff --git a/app/components/UI/account/balance_reconciliation.html.erb b/app/components/UI/account/balance_reconciliation.html.erb new file mode 100644 index 00000000..9eb9e6cd --- /dev/null +++ b/app/components/UI/account/balance_reconciliation.html.erb @@ -0,0 +1,22 @@ +
+ <% reconciliation_items.each_with_index do |item, index| %> + <% if item[:style] == :subtotal %> +
+ <% end %> + +
+
+ <%= item[:label] %> + <%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %> +
+
"> +
"> + <%= item[:value].format %> +
+
+ + <% if item[:style] == :adjustment %> +
+ <% end %> + <% end %> +
diff --git a/app/components/UI/account/balance_reconciliation.rb b/app/components/UI/account/balance_reconciliation.rb new file mode 100644 index 00000000..980fad60 --- /dev/null +++ b/app/components/UI/account/balance_reconciliation.rb @@ -0,0 +1,155 @@ +class UI::Account::BalanceReconciliation < ApplicationComponent + attr_reader :balance, :account + + def initialize(balance:, account:) + @balance = balance + @account = account + end + + def reconciliation_items + case account.accountable_type + when "Depository", "OtherAsset", "OtherLiability" + default_items + when "CreditCard" + credit_card_items + when "Investment" + investment_items + when "Loan" + loan_items + when "Property", "Vehicle" + asset_items + when "Crypto" + crypto_items + else + default_items + end + end + + private + + def default_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start }, + { label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final } + items + end + + def credit_card_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start }, + { label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow }, + { label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final } + items + end + + def investment_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start } + ] + + # Change in brokerage cash (includes deposits, withdrawals, and cash from trades) + items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow } + + # Change in holdings from trading activity + items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow } + + # Market price changes + items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow } + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final } + items + end + + def loan_items + items = [ + { label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start }, + { label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow } + ] + + if has_adjustments? + items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal } + items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final } + items + end + + def asset_items # Property/Vehicle + items = [ + { label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start }, + { label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow } + ] + + if has_adjustments? + items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment } + end + + items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final } + items + end + + def crypto_items + items = [ + { label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start } + ] + + items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0 + items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0 + items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0 + + if has_adjustments? + items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } + items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + end + + items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final } + items + end + + def net_cash_flow + balance.cash_inflows_money - balance.cash_outflows_money + end + + def net_non_cash_flow + balance.non_cash_inflows_money - balance.non_cash_outflows_money + end + + def net_total_flow + net_cash_flow + net_non_cash_flow + balance.net_market_flows_money + end + + def total_adjustments + balance.cash_adjustments_money + balance.non_cash_adjustments_money + end + + def has_adjustments? + balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0 + end + + def end_balance_before_adjustments + balance.end_balance_money - total_adjustments + end +end diff --git a/app/models/account/activity_feed_data.rb b/app/models/account/activity_feed_data.rb index 28f92a64..7d1c41bd 100644 --- a/app/models/account/activity_feed_data.rb +++ b/app/models/account/activity_feed_data.rb @@ -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 diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index aac821b2..6fadcfa1 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -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 diff --git a/app/models/balance.rb b/app/models/balance.rb index dffc9f07..3b6f74ce 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -14,4 +14,18 @@ class Balance < ApplicationRecord scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } + + def balance_trend + Trend.new( + current: end_balance_money, + previous: start_balance_money, + favorable_direction: favorable_direction + ) + end + + private + + def favorable_direction + flows_factor == -1 ? "down" : "up" + end end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 9d1d288f..a1e43d99 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -29,16 +29,16 @@ class Balance::BaseCalculator end end - def cash_adjustments_for_date(start_cash, net_cash_flows, valuation) - return 0 unless valuation && account.balance_type != :non_cash + def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows) + return 0 unless account.balance_type != :non_cash - valuation.amount - start_cash - net_cash_flows + end_cash - start_cash - net_cash_flows end - def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation) - return 0 unless valuation && account.balance_type == :non_cash + def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows) + return 0 unless account.balance_type == :non_cash - valuation.amount - start_non_cash - non_cash_flows + end_non_cash - start_non_cash - non_cash_flows end # If holdings value goes from $100 -> $200 (change_holdings_value is $100) diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index 0c367685..dad10817 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder end def balance_series - build_series_for(:balance) + build_series_for(:end_balance) rescue => e Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}" raise end def cash_balance_series - build_series_for(:cash_balance) + build_series_for(:end_cash_balance) rescue => e Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}" raise end def holdings_balance_series - build_series_for(:holdings_balance) + build_series_for(:end_holdings_balance) rescue => e Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}" raise @@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder def build_series_for(column) values = query_data.map do |datum| + # Map column names to their start equivalents + previous_column = case column + when :end_balance then :start_balance + when :end_cash_balance then :start_cash_balance + when :end_holdings_balance then :start_holdings_balance + end + Series::Value.new( date: datum.date, date_formatted: I18n.l(datum.date, format: :long), value: Money.new(datum.send(column), currency), trend: Trend.new( current: Money.new(datum.send(column), currency), - previous: Money.new(datum.send("previous_#{column}"), currency), + previous: Money.new(datum.send(previous_column), currency), favorable_direction: favorable_direction ) ) @@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder WITH dates AS ( SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date UNION DISTINCT - SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date - ), aggregated_balances AS ( - SELECT - d.date, - -- Total balance (assets positive, liabilities negative) - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.balance, 0) - ELSE -COALESCE(last_bal.balance, 0) - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS balance, - -- Cash-only balance - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.cash_balance, 0) - ELSE -COALESCE(last_bal.cash_balance, 0) - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS cash_balance, - -- Holdings value (balance ‑ cash) - SUM( - CASE WHEN accounts.classification = 'asset' - THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0) - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ) AS holdings_balance - FROM dates d - JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[]) - - -- Last observation carried forward (LOCF), use the most recent balance on or before the chart date - LEFT JOIN LATERAL ( - SELECT b.balance, b.cash_balance - FROM balances b - WHERE b.account_id = accounts.id - AND b.date <= d.date - ORDER BY b.date DESC - LIMIT 1 - ) last_bal ON TRUE - - -- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date - LEFT JOIN LATERAL ( - SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date <= d.date - ORDER BY er.date DESC - LIMIT 1 - ) er ON TRUE - GROUP BY d.date + SELECT :end_date::date -- Ensure end date is included ) SELECT - date, - balance, - cash_balance, - holdings_balance, - COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance, - COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance, - COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance - FROM aggregated_balances - ORDER BY date + d.date, + -- Use flows_factor: already handles asset (+1) vs liability (-1) + COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance, + COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance, + -- Holdings only for assets (flows_factor = 1) + COALESCE(SUM( + CASE WHEN last_bal.flows_factor = 1 + THEN last_bal.end_non_cash_balance + ELSE 0 + END * COALESCE(er.rate, 1) * :sign_multiplier::integer + ), 0) AS end_holdings_balance, + -- Previous balances + COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance, + COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance, + COALESCE(SUM( + CASE WHEN last_bal.flows_factor = 1 + THEN last_bal.start_non_cash_balance + ELSE 0 + END * COALESCE(er.rate, 1) * :sign_multiplier::integer + ), 0) AS start_holdings_balance + FROM dates d + CROSS JOIN accounts + LEFT JOIN LATERAL ( + SELECT b.end_balance, + b.end_cash_balance, + b.end_non_cash_balance, + b.start_balance, + b.start_cash_balance, + b.start_non_cash_balance, + b.flows_factor + FROM balances b + WHERE b.account_id = accounts.id + AND b.date <= d.date + ORDER BY b.date DESC + LIMIT 1 + ) last_bal ON TRUE + LEFT JOIN LATERAL ( + SELECT er.rate + FROM exchange_rates er + WHERE er.from_currency = accounts.currency + AND er.to_currency = :target_currency + AND er.date <= d.date + ORDER BY er.date DESC + LIMIT 1 + ) er ON TRUE + WHERE accounts.id = ANY(array[:account_ids]::uuid[]) + GROUP BY d.date + ORDER BY d.date SQL end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 42a420f7..af092a2a 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -2,10 +2,10 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def calculate Rails.logger.tagged("Balance::ForwardCalculator") do start_cash_balance = derive_cash_balance_on_date_from_total( - total_balance: 0, + total_balance: account.opening_anchor_balance, date: account.opening_anchor_date ) - start_non_cash_balance = 0 + start_non_cash_balance = account.opening_anchor_balance - start_cash_balance calc_start_date.upto(calc_end_date).map do |date| valuation = sync_cache.get_valuation(date) @@ -24,6 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator flows = flows_for_date(date) market_value_change = market_value_change_on_date(date, flows) + cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor) + non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor) + output_balance = build_balance( date: date, balance: end_cash_balance + end_non_cash_balance, @@ -34,8 +37,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator cash_outflows: flows[:cash_outflows], non_cash_inflows: flows[:non_cash_inflows], non_cash_outflows: flows[:non_cash_outflows], - cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation), - non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation), + cash_adjustments: cash_adjustments, + non_cash_adjustments: non_cash_adjustments, net_market_flows: market_value_change ) @@ -75,4 +78,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator def derive_end_non_cash_balance(start_non_cash_balance:, date:) derive_non_cash_balance(start_non_cash_balance, date, direction: :forward) end + + def flows_factor + account.asset? ? 1 : -1 + end end diff --git a/app/models/balance/materializer.rb b/app/models/balance/materializer.rb index 75a98ffd..c6501ffa 100644 --- a/app/models/balance/materializer.rb +++ b/app/models/balance/materializer.rb @@ -28,9 +28,20 @@ class Balance::Materializer end def update_account_info - calculated_balance = @balances.sort_by(&:date).last&.balance || 0 - calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0 - calculated_cash_balance = calculated_balance - calculated_holdings_value + # Query fresh balance from DB to get generated column values + current_balance = account.balances + .where(currency: account.currency) + .order(date: :desc) + .first + + if current_balance + calculated_balance = current_balance.end_balance + calculated_cash_balance = current_balance.end_cash_balance + else + # Fallback if no balance exists + calculated_balance = 0 + calculated_cash_balance = 0 + end Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}") @@ -48,14 +59,23 @@ class Balance::Materializer current_time = Time.now account.balances.upsert_all( @balances.map { |b| b.attributes - .slice("date", "balance", "cash_balance", "currency") + .slice("date", "balance", "cash_balance", "currency", + "start_cash_balance", "start_non_cash_balance", + "cash_inflows", "cash_outflows", + "non_cash_inflows", "non_cash_outflows", + "net_market_flows", + "cash_adjustments", "non_cash_adjustments", + "flows_factor") .merge("updated_at" => current_time) }, unique_by: %i[account_id date currency] ) end def purge_stale_balances - deleted_count = account.balances.delete_by("date < ?", account.start_date) + sorted_balances = @balances.sort_by(&:date) + oldest_calculated_balance_date = sorted_balances.first&.date + newest_calculated_balance_date = sorted_balances.last&.date + deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date) Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0 end diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb index 2139076c..ec093791 100644 --- a/test/models/account/activity_feed_data_test.rb +++ b/test/models/account/activity_feed_data_test.rb @@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase setup_test_data end - test "calculates balance trend with complete balance history" do + test "returns balance for date with complete balance history" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) @@ -22,14 +22,11 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) assert_not_nil day2_activity - trend = day2_activity.balance_trend - assert_equal 1100, trend.current.amount.to_i # End of day 2 - assert_equal 1000, trend.previous.amount.to_i # End of day 1 - assert_equal 100, trend.value.amount.to_i - assert_equal "up", trend.direction.to_s + assert_not_nil day2_activity.balance + assert_equal 1100, day2_activity.balance.end_balance # End of day 2 end - test "calculates balance trend for first day with zero starting balance" do + test "returns balance for first day" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) @@ -37,49 +34,24 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day1_activity = find_activity_for_date(activities, @test_period_start) assert_not_nil day1_activity - trend = day1_activity.balance_trend - assert_equal 1000, trend.current.amount.to_i # End of first day - assert_equal 0, trend.previous.amount.to_i # Fallback to 0 - assert_equal 1000, trend.value.amount.to_i + assert_not_nil day1_activity.balance + assert_equal 1000, day1_activity.balance.end_balance # End of first day end - test "uses last observed balance when intermediate balances are missing" do - @checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all - - entries = @checking.entries.includes(:entryable).to_a - feed_data = Account::ActivityFeedData.new(@checking, entries) - - activities = feed_data.entries_by_date - - # When day 2 balance is missing, both start and end use day 1 balance - day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) - assert_not_nil day2_activity - trend = day2_activity.balance_trend - assert_equal 1000, trend.current.amount.to_i # LOCF from day 1 - assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1 - assert_equal 0, trend.value.amount.to_i - assert_equal "flat", trend.direction.to_s - end - - test "returns zero balance when no balance history exists" do + test "returns nil balance when no balance exists for date" do @checking.balances.destroy_all entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) activities = feed_data.entries_by_date - # Use first day which has a transaction day1_activity = find_activity_for_date(activities, @test_period_start) assert_not_nil day1_activity - trend = day1_activity.balance_trend - assert_equal 0, trend.current.amount.to_i # Fallback to 0 - assert_equal 0, trend.previous.amount.to_i # Fallback to 0 - assert_equal 0, trend.value.amount.to_i - assert_equal "flat", trend.direction.to_s + assert_nil day1_activity.balance end - test "calculates cash and holdings trends for investment accounts" do + test "returns cash and holdings data for investment accounts" do entries = @investment.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@investment, entries) @@ -87,20 +59,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day3_activity = find_activity_for_date(activities, @test_period_start + 2.days) assert_not_nil day3_activity + assert_not_nil day3_activity.balance - # Cash trend for day 3 (after foreign currency transaction) - cash_trend = day3_activity.cash_balance_trend - assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance - assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance - assert_equal(-100, cash_trend.value.amount.to_i) - assert_equal "down", cash_trend.direction.to_s - - # Holdings trend for day 3 (after trade) - holdings_trend = day3_activity.holdings_value_trend - assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance - assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade - assert_equal 1500, holdings_trend.value.amount.to_i - assert_equal "up", holdings_trend.direction.to_s + # Balance should have the new schema fields + assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance + assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value + assert_equal 1900, day3_activity.balance.end_balance # Total balance end test "identifies transfers for a specific date" do @@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase activities.each do |activity| assert_respond_to activity, :date assert_respond_to activity, :entries - assert_respond_to activity, :balance_trend - assert_respond_to activity, :cash_balance_trend - assert_respond_to activity, :holdings_value_trend + assert_respond_to activity, :balance assert_respond_to activity, :transfers end end - test "handles valuations correctly by summing entry changes" do + test "handles valuations correctly with new balance schema" do # Create account with known balances account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0) # Day 1: Starting balance account.balances.create!( date: @test_period_start, - balance: 7321.56, - cash_balance: 1000, + balance: 7321.56, # Keep old field for now + cash_balance: 1000, # Keep old field for now + start_cash_balance: 0, + start_non_cash_balance: 0, + cash_inflows: 1000, + cash_outflows: 0, + non_cash_inflows: 6321.56, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) # Day 2: Add transactions, trades and a valuation account.balances.create!( date: @test_period_start + 1.day, - balance: 8500, # Valuation sets this - cash_balance: 1070, # Cash increased by transactions + balance: 8500, # Keep old field for now + cash_balance: 1070, # Keep old field for now + start_cash_balance: 1000, + start_non_cash_balance: 6321.56, + cash_inflows: 70, + cash_outflows: 0, + non_cash_inflows: 750, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 358.44, currency: "USD" ) @@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) assert_not_nil day2_activity + assert_not_nil day2_activity.balance - # Cash change should be $70 (50 + 20 from transactions only, not trades) - assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i - - # Holdings change should be 750 (from the trade) - assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i - - # Total balance change - assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01 - end - - test "normalizes multi-currency entries on valuation days" do - # Create EUR account - eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0) - - # Day 1: Starting balance - eur_account.balances.create!( - date: @test_period_start, - balance: 1000, - cash_balance: 500, - currency: "EUR" - ) - - # Day 2: Multi-currency transactions and valuation - eur_account.balances.create!( - date: @test_period_start + 1.day, - balance: 2000, - cash_balance: 600, - currency: "EUR" - ) - - # Create USD transaction (should be converted to EUR) - create_transaction( - account: eur_account, - date: @test_period_start + 1.day, - amount: -100, - currency: "USD", - name: "USD Payment" - ) - - # Create exchange rate: 1 USD = 0.9 EUR - ExchangeRate.create!( - date: @test_period_start + 1.day, - from_currency: "USD", - to_currency: "EUR", - rate: 0.9 - ) - - # Create valuation - create_valuation( - account: eur_account, - date: @test_period_start + 1.day, - amount: 2000 - ) - - entries = eur_account.entries.includes(:entryable).to_a - feed_data = Account::ActivityFeedData.new(eur_account, entries) - - activities = feed_data.entries_by_date - day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) - - assert_not_nil day2_activity - - # Cash change should be 90 EUR (100 USD * 0.9) - # The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR - assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i - assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code + # Check new balance fields + assert_equal 1070, day2_activity.balance.end_cash_balance + assert_equal 7430, day2_activity.balance.end_non_cash_balance + assert_equal 8500, day2_activity.balance.end_balance end private @@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase end def setup_test_data - # Create daily balances for checking account + # Create daily balances for checking account with new schema 5.times do |i| date = @test_period_start + i.days + prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0 + @checking.balances.create!( date: date, - balance: 1000 + (i * 100), + balance: 1000 + (i * 100), # Keep old field for now + cash_balance: 1000 + (i * 100), # Keep old field for now + start_balance: prev_balance, + start_cash_balance: prev_balance, + start_non_cash_balance: 0, + cash_inflows: i == 0 ? 1000 : 100, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) end @@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase # Create daily balances for investment account with cash_balance @investment.balances.create!( date: @test_period_start, - balance: 500, - cash_balance: 500, + balance: 500, # Keep old field for now + cash_balance: 500, # Keep old field for now + start_balance: 0, + start_cash_balance: 0, + start_non_cash_balance: 0, + cash_inflows: 500, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) @investment.balances.create!( date: @test_period_start + 1.day, - balance: 500, - cash_balance: 500, + balance: 500, # Keep old field for now + cash_balance: 500, # Keep old field for now + start_balance: 500, + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) @investment.balances.create!( date: @test_period_start + 2.days, - balance: 1900, # 1500 holdings + 400 cash - cash_balance: 400, # After -100 EUR transaction + balance: 1900, # Keep old field for now + cash_balance: 400, # Keep old field for now + start_balance: 500, + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 100, + non_cash_inflows: 1500, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, currency: "USD" ) diff --git a/test/models/account/reconciliation_manager_test.rb b/test/models/account/reconciliation_manager_test.rb index 794c2cc5..fe5257e9 100644 --- a/test/models/account/reconciliation_manager_test.rb +++ b/test/models/account/reconciliation_manager_test.rb @@ -1,18 +1,15 @@ require "test_helper" class Account::ReconciliationManagerTest < ActiveSupport::TestCase + include BalanceTestHelper + setup do @account = accounts(:investment) @manager = Account::ReconciliationManager.new(@account) end test "new reconciliation" do - @account.balances.create!( - date: Date.current, - balance: 1000, - cash_balance: 500, - currency: @account.currency - ) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) result = @manager.reconcile_balance(balance: 1200, date: Date.current) @@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "updates existing reconciliation without date change" do - @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) # Existing reconciliation entry existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency) @@ -39,8 +36,8 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "updates existing reconciliation with date and amount change" do - @account.balances.create!(date: 5.days.ago, balance: 1000, cash_balance: 500, currency: @account.currency) - @account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency) + create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500) + create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700) # Existing reconciliation entry (5 days ago) existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency) @@ -63,12 +60,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "handles date conflicts" do - @account.balances.create!( - date: Date.current, - balance: 1000, - cash_balance: 1000, - currency: @account.currency - ) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000) # Existing reconciliation entry @account.entries.create!( @@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase end test "dry run does not persist account" do - @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency) + create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500) assert_no_difference "Valuation.count" do @manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true) diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 80d2467f..fbf5019f 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase + include BalanceTestHelper + setup do end @@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account.balances.destroy_all # With gaps - account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD") - account.balances.create!(date: Date.current, balance: 1200, currency: "USD") + create_balance(account: account, date: 3.days.ago.to_date, balance: 1000) + create_balance(account: account, date: 1.day.ago.to_date, balance: 1100) + create_balance(account: account, date: Date.current, balance: 1200) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], @@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account = accounts(:depository) account.balances.destroy_all - account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD") - account.balances.create!(date: Date.current, balance: 1200, currency: "USD") + create_balance(account: account, date: 2.days.ago.to_date, balance: 1000) + create_balance(account: account, date: 1.day.ago.to_date, balance: 1100) + create_balance(account: account, date: Date.current, balance: 1200) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], @@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase Balance.destroy_all - asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD") - asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD") - asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD") + create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500) + create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: asset_account, date: Date.current, balance: 1000) - liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD") - liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD") - liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD") + create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200) + create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200) + create_balance(account: liability_account, date: Date.current, balance: 100) builder = Balance::ChartSeriesBuilder.new( account_ids: [ asset_account.id, liability_account.id ], @@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase account = accounts(:credit_card) account.balances.destroy_all - account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD") - account.balances.create!(date: Date.current, balance: 500, currency: "USD") + create_balance(account: account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: account, date: Date.current, balance: 500) builder = Balance::ChartSeriesBuilder.new( account_ids: [ account.id ], diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 2a65ae19..b2462cb5 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 0.0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 }, + balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: { market_flows: 0 }, - adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 4.days.ago.to_date, @@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 1000, cash_balance: 1000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, flows: 0, - adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 4.days.ago.to_date, @@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 4.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 3.days.ago.to_date, @@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 4.days.ago.to_date, legacy_balances: { balance: 100, cash_balance: 100 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, + balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, flows: 0, - adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 3.days.ago.to_date, @@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 }, + balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans + adjustments: 0 }, { date: 1.day.ago.to_date, @@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 500000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 }, + balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 5000, cash_balance: 5000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, flows: 0, - adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + adjustments: 0 }, { date: 2.days.ago.to_date, @@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase ) end + test "investment account can have valuations that override balance" do + account = create_account_with_ledger( + account: { type: Investment, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 }, + { type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 } + ], + holdings: [ + { date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 }, + { date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 } + ] + ) + + # Given constant prices, overall balance (account value) should be constant + # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) + calculated = Balance::ForwardCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 4000 }, + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 }, + flows: 0, + adjustments: 0 + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 10000, cash_balance: 8900 }, + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 }, + flows: { net_market_flows: 100 }, + adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 } + }, + { + date: Date.current, + legacy_balances: { balance: 10100, cash_balance: 8900 }, + balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 }, + flows: { net_market_flows: 100 }, + adjustments: 0 + } + ] + ) + end + private def assert_balances(calculated_data:, expected_balances:) # Sort calculated data by date to ensure consistent ordering diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb index 4a5ac439..01d34769 100644 --- a/test/models/balance/materializer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -2,6 +2,7 @@ require "test_helper" class Balance::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper + include BalanceTestHelper setup do @account = families(:empty).accounts.create!( @@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase test "syncs balances" do Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once - @account.expects(:start_date).returns(2.days.ago.to_date) + expected_balances = [ + Balance.new( + date: 1.day.ago.to_date, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 500, + start_non_cash_balance: 0, + cash_inflows: 500, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: Date.current, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + ] - Balance::ForwardCalculator.any_instance.expects(:calculate).returns( - [ - Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") - ] - ) + Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances) assert_difference "@account.balances.count", 2 do Balance::Materializer.new(@account, strategy: :forward).materialize_balances end + + assert_balance_fields_persisted(expected_balances) end - test "purges stale balances and holdings" do - # Balance before start date is stale - @account.expects(:start_date).returns(2.days.ago.to_date).twice - stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD") + test "purges stale balances outside calculated range" do + # Create existing balances that will be stale + stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000) + stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000) - Balance::ForwardCalculator.any_instance.expects(:calculate).returns( - [ - stale_balance, - Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"), - Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") - ] - ) + # Calculator will return balances for only these dates + expected_balances = [ + Balance.new( + date: 2.days.ago.to_date, + balance: 10000, + cash_balance: 10000, + currency: "USD", + start_cash_balance: 10000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: 1.day.ago.to_date, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 10000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 9000, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ), + Balance.new( + date: Date.current, + balance: 1000, + cash_balance: 1000, + currency: "USD", + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + ] - assert_difference "@account.balances.count", 3 do + Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances) + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once + + # Should end up with 3 balances (stale ones deleted, new ones created) + assert_difference "@account.balances.count", 1 do Balance::Materializer.new(@account, strategy: :forward).materialize_balances end + + # Verify stale balances were deleted + assert_nil @account.balances.find_by(id: stale_old.id) + assert_nil @account.balances.find_by(id: stale_future.id) + + # Verify expected balances were persisted + assert_balance_fields_persisted(expected_balances) end + + private + + def assert_balance_fields_persisted(expected_balances) + expected_balances.each do |expected| + persisted = @account.balances.find_by(date: expected.date) + assert_not_nil persisted, "Balance for #{expected.date} should be persisted" + + # Check all balance component fields + assert_equal expected.balance, persisted.balance + assert_equal expected.cash_balance, persisted.cash_balance + assert_equal expected.start_cash_balance, persisted.start_cash_balance + assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance + assert_equal expected.cash_inflows, persisted.cash_inflows + assert_equal expected.cash_outflows, persisted.cash_outflows + assert_equal expected.non_cash_inflows, persisted.non_cash_inflows + assert_equal expected.non_cash_outflows, persisted.non_cash_outflows + assert_equal expected.net_market_flows, persisted.net_market_flows + assert_equal expected.cash_adjustments, persisted.cash_adjustments + assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments + assert_equal expected.flows_factor, persisted.flows_factor + end + end end diff --git a/test/support/balance_test_helper.rb b/test/support/balance_test_helper.rb new file mode 100644 index 00000000..e9eed0d7 --- /dev/null +++ b/test/support/balance_test_helper.rb @@ -0,0 +1,72 @@ +module BalanceTestHelper + def create_balance(account:, date:, balance:, cash_balance: nil, **attributes) + # If cash_balance is not provided, default to entire balance being cash + cash_balance ||= balance + + # Calculate non-cash balance + non_cash_balance = balance - cash_balance + + # Set default component values that will generate the desired end_balance + # flows_factor should be 1 for assets, -1 for liabilities + flows_factor = account.classification == "liability" ? -1 : 1 + + defaults = { + date: date, + balance: balance, + cash_balance: cash_balance, + currency: account.currency, + start_cash_balance: cash_balance, + start_non_cash_balance: non_cash_balance, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: flows_factor + } + + account.balances.create!(defaults.merge(attributes)) + end + + def create_balance_with_flows(account:, date:, start_balance:, end_balance:, + cash_portion: 1.0, cash_flow: 0, non_cash_flow: 0, + market_flow: 0, **attributes) + # Calculate cash and non-cash portions + start_cash = start_balance * cash_portion + start_non_cash = start_balance * (1 - cash_portion) + + # Calculate adjustments needed to reach end_balance + expected_end_cash = start_cash + cash_flow + expected_end_non_cash = start_non_cash + non_cash_flow + market_flow + expected_total = expected_end_cash + expected_end_non_cash + + # Calculate adjustments if end_balance doesn't match expected + total_adjustment = end_balance - expected_total + cash_adjustment = cash_portion * total_adjustment + non_cash_adjustment = (1 - cash_portion) * total_adjustment + + # flows_factor should be 1 for assets, -1 for liabilities + flows_factor = account.classification == "liability" ? -1 : 1 + + defaults = { + date: date, + balance: end_balance, + cash_balance: expected_end_cash + cash_adjustment, + currency: account.currency, + start_cash_balance: start_cash, + start_non_cash_balance: start_non_cash, + cash_inflows: cash_flow > 0 ? cash_flow : 0, + cash_outflows: cash_flow < 0 ? -cash_flow : 0, + non_cash_inflows: non_cash_flow > 0 ? non_cash_flow : 0, + non_cash_outflows: non_cash_flow < 0 ? -non_cash_flow : 0, + net_market_flows: market_flow, + cash_adjustments: cash_adjustment, + non_cash_adjustments: non_cash_adjustment, + flows_factor: flows_factor + } + + account.balances.create!(defaults.merge(attributes)) + end +end