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,