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