mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-06 05:55:21 +02:00
Tweaks to balance calculation to acknowledge holdings value better
This commit is contained in:
parent
5850701da7
commit
6affb16768
10 changed files with 450 additions and 193 deletions
|
@ -27,33 +27,48 @@
|
|||
|
||||
<div class="p-4 space-y-3">
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt>Start of day balance</dt>
|
||||
<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>Cash Δ</dt>
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ 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><%= transaction_totals_money.format %></dd>
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt>Holdings Δ</dt>
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ 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><%= holding_change_money.format %></dd>
|
||||
<dd><%= holdings_change_money.format %></dd>
|
||||
</dl>
|
||||
<% else %>
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt>Transaction totals</dt>
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ 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><%= transaction_totals_money.format %></dd>
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt>End of day balance</dt>
|
||||
<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>
|
||||
|
@ -61,13 +76,19 @@
|
|||
<hr class="border border-primary">
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt>Value adjustments Δ </dt>
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ 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>Closing balance (incl. adjustments)</dt>
|
||||
<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>
|
51
app/components/UI/account/activity_date.rb
Normal file
51
app/components/UI/account/activity_date.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class UI::Account::ActivityDate < ApplicationComponent
|
||||
attr_reader :account, :data
|
||||
|
||||
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
|
||||
|
||||
def initialize(account:, data:)
|
||||
@account = account
|
||||
@data = data
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, "entries_#{date}")
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def start_balance_money
|
||||
balance_trend.previous
|
||||
end
|
||||
|
||||
def cash_change_money
|
||||
cash_balance_trend.value
|
||||
end
|
||||
|
||||
def holdings_change_money
|
||||
holdings_value_trend.value
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments_money
|
||||
balance_trend.previous + cash_change_money + holdings_change_money
|
||||
end
|
||||
|
||||
def adjustments_money
|
||||
end_balance_money - end_balance_before_adjustments_money
|
||||
end
|
||||
|
||||
def end_balance_money
|
||||
balance_trend.current
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
end
|
|
@ -50,7 +50,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if grouped_entries.empty? %>
|
||||
<% if activity_dates.empty? %>
|
||||
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||
<% else %>
|
||||
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||
|
@ -76,13 +76,10 @@
|
|||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<% grouped_entries.each do |date, entries| %>
|
||||
<%= render UI::Account::EntriesDateGroup.new(
|
||||
<% activity_dates.each do |activity_date_data| %>
|
||||
<%= render UI::Account::ActivityDate.new(
|
||||
account: account,
|
||||
date: date,
|
||||
entries: entries,
|
||||
balance_trend: balance_trend_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
data: activity_date_data
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -24,16 +24,8 @@ class UI::Account::ActivityFeed < ApplicationComponent
|
|||
)
|
||||
end
|
||||
|
||||
def grouped_entries
|
||||
feed_data.entries.group_by(&:date).sort.reverse
|
||||
end
|
||||
|
||||
def balance_trend_for_date(date)
|
||||
feed_data.trend_for_date(date)
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
feed_data.transfers_for_date(date)
|
||||
def activity_dates
|
||||
feed_data.entries_by_date
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
class UI::Account::EntriesDateGroup < ApplicationComponent
|
||||
attr_reader :account, :date, :entries, :balance_trend, :transfers
|
||||
|
||||
def initialize(account:, date:, entries:, balance_trend:, transfers:)
|
||||
@account = account
|
||||
@date = date
|
||||
@entries = entries
|
||||
@balance_trend = balance_trend
|
||||
@transfers = transfers
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, "entries_#{date}")
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def valuation_entry
|
||||
entries.find { |entry| entry.entryable_type == "Valuation" }
|
||||
end
|
||||
|
||||
def start_balance_money
|
||||
balance_trend.previous
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments_money
|
||||
balance_trend.previous + transaction_totals_money - holding_change_money
|
||||
end
|
||||
|
||||
def adjustments_money
|
||||
end_balance_money - end_balance_before_adjustments_money
|
||||
end
|
||||
|
||||
def transaction_totals_money
|
||||
transactions = entries.select { |e| e.transaction? }
|
||||
|
||||
if transactions.any?
|
||||
transactions.sum { |e| e.amount_money } * -1
|
||||
else
|
||||
Money.new(0, account.currency)
|
||||
end
|
||||
end
|
||||
|
||||
def holding_change_money
|
||||
trades = entries.select { |e| e.trade? }
|
||||
|
||||
if trades.any?
|
||||
trades.sum { |e| e.amount_money } * -1
|
||||
else
|
||||
Money.new(0, account.currency)
|
||||
end
|
||||
end
|
||||
|
||||
def end_balance_money
|
||||
balance_trend.current
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
end
|
|
@ -2,6 +2,8 @@
|
|||
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
|
||||
# activity feed component in controllers and background jobs that refresh it.
|
||||
class Account::ActivityFeedData
|
||||
ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers)
|
||||
|
||||
attr_reader :account, :entries
|
||||
|
||||
def initialize(account, entries)
|
||||
|
@ -9,63 +11,116 @@ class Account::ActivityFeedData
|
|||
@entries = entries.to_a
|
||||
end
|
||||
|
||||
def trend_for_date(date)
|
||||
start_balance = start_balance_for_date(date)
|
||||
end_balance = end_balance_for_date(date)
|
||||
|
||||
Trend.new(
|
||||
current: end_balance.balance_money,
|
||||
previous: start_balance.balance_money
|
||||
)
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
date_entries = entries_by_date[date] || []
|
||||
return [] if date_entries.empty?
|
||||
|
||||
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
|
||||
return [] if date_transaction_ids.empty?
|
||||
|
||||
# Convert to Set for O(1) lookups
|
||||
date_transaction_id_set = Set.new(date_transaction_ids)
|
||||
|
||||
transfers.select { |txfr|
|
||||
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
|
||||
date_transaction_id_set.include?(txfr.outflow_transaction_id)
|
||||
}
|
||||
end
|
||||
|
||||
def exchange_rates_for_date(date)
|
||||
exchange_rates.select { |rate| rate.date == date }
|
||||
def entries_by_date
|
||||
@entries_by_date_objects ||= begin
|
||||
grouped_entries.map do |date, date_entries|
|
||||
ActivityDateData.new(
|
||||
date: date,
|
||||
entries: date_entries,
|
||||
balance_trend: balance_trend_for_date(date),
|
||||
cash_balance_trend: cash_balance_trend_for_date(date),
|
||||
holdings_value_trend: holdings_value_trend_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_trend_for_date(date)
|
||||
build_trend_for_date(date, :balance_money)
|
||||
end
|
||||
|
||||
def cash_balance_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate cash change from transaction entries only
|
||||
transactions = date_entries.select { |e| e.transaction? }
|
||||
cash_change = sum_entries_with_exchange_rates(transactions, date) * -1
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
Trend.new(
|
||||
current: start_balance.cash_balance_money + cash_change,
|
||||
previous: start_balance.cash_balance_money
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date, :cash_balance_money)
|
||||
end
|
||||
end
|
||||
|
||||
def holdings_value_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate holdings change from trade entries only
|
||||
trades = date_entries.select { |e| e.trade? }
|
||||
holdings_change = sum_entries_with_exchange_rates(trades, date)
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
start_holdings = start_balance.balance_money - start_balance.cash_balance_money
|
||||
Trend.new(
|
||||
current: start_holdings + holdings_change,
|
||||
previous: start_holdings
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date) do |balance|
|
||||
balance.balance_money - balance.cash_balance_money
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
return [] if date_entries.empty?
|
||||
|
||||
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
|
||||
return [] if date_transaction_ids.empty?
|
||||
|
||||
# Convert to Set for O(1) lookups
|
||||
date_transaction_id_set = Set.new(date_transaction_ids)
|
||||
|
||||
transfers.select { |txfr|
|
||||
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
|
||||
date_transaction_id_set.include?(txfr.outflow_transaction_id)
|
||||
}
|
||||
end
|
||||
|
||||
def build_trend_for_date(date, method = nil)
|
||||
start_balance = start_balance_for_date(date)
|
||||
end_balance = end_balance_for_date(date)
|
||||
|
||||
if block_given?
|
||||
Trend.new(
|
||||
current: yield(end_balance),
|
||||
previous: yield(start_balance)
|
||||
)
|
||||
else
|
||||
Trend.new(
|
||||
current: end_balance.send(method),
|
||||
previous: start_balance.send(method)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def start_balance_for_date(date)
|
||||
locf_balance_for_date(date.prev_day) || generate_fallback_balance(date)
|
||||
@start_balance_for_date ||= {}
|
||||
@start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def end_balance_for_date(date)
|
||||
locf_balance_for_date(date) || generate_fallback_balance(date)
|
||||
@end_balance_for_date ||= {}
|
||||
@end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
RequiredExchangeRate = Data.define(:date, :from, :to)
|
||||
|
||||
def entries_by_date
|
||||
@entries_by_date ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
|
||||
def balances
|
||||
@balances ||= begin
|
||||
return [] if entries.empty?
|
||||
|
||||
min_date = entries.min_by(&:date).date.prev_day
|
||||
max_date = entries.max_by(&:date).date
|
||||
|
||||
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
|
||||
end
|
||||
def grouped_entries
|
||||
@grouped_entries ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
def needs_exchange_rates?
|
||||
|
@ -89,17 +144,45 @@ class Account::ActivityFeedData
|
|||
rate_requirements = required_exchange_rates
|
||||
return [] if rate_requirements.empty?
|
||||
|
||||
# Build a single SQL query with all date/currency pairs
|
||||
# Use ActiveRecord's or chain for better performance
|
||||
conditions = rate_requirements.map do |req|
|
||||
"(date = ? AND from_currency = ? AND to_currency = ?)"
|
||||
end.join(" OR ")
|
||||
ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to)
|
||||
end.reduce(:or)
|
||||
|
||||
# Flatten the parameters array in the same order
|
||||
params = rate_requirements.flat_map do |req|
|
||||
[ req.date, req.from, req.to ]
|
||||
conditions.to_a
|
||||
end
|
||||
end
|
||||
|
||||
def exchange_rate_for(date, from_currency, to_currency)
|
||||
return 1.0 if from_currency == to_currency
|
||||
|
||||
rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency }
|
||||
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
|
||||
end
|
||||
|
||||
def sum_entries_with_exchange_rates(entries, date)
|
||||
return Money.new(0, account.currency) if entries.empty?
|
||||
|
||||
entries.sum do |entry|
|
||||
amount = entry.amount_money
|
||||
if entry.currency != account.currency
|
||||
rate = exchange_rate_for(date, entry.currency, account.currency)
|
||||
Money.new(amount.amount * rate, account.currency)
|
||||
else
|
||||
amount
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ExchangeRate.where(conditions, *params).to_a
|
||||
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
|
||||
def balances
|
||||
@balances ||= begin
|
||||
return [] if entries.empty?
|
||||
|
||||
min_date = entries.min_by(&:date).date.prev_day
|
||||
max_date = entries.max_by(&:date).date
|
||||
|
||||
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -107,18 +190,15 @@ class Account::ActivityFeedData
|
|||
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
|
||||
end
|
||||
|
||||
def has_transfers?
|
||||
entries.any? { |entry| entry.transaction? && entry.transaction.transfer? }
|
||||
end
|
||||
|
||||
def transfers
|
||||
return [] unless has_transfers?
|
||||
return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty?
|
||||
return [] if transaction_ids.empty?
|
||||
|
||||
@transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a
|
||||
end
|
||||
|
||||
# Use binary search since balances are sorted by date
|
||||
def locf_balance_for_date(date)
|
||||
def last_observed_balance_before_date(date)
|
||||
idx = balances.bsearch_index { |b| b.date > date }
|
||||
|
||||
if idx
|
||||
|
|
|
@ -3,7 +3,7 @@ class Balance < ApplicationRecord
|
|||
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
monetize :balance, :cash_balance
|
||||
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<%# locals: (entry:, balance_trend: nil, **) %>
|
||||
<%# locals: (entry:, **) %>
|
||||
|
||||
<% valuation = entry.entryable %>
|
||||
|
||||
<% color = balance_trend&.trend&.color || "#D444F1" %>
|
||||
<% icon = balance_trend&.trend&.icon || "plus" %>
|
||||
<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %>
|
||||
<% icon = valuation.opening_anchor? ? "plus" : "minus" %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
||||
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
|
||||
<div class="col-span-4 justify-self-end">
|
||||
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
|
||||
<%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
max: Date.current %>
|
||||
|
||||
<%= f.money_field :amount,
|
||||
label: t(".amount"),
|
||||
label: "Account value on date",
|
||||
disable_currency: true %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
|
|
|
@ -10,103 +10,267 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
@investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||
|
||||
@test_period_start = Date.current - 4.days
|
||||
@test_period_end = Date.current
|
||||
|
||||
setup_test_data
|
||||
end
|
||||
|
||||
test "calculates correct trend for a given date when all balances exist" do
|
||||
test "calculates balance trend with complete balance history" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
# Trend for day 2 should show change from end of day 1 to end of day 2
|
||||
trend = feed_data.trend_for_date(@test_period_start + 1.day)
|
||||
activities = feed_data.entries_by_date
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
trend = day2_activity.balance_trend
|
||||
assert_equal 1100, trend.current.amount.to_i # End of day 2
|
||||
assert_equal 1000, trend.previous.amount.to_i # End of day 1
|
||||
assert_equal 100, trend.value.amount.to_i
|
||||
assert_equal "up", trend.direction.to_s
|
||||
end
|
||||
|
||||
test "calculates trend with correct start and end values" do
|
||||
test "calculates balance trend for first day with zero starting balance" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
# First day trend (no previous day balance)
|
||||
trend = feed_data.trend_for_date(@test_period_start)
|
||||
activities = feed_data.entries_by_date
|
||||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||
|
||||
assert_not_nil day1_activity
|
||||
trend = day1_activity.balance_trend
|
||||
assert_equal 1000, trend.current.amount.to_i # End of first day
|
||||
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||
assert_equal 1000, trend.value.amount.to_i
|
||||
end
|
||||
|
||||
test "uses last observation carried forward when intermediate balances are missing" do
|
||||
test "uses last observed balance when intermediate balances are missing" do
|
||||
@checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all
|
||||
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
|
||||
# When day 2 balance is missing, both start and end use day 1 balance
|
||||
trend = feed_data.trend_for_date(@test_period_start + 1.day)
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
assert_not_nil day2_activity
|
||||
trend = day2_activity.balance_trend
|
||||
assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
|
||||
assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
|
||||
assert_equal 0, trend.value.amount.to_i
|
||||
assert_equal "flat", trend.direction.to_s
|
||||
|
||||
# When day 4 balance is missing, uses last available (day 1)
|
||||
trend = feed_data.trend_for_date(@test_period_start + 3.days)
|
||||
assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
|
||||
assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
|
||||
end
|
||||
|
||||
test "returns zero-balance fallback when no prior balances exist" do
|
||||
test "returns zero balance when no balance history exists" do
|
||||
@checking.balances.destroy_all
|
||||
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
trend = feed_data.trend_for_date(@test_period_start + 2.days)
|
||||
activities = feed_data.entries_by_date
|
||||
# Use first day which has a transaction
|
||||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||
|
||||
assert_not_nil day1_activity
|
||||
trend = day1_activity.balance_trend
|
||||
assert_equal 0, trend.current.amount.to_i # Fallback to 0
|
||||
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||
assert_equal 0, trend.value.amount.to_i
|
||||
assert_equal "flat", trend.direction.to_s
|
||||
end
|
||||
|
||||
test "calculates cash and holdings trends for investment accounts" do
|
||||
entries = @investment.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
|
||||
|
||||
assert_not_nil day3_activity
|
||||
|
||||
# Cash trend for day 3 (after foreign currency transaction)
|
||||
cash_trend = day3_activity.cash_balance_trend
|
||||
assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
|
||||
assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
|
||||
assert_equal(-100, cash_trend.value.amount.to_i)
|
||||
assert_equal "down", cash_trend.direction.to_s
|
||||
|
||||
# Holdings trend for day 3 (after trade)
|
||||
holdings_trend = day3_activity.holdings_value_trend
|
||||
assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance
|
||||
assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade
|
||||
assert_equal 1500, holdings_trend.value.amount.to_i
|
||||
assert_equal "up", holdings_trend.direction.to_s
|
||||
end
|
||||
|
||||
test "identifies transfers for a specific date" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
|
||||
# Day 2 has the transfer
|
||||
transfers = feed_data.transfers_for_date(@test_period_start + 1.day)
|
||||
assert_equal 1, transfers.size
|
||||
assert_equal @transfer, transfers.first
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
assert_not_nil day2_activity
|
||||
assert_equal 1, day2_activity.transfers.size
|
||||
assert_equal @transfer, day2_activity.transfers.first
|
||||
|
||||
# Other days have no transfers
|
||||
transfers = feed_data.transfers_for_date(@test_period_start)
|
||||
assert_empty transfers
|
||||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||
assert_not_nil day1_activity
|
||||
assert_empty day1_activity.transfers
|
||||
end
|
||||
|
||||
test "loads exchange rates only for entries with foreign currencies" do
|
||||
test "returns complete ActivityDateData objects with all required fields" do
|
||||
entries = @investment.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
||||
|
||||
rates = feed_data.exchange_rates_for_date(@test_period_start + 2.days)
|
||||
assert_equal 1, rates.size
|
||||
assert_equal "EUR", rates.first.from_currency
|
||||
assert_equal "USD", rates.first.to_currency
|
||||
assert_equal 1.1, rates.first.rate
|
||||
|
||||
rates = feed_data.exchange_rates_for_date(@test_period_start)
|
||||
assert_empty rates
|
||||
activities = feed_data.entries_by_date
|
||||
|
||||
# Check that we get ActivityDateData objects
|
||||
assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) }
|
||||
|
||||
# Check that each ActivityDate has the required fields
|
||||
activities.each do |activity|
|
||||
assert_respond_to activity, :date
|
||||
assert_respond_to activity, :entries
|
||||
assert_respond_to activity, :balance_trend
|
||||
assert_respond_to activity, :cash_balance_trend
|
||||
assert_respond_to activity, :holdings_value_trend
|
||||
assert_respond_to activity, :transfers
|
||||
end
|
||||
end
|
||||
|
||||
test "returns empty exchange rates when no foreign currency entries exist" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
test "handles valuations correctly by summing entry changes" do
|
||||
# Create account with known balances
|
||||
account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||
|
||||
# Day 1: Starting balance
|
||||
account.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 7321.56,
|
||||
cash_balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Day 2: Add transactions, trades and a valuation
|
||||
account.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 8500, # Valuation sets this
|
||||
cash_balance: 1070, # Cash increased by transactions
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Create transactions
|
||||
create_transaction(
|
||||
account: account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: -50,
|
||||
name: "Interest payment"
|
||||
)
|
||||
create_transaction(
|
||||
account: account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: -20,
|
||||
name: "Interest payment"
|
||||
)
|
||||
|
||||
# Create a trade
|
||||
create_trade(
|
||||
securities(:aapl),
|
||||
account: account,
|
||||
qty: 5,
|
||||
date: @test_period_start + 1.day,
|
||||
price: 150 # 5 * 150 = 750
|
||||
)
|
||||
|
||||
# Create valuation
|
||||
create_valuation(
|
||||
account: account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: 8500
|
||||
)
|
||||
|
||||
entries = account.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(account, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
|
||||
# Cash change should be $70 (50 + 20 from transactions only, not trades)
|
||||
assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
|
||||
|
||||
# Holdings change should be 750 (from the trade)
|
||||
assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i
|
||||
|
||||
# Total balance change
|
||||
assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01
|
||||
end
|
||||
|
||||
rates = feed_data.exchange_rates_for_date(@test_period_start + 2.days)
|
||||
assert_empty rates
|
||||
test "normalizes multi-currency entries on valuation days" do
|
||||
# Create EUR account
|
||||
eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0)
|
||||
|
||||
# Day 1: Starting balance
|
||||
eur_account.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 1000,
|
||||
cash_balance: 500,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
# Day 2: Multi-currency transactions and valuation
|
||||
eur_account.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 2000,
|
||||
cash_balance: 600,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
# Create USD transaction (should be converted to EUR)
|
||||
create_transaction(
|
||||
account: eur_account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: -100,
|
||||
currency: "USD",
|
||||
name: "USD Payment"
|
||||
)
|
||||
|
||||
# Create exchange rate: 1 USD = 0.9 EUR
|
||||
ExchangeRate.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
from_currency: "USD",
|
||||
to_currency: "EUR",
|
||||
rate: 0.9
|
||||
)
|
||||
|
||||
# Create valuation
|
||||
create_valuation(
|
||||
account: eur_account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: 2000
|
||||
)
|
||||
|
||||
entries = eur_account.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(eur_account, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
|
||||
# Cash change should be 90 EUR (100 USD * 0.9)
|
||||
# The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR
|
||||
assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i
|
||||
assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code
|
||||
end
|
||||
|
||||
private
|
||||
def find_activity_for_date(activities, date)
|
||||
activities.find { |a| a.date == date }
|
||||
end
|
||||
|
||||
def setup_test_data
|
||||
# Create daily balances for checking account
|
||||
|
@ -119,6 +283,26 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
)
|
||||
end
|
||||
|
||||
# Create daily balances for investment account with cash_balance
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 500,
|
||||
cash_balance: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 500,
|
||||
cash_balance: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start + 2.days,
|
||||
balance: 1900, # 1500 holdings + 400 cash
|
||||
cash_balance: 400, # After -100 EUR transaction
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Day 1: Regular transaction
|
||||
create_transaction(
|
||||
account: @checking,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue