mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 04:55:20 +02:00
Use new balance components in activity feed (#2511)
* Balance reconcilations with new components * Fix materializer and test assumptions * Fix investment valuation calculations and recon display * Lint fixes * Balance series uses new component fields
This commit is contained in:
parent
3f92fe0f6f
commit
f7f6ebb091
17 changed files with 723 additions and 539 deletions
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<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") %>
|
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||||
</div>
|
</div>
|
||||||
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||||
|
@ -25,73 +25,12 @@
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="p-4 space-y-3">
|
<div class="p-4">
|
||||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
<% if balance %>
|
||||||
<dt class="flex items-center gap-2">
|
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
|
||||||
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">
|
|
||||||
Δ 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">
|
|
||||||
Δ 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>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
<p class="text-sm text-secondary">No balance data available for this date</p>
|
||||||
<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><%= cash_change_money.format %></dd>
|
|
||||||
</dl>
|
|
||||||
<% end %>
|
<% 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">
|
|
||||||
Δ 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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class UI::Account::ActivityDate < ApplicationComponent
|
class UI::Account::ActivityDate < ApplicationComponent
|
||||||
attr_reader :account, :data
|
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:)
|
def initialize(account:, data:)
|
||||||
@account = account
|
@account = account
|
||||||
|
@ -16,28 +16,8 @@ class UI::Account::ActivityDate < ApplicationComponent
|
||||||
account
|
account
|
||||||
end
|
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
|
def end_balance_money
|
||||||
balance_trend.current
|
balance&.end_balance_money || Money.new(0, account.currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_refresh!
|
def broadcast_refresh!
|
||||||
|
|
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
22
app/components/UI/account/balance_reconciliation.html.erb
Normal 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>
|
155
app/components/UI/account/balance_reconciliation.rb
Normal file
155
app/components/UI/account/balance_reconciliation.rb
Normal file
|
@ -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
|
|
@ -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
|
# 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.
|
# activity feed component in controllers and background jobs that refresh it.
|
||||||
class Account::ActivityFeedData
|
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
|
attr_reader :account, :entries
|
||||||
|
|
||||||
|
@ -17,9 +17,7 @@ class Account::ActivityFeedData
|
||||||
ActivityDateData.new(
|
ActivityDateData.new(
|
||||||
date: date,
|
date: date,
|
||||||
entries: date_entries,
|
entries: date_entries,
|
||||||
balance_trend: balance_trend_for_date(date),
|
balance: balance_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)
|
transfers: transfers_for_date(date)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -27,193 +25,61 @@ class Account::ActivityFeedData
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def balance_trend_for_date(date)
|
def balance_for_date(date)
|
||||||
build_trend_for_date(date, :balance_money)
|
balances_by_date[date]
|
||||||
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
|
end
|
||||||
|
|
||||||
def transfers_for_date(date)
|
def transfers_for_date(date)
|
||||||
date_entries = grouped_entries[date] || []
|
transfers_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
|
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
|
def grouped_entries
|
||||||
@grouped_entries ||= entries.group_by(&:date)
|
@grouped_entries ||= entries.group_by(&:date)
|
||||||
end
|
end
|
||||||
|
|
||||||
def needs_exchange_rates?
|
def balances_by_date
|
||||||
entries.any? { |entry| entry.currency != account.currency }
|
@balances_by_date ||= begin
|
||||||
end
|
return {} if entries.empty?
|
||||||
|
|
||||||
def required_exchange_rates
|
dates = grouped_entries.keys
|
||||||
multi_currency_entries = entries.select { |entry| entry.currency != account.currency }
|
account.balances
|
||||||
|
.where(date: dates, currency: account.currency)
|
||||||
multi_currency_entries.map do |entry|
|
.index_by(&:date)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def exchange_rate_for(date, from_currency, to_currency)
|
def transfers_by_date
|
||||||
return 1.0 if from_currency == to_currency
|
@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 }
|
transfers = Transfer
|
||||||
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
|
.where(inflow_transaction_id: transaction_ids)
|
||||||
end
|
.or(Transfer.where(outflow_transaction_id: transaction_ids))
|
||||||
|
.to_a
|
||||||
|
|
||||||
def sum_entries_with_exchange_rates(entries, date)
|
# Group transfers by the date of their transaction entries
|
||||||
return Money.new(0, account.currency) if entries.empty?
|
result = Hash.new { |h, k| h[k] = [] }
|
||||||
|
|
||||||
entries.sum do |entry|
|
entries.each do |entry|
|
||||||
amount = entry.amount_money
|
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
|
||||||
if entry.currency != account.currency
|
|
||||||
rate = exchange_rate_for(date, entry.currency, account.currency)
|
transfers.each do |transfer|
|
||||||
Money.new(amount.amount * rate, account.currency)
|
if transfer.inflow_transaction_id == entry.entryable_id ||
|
||||||
else
|
transfer.outflow_transaction_id == entry.entryable_id
|
||||||
amount
|
result[entry.date] << transfer
|
||||||
|
end
|
||||||
|
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
|
# Remove duplicates
|
||||||
def balances
|
result.transform_values(&:uniq)
|
||||||
@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
|
||||||
end
|
end
|
||||||
|
|
||||||
def transaction_ids
|
def transaction_ids
|
||||||
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
|
@transaction_ids ||= entries
|
||||||
end
|
.select(&:transaction?)
|
||||||
|
.map(&:entryable_id)
|
||||||
def transfers
|
.compact
|
||||||
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
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -82,8 +82,8 @@ class Account::ReconciliationManager
|
||||||
balance_record = account.balances.find_by(date: date, currency: account.currency)
|
balance_record = account.balances.find_by(date: date, currency: account.currency)
|
||||||
|
|
||||||
{
|
{
|
||||||
cash_balance: balance_record&.cash_balance,
|
cash_balance: balance_record&.end_cash_balance,
|
||||||
balance: balance_record&.balance
|
balance: balance_record&.end_balance
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,4 +14,18 @@ class Balance < ApplicationRecord
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
|
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
|
||||||
scope :chronological, -> { order(:date) }
|
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
|
end
|
||||||
|
|
|
@ -29,16 +29,16 @@ class Balance::BaseCalculator
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cash_adjustments_for_date(start_cash, net_cash_flows, valuation)
|
def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
|
||||||
return 0 unless valuation && account.balance_type != :non_cash
|
return 0 unless account.balance_type != :non_cash
|
||||||
|
|
||||||
valuation.amount - start_cash - net_cash_flows
|
end_cash - start_cash - net_cash_flows
|
||||||
end
|
end
|
||||||
|
|
||||||
def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation)
|
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
|
||||||
return 0 unless valuation && account.balance_type == :non_cash
|
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
|
end
|
||||||
|
|
||||||
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
|
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
|
||||||
|
|
|
@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def balance_series
|
def balance_series
|
||||||
build_series_for(:balance)
|
build_series_for(:end_balance)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
|
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
|
||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
def cash_balance_series
|
def cash_balance_series
|
||||||
build_series_for(:cash_balance)
|
build_series_for(:end_cash_balance)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
|
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
|
||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
def holdings_balance_series
|
def holdings_balance_series
|
||||||
build_series_for(:holdings_balance)
|
build_series_for(:end_holdings_balance)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
|
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
|
||||||
raise
|
raise
|
||||||
|
@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
|
||||||
|
|
||||||
def build_series_for(column)
|
def build_series_for(column)
|
||||||
values = query_data.map do |datum|
|
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(
|
Series::Value.new(
|
||||||
date: datum.date,
|
date: datum.date,
|
||||||
date_formatted: I18n.l(datum.date, format: :long),
|
date_formatted: I18n.l(datum.date, format: :long),
|
||||||
value: Money.new(datum.send(column), currency),
|
value: Money.new(datum.send(column), currency),
|
||||||
trend: Trend.new(
|
trend: Trend.new(
|
||||||
current: Money.new(datum.send(column), currency),
|
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
|
favorable_direction: favorable_direction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
|
||||||
WITH dates AS (
|
WITH dates AS (
|
||||||
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
|
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
|
||||||
UNION DISTINCT
|
UNION DISTINCT
|
||||||
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
|
SELECT :end_date::date -- Ensure end date is included
|
||||||
), 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
|
SELECT
|
||||||
date,
|
d.date,
|
||||||
balance,
|
-- Use flows_factor: already handles asset (+1) vs liability (-1)
|
||||||
cash_balance,
|
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
|
||||||
holdings_balance,
|
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
|
||||||
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
|
-- Holdings only for assets (flows_factor = 1)
|
||||||
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
|
COALESCE(SUM(
|
||||||
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
|
CASE WHEN last_bal.flows_factor = 1
|
||||||
FROM aggregated_balances
|
THEN last_bal.end_non_cash_balance
|
||||||
ORDER BY date
|
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
|
SQL
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,10 +2,10 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||||
def calculate
|
def calculate
|
||||||
Rails.logger.tagged("Balance::ForwardCalculator") do
|
Rails.logger.tagged("Balance::ForwardCalculator") do
|
||||||
start_cash_balance = derive_cash_balance_on_date_from_total(
|
start_cash_balance = derive_cash_balance_on_date_from_total(
|
||||||
total_balance: 0,
|
total_balance: account.opening_anchor_balance,
|
||||||
date: account.opening_anchor_date
|
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|
|
calc_start_date.upto(calc_end_date).map do |date|
|
||||||
valuation = sync_cache.get_valuation(date)
|
valuation = sync_cache.get_valuation(date)
|
||||||
|
@ -24,6 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||||
flows = flows_for_date(date)
|
flows = flows_for_date(date)
|
||||||
market_value_change = market_value_change_on_date(date, flows)
|
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(
|
output_balance = build_balance(
|
||||||
date: date,
|
date: date,
|
||||||
balance: end_cash_balance + end_non_cash_balance,
|
balance: end_cash_balance + end_non_cash_balance,
|
||||||
|
@ -34,8 +37,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||||
cash_outflows: flows[:cash_outflows],
|
cash_outflows: flows[:cash_outflows],
|
||||||
non_cash_inflows: flows[:non_cash_inflows],
|
non_cash_inflows: flows[:non_cash_inflows],
|
||||||
non_cash_outflows: flows[:non_cash_outflows],
|
non_cash_outflows: flows[:non_cash_outflows],
|
||||||
cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation),
|
cash_adjustments: cash_adjustments,
|
||||||
non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation),
|
non_cash_adjustments: non_cash_adjustments,
|
||||||
net_market_flows: market_value_change
|
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:)
|
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
|
||||||
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
|
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def flows_factor
|
||||||
|
account.asset? ? 1 : -1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,9 +28,20 @@ class Balance::Materializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_account_info
|
def update_account_info
|
||||||
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
|
# Query fresh balance from DB to get generated column values
|
||||||
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
|
current_balance = account.balances
|
||||||
calculated_cash_balance = calculated_balance - calculated_holdings_value
|
.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}")
|
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
|
||||||
|
|
||||||
|
@ -48,14 +59,23 @@ class Balance::Materializer
|
||||||
current_time = Time.now
|
current_time = Time.now
|
||||||
account.balances.upsert_all(
|
account.balances.upsert_all(
|
||||||
@balances.map { |b| b.attributes
|
@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) },
|
.merge("updated_at" => current_time) },
|
||||||
unique_by: %i[account_id date currency]
|
unique_by: %i[account_id date currency]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def purge_stale_balances
|
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
|
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
setup_test_data
|
setup_test_data
|
||||||
end
|
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
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
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)
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
|
||||||
assert_not_nil day2_activity
|
assert_not_nil day2_activity
|
||||||
trend = day2_activity.balance_trend
|
assert_not_nil day2_activity.balance
|
||||||
assert_equal 1100, trend.current.amount.to_i # End of day 2
|
assert_equal 1100, day2_activity.balance.end_balance # 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
|
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
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
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)
|
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||||
|
|
||||||
assert_not_nil day1_activity
|
assert_not_nil day1_activity
|
||||||
trend = day1_activity.balance_trend
|
assert_not_nil day1_activity.balance
|
||||||
assert_equal 1000, trend.current.amount.to_i # End of first day
|
assert_equal 1000, day1_activity.balance.end_balance # End of first day
|
||||||
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
|
||||||
assert_equal 1000, trend.value.amount.to_i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses last observed balance when intermediate balances are missing" do
|
test "returns nil balance when no balance exists for date" 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
|
|
||||||
@checking.balances.destroy_all
|
@checking.balances.destroy_all
|
||||||
|
|
||||||
entries = @checking.entries.includes(:entryable).to_a
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
activities = feed_data.entries_by_date
|
activities = feed_data.entries_by_date
|
||||||
# Use first day which has a transaction
|
|
||||||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||||
|
|
||||||
assert_not_nil day1_activity
|
assert_not_nil day1_activity
|
||||||
trend = day1_activity.balance_trend
|
assert_nil day1_activity.balance
|
||||||
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
|
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
|
entries = @investment.entries.includes(:entryable).to_a
|
||||||
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
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)
|
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
|
||||||
|
|
||||||
assert_not_nil day3_activity
|
assert_not_nil day3_activity
|
||||||
|
assert_not_nil day3_activity.balance
|
||||||
|
|
||||||
# Cash trend for day 3 (after foreign currency transaction)
|
# Balance should have the new schema fields
|
||||||
cash_trend = day3_activity.cash_balance_trend
|
assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance
|
||||||
assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
|
assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value
|
||||||
assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
|
assert_equal 1900, day3_activity.balance.end_balance # Total 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
|
end
|
||||||
|
|
||||||
test "identifies transfers for a specific date" do
|
test "identifies transfers for a specific date" do
|
||||||
|
@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
activities.each do |activity|
|
activities.each do |activity|
|
||||||
assert_respond_to activity, :date
|
assert_respond_to activity, :date
|
||||||
assert_respond_to activity, :entries
|
assert_respond_to activity, :entries
|
||||||
assert_respond_to activity, :balance_trend
|
assert_respond_to activity, :balance
|
||||||
assert_respond_to activity, :cash_balance_trend
|
|
||||||
assert_respond_to activity, :holdings_value_trend
|
|
||||||
assert_respond_to activity, :transfers
|
assert_respond_to activity, :transfers
|
||||||
end
|
end
|
||||||
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
|
# Create account with known balances
|
||||||
account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||||
|
|
||||||
# Day 1: Starting balance
|
# Day 1: Starting balance
|
||||||
account.balances.create!(
|
account.balances.create!(
|
||||||
date: @test_period_start,
|
date: @test_period_start,
|
||||||
balance: 7321.56,
|
balance: 7321.56, # Keep old field for now
|
||||||
cash_balance: 1000,
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Day 2: Add transactions, trades and a valuation
|
# Day 2: Add transactions, trades and a valuation
|
||||||
account.balances.create!(
|
account.balances.create!(
|
||||||
date: @test_period_start + 1.day,
|
date: @test_period_start + 1.day,
|
||||||
balance: 8500, # Valuation sets this
|
balance: 8500, # Keep old field for now
|
||||||
cash_balance: 1070, # Cash increased by transactions
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
|
||||||
assert_not_nil day2_activity
|
assert_not_nil day2_activity
|
||||||
|
assert_not_nil day2_activity.balance
|
||||||
|
|
||||||
# Cash change should be $70 (50 + 20 from transactions only, not trades)
|
# Check new balance fields
|
||||||
assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
|
assert_equal 1070, day2_activity.balance.end_cash_balance
|
||||||
|
assert_equal 7430, day2_activity.balance.end_non_cash_balance
|
||||||
# Holdings change should be 750 (from the trade)
|
assert_equal 8500, day2_activity.balance.end_balance
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
def setup_test_data
|
def setup_test_data
|
||||||
# Create daily balances for checking account
|
# Create daily balances for checking account with new schema
|
||||||
5.times do |i|
|
5.times do |i|
|
||||||
date = @test_period_start + i.days
|
date = @test_period_start + i.days
|
||||||
|
prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0
|
||||||
|
|
||||||
@checking.balances.create!(
|
@checking.balances.create!(
|
||||||
date: date,
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
# Create daily balances for investment account with cash_balance
|
# Create daily balances for investment account with cash_balance
|
||||||
@investment.balances.create!(
|
@investment.balances.create!(
|
||||||
date: @test_period_start,
|
date: @test_period_start,
|
||||||
balance: 500,
|
balance: 500, # Keep old field for now
|
||||||
cash_balance: 500,
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
@investment.balances.create!(
|
@investment.balances.create!(
|
||||||
date: @test_period_start + 1.day,
|
date: @test_period_start + 1.day,
|
||||||
balance: 500,
|
balance: 500, # Keep old field for now
|
||||||
cash_balance: 500,
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
@investment.balances.create!(
|
@investment.balances.create!(
|
||||||
date: @test_period_start + 2.days,
|
date: @test_period_start + 2.days,
|
||||||
balance: 1900, # 1500 holdings + 400 cash
|
balance: 1900, # Keep old field for now
|
||||||
cash_balance: 400, # After -100 EUR transaction
|
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"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,15 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
||||||
|
include BalanceTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@account = accounts(:investment)
|
@account = accounts(:investment)
|
||||||
@manager = Account::ReconciliationManager.new(@account)
|
@manager = Account::ReconciliationManager.new(@account)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new reconciliation" do
|
test "new reconciliation" do
|
||||||
@account.balances.create!(
|
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
|
||||||
date: Date.current,
|
|
||||||
balance: 1000,
|
|
||||||
cash_balance: 500,
|
|
||||||
currency: @account.currency
|
|
||||||
)
|
|
||||||
|
|
||||||
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
|
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
|
||||||
|
|
||||||
|
@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "updates existing reconciliation without date change" do
|
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 reconciliation entry
|
||||||
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
|
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
|
end
|
||||||
|
|
||||||
test "updates existing reconciliation with date and amount change" do
|
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)
|
create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)
|
||||||
@account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency)
|
create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)
|
||||||
|
|
||||||
# Existing reconciliation entry (5 days ago)
|
# 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)
|
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
|
end
|
||||||
|
|
||||||
test "handles date conflicts" do
|
test "handles date conflicts" do
|
||||||
@account.balances.create!(
|
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
|
||||||
date: Date.current,
|
|
||||||
balance: 1000,
|
|
||||||
cash_balance: 1000,
|
|
||||||
currency: @account.currency
|
|
||||||
)
|
|
||||||
|
|
||||||
# Existing reconciliation entry
|
# Existing reconciliation entry
|
||||||
@account.entries.create!(
|
@account.entries.create!(
|
||||||
|
@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dry run does not persist account" do
|
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
|
assert_no_difference "Valuation.count" do
|
||||||
@manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)
|
@manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||||
|
include BalanceTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||||
account.balances.destroy_all
|
account.balances.destroy_all
|
||||||
|
|
||||||
# With gaps
|
# With gaps
|
||||||
account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD")
|
create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
|
||||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
||||||
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
|
create_balance(account: account, date: Date.current, balance: 1200)
|
||||||
|
|
||||||
builder = Balance::ChartSeriesBuilder.new(
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
account_ids: [ account.id ],
|
account_ids: [ account.id ],
|
||||||
|
@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||||
account = accounts(:depository)
|
account = accounts(:depository)
|
||||||
account.balances.destroy_all
|
account.balances.destroy_all
|
||||||
|
|
||||||
account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD")
|
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
|
||||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
||||||
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
|
create_balance(account: account, date: Date.current, balance: 1200)
|
||||||
|
|
||||||
builder = Balance::ChartSeriesBuilder.new(
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
account_ids: [ account.id ],
|
account_ids: [ account.id ],
|
||||||
|
@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
Balance.destroy_all
|
Balance.destroy_all
|
||||||
|
|
||||||
asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD")
|
create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
|
||||||
asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
|
create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
|
||||||
asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
|
create_balance(account: asset_account, date: Date.current, balance: 1000)
|
||||||
|
|
||||||
liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD")
|
create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
|
||||||
liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD")
|
create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
|
||||||
liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD")
|
create_balance(account: liability_account, date: Date.current, balance: 100)
|
||||||
|
|
||||||
builder = Balance::ChartSeriesBuilder.new(
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
account_ids: [ asset_account.id, liability_account.id ],
|
account_ids: [ asset_account.id, liability_account.id ],
|
||||||
|
@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||||
account = accounts(:credit_card)
|
account = accounts(:credit_card)
|
||||||
account.balances.destroy_all
|
account.balances.destroy_all
|
||||||
|
|
||||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
|
||||||
account.balances.create!(date: Date.current, balance: 500, currency: "USD")
|
create_balance(account: account, date: Date.current, balance: 500)
|
||||||
|
|
||||||
builder = Balance::ChartSeriesBuilder.new(
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
account_ids: [ account.id ],
|
account_ids: [ account.id ],
|
||||||
|
|
|
@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 2.days.ago.to_date,
|
date: 2.days.ago.to_date,
|
||||||
|
@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
legacy_balances: { balance: 17000, cash_balance: 0.0 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 2.days.ago.to_date,
|
date: 2.days.ago.to_date,
|
||||||
|
@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
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 },
|
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,
|
date: 2.days.ago.to_date,
|
||||||
|
@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 5.days.ago.to_date,
|
date: 5.days.ago.to_date,
|
||||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 4.days.ago.to_date,
|
date: 4.days.ago.to_date,
|
||||||
|
@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 5.days.ago.to_date,
|
date: 5.days.ago.to_date,
|
||||||
legacy_balances: { balance: 1000, cash_balance: 1000 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 4.days.ago.to_date,
|
date: 4.days.ago.to_date,
|
||||||
|
@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 4.days.ago.to_date,
|
date: 4.days.ago.to_date,
|
||||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
|
@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 4.days.ago.to_date,
|
date: 4.days.ago.to_date,
|
||||||
legacy_balances: { balance: 100, cash_balance: 100 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
|
@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 2.days.ago.to_date,
|
date: 2.days.ago.to_date,
|
||||||
legacy_balances: { balance: 20000, cash_balance: 0 },
|
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,
|
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,
|
date: 1.day.ago.to_date,
|
||||||
|
@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
legacy_balances: { balance: 500000, cash_balance: 0 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 2.days.ago.to_date,
|
date: 2.days.ago.to_date,
|
||||||
|
@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
{
|
{
|
||||||
date: 3.days.ago.to_date,
|
date: 3.days.ago.to_date,
|
||||||
legacy_balances: { balance: 5000, cash_balance: 5000 },
|
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,
|
flows: 0,
|
||||||
adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 }
|
adjustments: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: 2.days.ago.to_date,
|
date: 2.days.ago.to_date,
|
||||||
|
@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
)
|
)
|
||||||
end
|
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
|
private
|
||||||
def assert_balances(calculated_data:, expected_balances:)
|
def assert_balances(calculated_data:, expected_balances:)
|
||||||
# Sort calculated data by date to ensure consistent ordering
|
# Sort calculated data by date to ensure consistent ordering
|
||||||
|
|
|
@ -2,6 +2,7 @@ require "test_helper"
|
||||||
|
|
||||||
class Balance::MaterializerTest < ActiveSupport::TestCase
|
class Balance::MaterializerTest < ActiveSupport::TestCase
|
||||||
include EntriesTestHelper
|
include EntriesTestHelper
|
||||||
|
include BalanceTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@account = families(:empty).accounts.create!(
|
@account = families(:empty).accounts.create!(
|
||||||
|
@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase
|
||||||
test "syncs balances" do
|
test "syncs balances" do
|
||||||
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
|
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::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
|
||||||
[
|
|
||||||
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")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_difference "@account.balances.count", 2 do
|
assert_difference "@account.balances.count", 2 do
|
||||||
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
|
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_balance_fields_persisted(expected_balances)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "purges stale balances and holdings" do
|
test "purges stale balances outside calculated range" do
|
||||||
# Balance before start date is stale
|
# Create existing balances that will be stale
|
||||||
@account.expects(:start_date).returns(2.days.ago.to_date).twice
|
stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)
|
||||||
stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
|
stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)
|
||||||
|
|
||||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
# Calculator will return balances for only these dates
|
||||||
[
|
expected_balances = [
|
||||||
stale_balance,
|
Balance.new(
|
||||||
Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
|
date: 2.days.ago.to_date,
|
||||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
balance: 10000,
|
||||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
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
|
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
|
||||||
end
|
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
|
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
|
end
|
||||||
|
|
72
test/support/balance_test_helper.rb
Normal file
72
test/support/balance_test_helper.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue