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