From 6affb16768a4952e3ce74dcbc6554f9e0d4184f5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 18 Jul 2025 17:10:50 -0400 Subject: [PATCH] Tweaks to balance calculation to acknowledge holdings value better --- ..._group.html.erb => activity_date.html.erb} | 41 ++- app/components/UI/account/activity_date.rb | 51 ++++ .../UI/account/activity_feed.html.erb | 11 +- app/components/UI/account/activity_feed.rb | 12 +- .../UI/account/entries_date_group.rb | 68 ----- app/models/account/activity_feed_data.rb | 194 +++++++++---- app/models/balance.rb | 2 +- app/views/valuations/_valuation.html.erb | 8 +- app/views/valuations/show.html.erb | 2 +- .../models/account/activity_feed_data_test.rb | 254 +++++++++++++++--- 10 files changed, 450 insertions(+), 193 deletions(-) rename app/components/UI/account/{entries_date_group.html.erb => activity_date.html.erb} (58%) create mode 100644 app/components/UI/account/activity_date.rb delete mode 100644 app/components/UI/account/entries_date_group.rb diff --git a/app/components/UI/account/entries_date_group.html.erb b/app/components/UI/account/activity_date.html.erb similarity index 58% rename from app/components/UI/account/entries_date_group.html.erb rename to app/components/UI/account/activity_date.html.erb index e63acd4a..0d7f5057 100644 --- a/app/components/UI/account/entries_date_group.html.erb +++ b/app/components/UI/account/activity_date.html.erb @@ -27,33 +27,48 @@
-
Start of day balance
+
+ 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 Δ
+
+ Δ Cash + <%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %> +

-
<%= transaction_totals_money.format %>
+
<%= cash_change_money.format %>
-
Holdings Δ
+
+ Δ Holdings + <%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %> +

-
<%= holding_change_money.format %>
+
<%= holdings_change_money.format %>
<% else %>
-
Transaction totals
+
+ Δ Cash + <%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %> +

-
<%= transaction_totals_money.format %>
+
<%= cash_change_money.format %>
<% end %>
-
End of day balance
+
+ 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 %>
@@ -61,13 +76,19 @@
-
Value adjustments Δ
+
+ Δ 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 (incl. adjustments)
+
+ 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 new file mode 100644 index 00000000..17fa2255 --- /dev/null +++ b/app/components/UI/account/activity_date.rb @@ -0,0 +1,51 @@ +class UI::Account::ActivityDate < ApplicationComponent + attr_reader :account, :data + + delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data + + def initialize(account:, data:) + @account = account + @data = data + end + + def id + dom_id(account, "entries_#{date}") + end + + def broadcast_channel + 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 + end + + def broadcast_refresh! + Turbo::StreamsChannel.broadcast_replace_to( + broadcast_channel, + target: id, + renderable: self, + layout: false + ) + end +end diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index dde24b2e..c6740a0a 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -50,7 +50,7 @@ <% end %>
- <% if grouped_entries.empty? %> + <% if activity_dates.empty? %>

No entries yet

<% else %> <%= tag.div id: dom_id(account, "entries_bulk_select"), @@ -76,13 +76,10 @@
- <% grouped_entries.each do |date, entries| %> - <%= render UI::Account::EntriesDateGroup.new( + <% activity_dates.each do |activity_date_data| %> + <%= render UI::Account::ActivityDate.new( account: account, - date: date, - entries: entries, - balance_trend: balance_trend_for_date(date), - transfers: transfers_for_date(date) + data: activity_date_data ) %> <% end %>
diff --git a/app/components/UI/account/activity_feed.rb b/app/components/UI/account/activity_feed.rb index d58906cc..bb7fa3a1 100644 --- a/app/components/UI/account/activity_feed.rb +++ b/app/components/UI/account/activity_feed.rb @@ -24,16 +24,8 @@ class UI::Account::ActivityFeed < ApplicationComponent ) end - def grouped_entries - feed_data.entries.group_by(&:date).sort.reverse - end - - def balance_trend_for_date(date) - feed_data.trend_for_date(date) - end - - def transfers_for_date(date) - feed_data.transfers_for_date(date) + def activity_dates + feed_data.entries_by_date end private diff --git a/app/components/UI/account/entries_date_group.rb b/app/components/UI/account/entries_date_group.rb deleted file mode 100644 index 985b04ae..00000000 --- a/app/components/UI/account/entries_date_group.rb +++ /dev/null @@ -1,68 +0,0 @@ -class UI::Account::EntriesDateGroup < ApplicationComponent - attr_reader :account, :date, :entries, :balance_trend, :transfers - - def initialize(account:, date:, entries:, balance_trend:, transfers:) - @account = account - @date = date - @entries = entries - @balance_trend = balance_trend - @transfers = transfers - end - - def id - dom_id(account, "entries_#{date}") - end - - def broadcast_channel - account - end - - def valuation_entry - entries.find { |entry| entry.entryable_type == "Valuation" } - end - - def start_balance_money - balance_trend.previous - end - - def end_balance_before_adjustments_money - balance_trend.previous + transaction_totals_money - holding_change_money - end - - def adjustments_money - end_balance_money - end_balance_before_adjustments_money - end - - def transaction_totals_money - transactions = entries.select { |e| e.transaction? } - - if transactions.any? - transactions.sum { |e| e.amount_money } * -1 - else - Money.new(0, account.currency) - end - end - - def holding_change_money - trades = entries.select { |e| e.trade? } - - if trades.any? - trades.sum { |e| e.amount_money } * -1 - else - Money.new(0, account.currency) - end - end - - def end_balance_money - balance_trend.current - end - - def broadcast_refresh! - Turbo::StreamsChannel.broadcast_replace_to( - broadcast_channel, - target: id, - renderable: self, - layout: false - ) - end -end diff --git a/app/models/account/activity_feed_data.rb b/app/models/account/activity_feed_data.rb index 1854e327..28f92a64 100644 --- a/app/models/account/activity_feed_data.rb +++ b/app/models/account/activity_feed_data.rb @@ -2,6 +2,8 @@ # 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) + attr_reader :account, :entries def initialize(account, entries) @@ -9,63 +11,116 @@ class Account::ActivityFeedData @entries = entries.to_a end - def trend_for_date(date) - start_balance = start_balance_for_date(date) - end_balance = end_balance_for_date(date) - - Trend.new( - current: end_balance.balance_money, - previous: start_balance.balance_money - ) - end - - def transfers_for_date(date) - date_entries = entries_by_date[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) - } - end - - def exchange_rates_for_date(date) - exchange_rates.select { |rate| rate.date == date } + def entries_by_date + @entries_by_date_objects ||= begin + grouped_entries.map do |date, date_entries| + 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), + transfers: transfers_for_date(date) + ) + end + end 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 + 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) + } + 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) - locf_balance_for_date(date.prev_day) || generate_fallback_balance(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) - locf_balance_for_date(date) || generate_fallback_balance(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 entries_by_date - @entries_by_date ||= entries.group_by(&:date) - 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 - end + def grouped_entries + @grouped_entries ||= entries.group_by(&:date) end def needs_exchange_rates? @@ -89,17 +144,45 @@ class Account::ActivityFeedData rate_requirements = required_exchange_rates return [] if rate_requirements.empty? - # Build a single SQL query with all date/currency pairs + # Use ActiveRecord's or chain for better performance conditions = rate_requirements.map do |req| - "(date = ? AND from_currency = ? AND to_currency = ?)" - end.join(" OR ") + ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to) + end.reduce(:or) - # Flatten the parameters array in the same order - params = rate_requirements.flat_map do |req| - [ req.date, req.from, req.to ] + conditions.to_a + end + end + + def exchange_rate_for(date, from_currency, to_currency) + return 1.0 if from_currency == to_currency + + 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 + + def sum_entries_with_exchange_rates(entries, date) + return Money.new(0, account.currency) if entries.empty? + + 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 end + end + end - ExchangeRate.where(conditions, *params).to_a + # 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 end end @@ -107,18 +190,15 @@ class Account::ActivityFeedData entries.select { |entry| entry.transaction? }.map(&:entryable_id) end - def has_transfers? - entries.any? { |entry| entry.transaction? && entry.transaction.transfer? } - end - def transfers - return [] unless has_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 locf_balance_for_date(date) + def last_observed_balance_before_date(date) idx = balances.bsearch_index { |b| b.date > date } if idx diff --git a/app/models/balance.rb b/app/models/balance.rb index 90c4df41..ff28db90 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -3,7 +3,7 @@ class Balance < ApplicationRecord belongs_to :account validates :account, :date, :balance, presence: true - monetize :balance + monetize :balance, :cash_balance scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } end diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb index eeef903c..9bb41ce1 100644 --- a/app/views/valuations/_valuation.html.erb +++ b/app/views/valuations/_valuation.html.erb @@ -1,9 +1,9 @@ -<%# locals: (entry:, balance_trend: nil, **) %> +<%# locals: (entry:, **) %> <% valuation = entry.entryable %> -<% color = balance_trend&.trend&.color || "#D444F1" %> -<% icon = balance_trend&.trend&.icon || "plus" %> +<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %> +<% icon = valuation.opening_anchor? ? "plus" : "minus" %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(valuation) do %> @@ -26,7 +26,7 @@
- <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %> + <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
<% end %> diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb index f58be03e..089d98a4 100644 --- a/app/views/valuations/show.html.erb +++ b/app/views/valuations/show.html.erb @@ -24,7 +24,7 @@ max: Date.current %> <%= f.money_field :amount, - label: t(".amount"), + label: "Account value on date", disable_currency: true %>
diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb index 21a65e2f..b87c2e02 100644 --- a/test/models/account/activity_feed_data_test.rb +++ b/test/models/account/activity_feed_data_test.rb @@ -10,103 +10,267 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase @investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0) @test_period_start = Date.current - 4.days - @test_period_end = Date.current setup_test_data end - test "calculates correct trend for a given date when all balances exist" do + test "calculates balance trend with complete balance history" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) - # Trend for day 2 should show change from end of day 1 to end of day 2 - trend = feed_data.trend_for_date(@test_period_start + 1.day) + activities = feed_data.entries_by_date + 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 end - test "calculates trend with correct start and end values" do + test "calculates balance trend for first day with zero starting balance" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) - # First day trend (no previous day balance) - trend = feed_data.trend_for_date(@test_period_start) + activities = feed_data.entries_by_date + 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 end - test "uses last observation carried forward when intermediate balances are missing" do + 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 - trend = feed_data.trend_for_date(@test_period_start + 1.day) + 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 - - # When day 4 balance is missing, uses last available (day 1) - trend = feed_data.trend_for_date(@test_period_start + 3.days) - assert_equal 1000, trend.current.amount.to_i # LOCF from day 1 - assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1 end - test "returns zero-balance fallback when no prior balances exist" do + test "returns zero balance when no balance history exists" do @checking.balances.destroy_all entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) - trend = feed_data.trend_for_date(@test_period_start + 2.days) + 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 end + test "calculates cash and holdings trends for investment accounts" do + entries = @investment.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@investment, entries) + + activities = feed_data.entries_by_date + day3_activity = find_activity_for_date(activities, @test_period_start + 2.days) + + assert_not_nil day3_activity + + # 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 + end + test "identifies transfers for a specific date" do entries = @checking.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@checking, entries) + activities = feed_data.entries_by_date + # Day 2 has the transfer - transfers = feed_data.transfers_for_date(@test_period_start + 1.day) - assert_equal 1, transfers.size - assert_equal @transfer, transfers.first + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + assert_not_nil day2_activity + assert_equal 1, day2_activity.transfers.size + assert_equal @transfer, day2_activity.transfers.first # Other days have no transfers - transfers = feed_data.transfers_for_date(@test_period_start) - assert_empty transfers + day1_activity = find_activity_for_date(activities, @test_period_start) + assert_not_nil day1_activity + assert_empty day1_activity.transfers end - test "loads exchange rates only for entries with foreign currencies" do + test "returns complete ActivityDateData objects with all required fields" do entries = @investment.entries.includes(:entryable).to_a feed_data = Account::ActivityFeedData.new(@investment, entries) - rates = feed_data.exchange_rates_for_date(@test_period_start + 2.days) - assert_equal 1, rates.size - assert_equal "EUR", rates.first.from_currency - assert_equal "USD", rates.first.to_currency - assert_equal 1.1, rates.first.rate - - rates = feed_data.exchange_rates_for_date(@test_period_start) - assert_empty rates + activities = feed_data.entries_by_date + + # Check that we get ActivityDateData objects + assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) } + + # Check that each ActivityDate has the required fields + 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, :transfers + end end - test "returns empty exchange rates when no foreign currency entries exist" do - entries = @checking.entries.includes(:entryable).to_a - feed_data = Account::ActivityFeedData.new(@checking, entries) + test "handles valuations correctly by summing entry changes" 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, + 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 + currency: "USD" + ) + + # Create transactions + create_transaction( + account: account, + date: @test_period_start + 1.day, + amount: -50, + name: "Interest payment" + ) + create_transaction( + account: account, + date: @test_period_start + 1.day, + amount: -20, + name: "Interest payment" + ) + + # Create a trade + create_trade( + securities(:aapl), + account: account, + qty: 5, + date: @test_period_start + 1.day, + price: 150 # 5 * 150 = 750 + ) + + # Create valuation + create_valuation( + account: account, + date: @test_period_start + 1.day, + amount: 8500 + ) + + entries = account.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(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 $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 - rates = feed_data.exchange_rates_for_date(@test_period_start + 2.days) - assert_empty rates + 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 end private + def find_activity_for_date(activities, date) + activities.find { |a| a.date == date } + end def setup_test_data # Create daily balances for checking account @@ -119,6 +283,26 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase ) end + # Create daily balances for investment account with cash_balance + @investment.balances.create!( + date: @test_period_start, + balance: 500, + cash_balance: 500, + currency: "USD" + ) + @investment.balances.create!( + date: @test_period_start + 1.day, + balance: 500, + cash_balance: 500, + currency: "USD" + ) + @investment.balances.create!( + date: @test_period_start + 2.days, + balance: 1900, # 1500 holdings + 400 cash + cash_balance: 400, # After -100 EUR transaction + currency: "USD" + ) + # Day 1: Regular transaction create_transaction( account: @checking,