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..6cfabadc --- /dev/null +++ b/app/components/UI/account/balance_reconciliation.rb @@ -0,0 +1,150 @@ +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 } + ] + + items << { label: "Cash flows", value: net_cash_flow, tooltip: "Deposits and withdrawals during the day", style: :flow } if net_cash_flow != 0 + items << { label: "Trading activity", value: net_non_cash_flow, tooltip: "Net change from buying and selling securities", style: :flow } if net_non_cash_flow != 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 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/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/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" )