1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-06 05:55:21 +02:00

Balance reconcilations with new components

This commit is contained in:
Zach Gollwitzer 2025-07-23 13:41:39 -04:00
parent 3f92fe0f6f
commit ade88851fd
7 changed files with 323 additions and 390 deletions

View file

@ -17,7 +17,7 @@
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium"><%= balance_trend.current.format %></span>
<span class="font-medium"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
@ -25,73 +25,12 @@
</div>
</summary>
<div class="p-4 space-y-3">
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
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") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd class="font-bold"><%= start_balance_money.format %></dd>
</dl>
<% if account.balance_type == :investment %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Cash
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= cash_change_money.format %></dd>
</dl>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Holdings
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= holdings_change_money.format %></dd>
</dl>
<div class="p-4">
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% else %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Cash
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= cash_change_money.format %></dd>
</dl>
<p class="text-sm text-secondary">No balance data available for this date</p>
<% end %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
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") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
</dl>
<hr class="border border-primary">
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; 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") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= adjustments_money.format %></dd>
</dl>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
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") %>
</dt>
<hr class="grow border-dashed border-primary">
<dd class="font-bold"><%= end_balance_money.format %></dd>
</dl>
</div>
</details>

View file

@ -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!

View file

@ -0,0 +1,22 @@
<div class="space-y-3">
<% reconciliation_items.each_with_index do |item, index| %>
<% if item[:style] == :subtotal %>
<hr class="border border-primary">
<% end %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
<%= item[:label] %>
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
<%= item[:value].format %>
</dd>
</dl>
<% if item[:style] == :adjustment %>
<hr class="border border-primary">
<% end %>
<% end %>
</div>

View file

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

View file

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

View file

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

View file

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