mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-06 05:55:21 +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
|
@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
setup_test_data
|
||||
end
|
||||
|
||||
test "calculates balance trend with complete balance history" do
|
||||
test "returns balance for date with complete balance history" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
|
@ -22,14 +22,11 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
trend = day2_activity.balance_trend
|
||||
assert_equal 1100, trend.current.amount.to_i # End of day 2
|
||||
assert_equal 1000, trend.previous.amount.to_i # End of day 1
|
||||
assert_equal 100, trend.value.amount.to_i
|
||||
assert_equal "up", trend.direction.to_s
|
||||
assert_not_nil day2_activity.balance
|
||||
assert_equal 1100, day2_activity.balance.end_balance # End of day 2
|
||||
end
|
||||
|
||||
test "calculates balance trend for first day with zero starting balance" do
|
||||
test "returns balance for first day" do
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
|
@ -37,49 +34,24 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||
|
||||
assert_not_nil day1_activity
|
||||
trend = day1_activity.balance_trend
|
||||
assert_equal 1000, trend.current.amount.to_i # End of first day
|
||||
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||
assert_equal 1000, trend.value.amount.to_i
|
||||
assert_not_nil day1_activity.balance
|
||||
assert_equal 1000, day1_activity.balance.end_balance # End of first day
|
||||
end
|
||||
|
||||
test "uses last observed balance when intermediate balances are missing" do
|
||||
@checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all
|
||||
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
|
||||
# When day 2 balance is missing, both start and end use day 1 balance
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
assert_not_nil day2_activity
|
||||
trend = day2_activity.balance_trend
|
||||
assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
|
||||
assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
|
||||
assert_equal 0, trend.value.amount.to_i
|
||||
assert_equal "flat", trend.direction.to_s
|
||||
end
|
||||
|
||||
test "returns zero balance when no balance history exists" do
|
||||
test "returns nil balance when no balance exists for date" do
|
||||
@checking.balances.destroy_all
|
||||
|
||||
entries = @checking.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
# Use first day which has a transaction
|
||||
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||
|
||||
assert_not_nil day1_activity
|
||||
trend = day1_activity.balance_trend
|
||||
assert_equal 0, trend.current.amount.to_i # Fallback to 0
|
||||
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||
assert_equal 0, trend.value.amount.to_i
|
||||
assert_equal "flat", trend.direction.to_s
|
||||
assert_nil day1_activity.balance
|
||||
end
|
||||
|
||||
test "calculates cash and holdings trends for investment accounts" do
|
||||
test "returns cash and holdings data for investment accounts" do
|
||||
entries = @investment.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
||||
|
||||
|
@ -87,20 +59,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
|
||||
|
||||
assert_not_nil day3_activity
|
||||
assert_not_nil day3_activity.balance
|
||||
|
||||
# Cash trend for day 3 (after foreign currency transaction)
|
||||
cash_trend = day3_activity.cash_balance_trend
|
||||
assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
|
||||
assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
|
||||
assert_equal(-100, cash_trend.value.amount.to_i)
|
||||
assert_equal "down", cash_trend.direction.to_s
|
||||
|
||||
# Holdings trend for day 3 (after trade)
|
||||
holdings_trend = day3_activity.holdings_value_trend
|
||||
assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance
|
||||
assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade
|
||||
assert_equal 1500, holdings_trend.value.amount.to_i
|
||||
assert_equal "up", holdings_trend.direction.to_s
|
||||
# Balance should have the new schema fields
|
||||
assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance
|
||||
assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value
|
||||
assert_equal 1900, day3_activity.balance.end_balance # Total balance
|
||||
end
|
||||
|
||||
test "identifies transfers for a specific date" do
|
||||
|
@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
activities.each do |activity|
|
||||
assert_respond_to activity, :date
|
||||
assert_respond_to activity, :entries
|
||||
assert_respond_to activity, :balance_trend
|
||||
assert_respond_to activity, :cash_balance_trend
|
||||
assert_respond_to activity, :holdings_value_trend
|
||||
assert_respond_to activity, :balance
|
||||
assert_respond_to activity, :transfers
|
||||
end
|
||||
end
|
||||
|
||||
test "handles valuations correctly by summing entry changes" do
|
||||
test "handles valuations correctly with new balance schema" do
|
||||
# Create account with known balances
|
||||
account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||
|
||||
# Day 1: Starting balance
|
||||
account.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 7321.56,
|
||||
cash_balance: 1000,
|
||||
balance: 7321.56, # Keep old field for now
|
||||
cash_balance: 1000, # Keep old field for now
|
||||
start_cash_balance: 0,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 1000,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 6321.56,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Day 2: Add transactions, trades and a valuation
|
||||
account.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 8500, # Valuation sets this
|
||||
cash_balance: 1070, # Cash increased by transactions
|
||||
balance: 8500, # Keep old field for now
|
||||
cash_balance: 1070, # Keep old field for now
|
||||
start_cash_balance: 1000,
|
||||
start_non_cash_balance: 6321.56,
|
||||
cash_inflows: 70,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 750,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 358.44,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
|
@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
assert_not_nil day2_activity.balance
|
||||
|
||||
# Cash change should be $70 (50 + 20 from transactions only, not trades)
|
||||
assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
|
||||
|
||||
# Holdings change should be 750 (from the trade)
|
||||
assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i
|
||||
|
||||
# Total balance change
|
||||
assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01
|
||||
end
|
||||
|
||||
test "normalizes multi-currency entries on valuation days" do
|
||||
# Create EUR account
|
||||
eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0)
|
||||
|
||||
# Day 1: Starting balance
|
||||
eur_account.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 1000,
|
||||
cash_balance: 500,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
# Day 2: Multi-currency transactions and valuation
|
||||
eur_account.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 2000,
|
||||
cash_balance: 600,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
# Create USD transaction (should be converted to EUR)
|
||||
create_transaction(
|
||||
account: eur_account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: -100,
|
||||
currency: "USD",
|
||||
name: "USD Payment"
|
||||
)
|
||||
|
||||
# Create exchange rate: 1 USD = 0.9 EUR
|
||||
ExchangeRate.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
from_currency: "USD",
|
||||
to_currency: "EUR",
|
||||
rate: 0.9
|
||||
)
|
||||
|
||||
# Create valuation
|
||||
create_valuation(
|
||||
account: eur_account,
|
||||
date: @test_period_start + 1.day,
|
||||
amount: 2000
|
||||
)
|
||||
|
||||
entries = eur_account.entries.includes(:entryable).to_a
|
||||
feed_data = Account::ActivityFeedData.new(eur_account, entries)
|
||||
|
||||
activities = feed_data.entries_by_date
|
||||
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||
|
||||
assert_not_nil day2_activity
|
||||
|
||||
# Cash change should be 90 EUR (100 USD * 0.9)
|
||||
# The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR
|
||||
assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i
|
||||
assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code
|
||||
# Check new balance fields
|
||||
assert_equal 1070, day2_activity.balance.end_cash_balance
|
||||
assert_equal 7430, day2_activity.balance.end_non_cash_balance
|
||||
assert_equal 8500, day2_activity.balance.end_balance
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
def setup_test_data
|
||||
# Create daily balances for checking account
|
||||
# Create daily balances for checking account with new schema
|
||||
5.times do |i|
|
||||
date = @test_period_start + i.days
|
||||
prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0
|
||||
|
||||
@checking.balances.create!(
|
||||
date: date,
|
||||
balance: 1000 + (i * 100),
|
||||
balance: 1000 + (i * 100), # Keep old field for now
|
||||
cash_balance: 1000 + (i * 100), # Keep old field for now
|
||||
start_balance: prev_balance,
|
||||
start_cash_balance: prev_balance,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: i == 0 ? 1000 : 100,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
|||
# Create daily balances for investment account with cash_balance
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start,
|
||||
balance: 500,
|
||||
cash_balance: 500,
|
||||
balance: 500, # Keep old field for now
|
||||
cash_balance: 500, # Keep old field for now
|
||||
start_balance: 0,
|
||||
start_cash_balance: 0,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 500,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start + 1.day,
|
||||
balance: 500,
|
||||
cash_balance: 500,
|
||||
balance: 500, # Keep old field for now
|
||||
cash_balance: 500, # Keep old field for now
|
||||
start_balance: 500,
|
||||
start_cash_balance: 500,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
@investment.balances.create!(
|
||||
date: @test_period_start + 2.days,
|
||||
balance: 1900, # 1500 holdings + 400 cash
|
||||
cash_balance: 400, # After -100 EUR transaction
|
||||
balance: 1900, # Keep old field for now
|
||||
cash_balance: 400, # Keep old field for now
|
||||
start_balance: 500,
|
||||
start_cash_balance: 500,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 100,
|
||||
non_cash_inflows: 1500,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
||||
include BalanceTestHelper
|
||||
|
||||
setup do
|
||||
@account = accounts(:investment)
|
||||
@manager = Account::ReconciliationManager.new(@account)
|
||||
end
|
||||
|
||||
test "new reconciliation" do
|
||||
@account.balances.create!(
|
||||
date: Date.current,
|
||||
balance: 1000,
|
||||
cash_balance: 500,
|
||||
currency: @account.currency
|
||||
)
|
||||
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
|
||||
|
||||
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
|
||||
|
||||
|
@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "updates existing reconciliation without date change" do
|
||||
@account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
|
||||
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
|
||||
|
||||
# Existing reconciliation entry
|
||||
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
|
||||
|
@ -39,8 +36,8 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "updates existing reconciliation with date and amount change" do
|
||||
@account.balances.create!(date: 5.days.ago, balance: 1000, cash_balance: 500, currency: @account.currency)
|
||||
@account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency)
|
||||
create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)
|
||||
create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)
|
||||
|
||||
# Existing reconciliation entry (5 days ago)
|
||||
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
|
||||
|
@ -63,12 +60,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "handles date conflicts" do
|
||||
@account.balances.create!(
|
||||
date: Date.current,
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: @account.currency
|
||||
)
|
||||
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
|
||||
|
||||
# Existing reconciliation entry
|
||||
@account.entries.create!(
|
||||
|
@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "dry run does not persist account" do
|
||||
@account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
|
||||
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
|
||||
|
||||
assert_no_difference "Valuation.count" do
|
||||
@manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
require "test_helper"
|
||||
|
||||
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
||||
include BalanceTestHelper
|
||||
|
||||
setup do
|
||||
end
|
||||
|
||||
|
@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
|||
account.balances.destroy_all
|
||||
|
||||
# With gaps
|
||||
account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD")
|
||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
|
||||
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
|
||||
create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
|
||||
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
||||
create_balance(account: account, date: Date.current, balance: 1200)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: [ account.id ],
|
||||
|
@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
|||
account = accounts(:depository)
|
||||
account.balances.destroy_all
|
||||
|
||||
account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD")
|
||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
|
||||
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
|
||||
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
|
||||
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
||||
create_balance(account: account, date: Date.current, balance: 1200)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: [ account.id ],
|
||||
|
@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
|||
|
||||
Balance.destroy_all
|
||||
|
||||
asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD")
|
||||
asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
|
||||
asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
|
||||
create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
|
||||
create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
|
||||
create_balance(account: asset_account, date: Date.current, balance: 1000)
|
||||
|
||||
liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD")
|
||||
liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD")
|
||||
liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD")
|
||||
create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
|
||||
create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
|
||||
create_balance(account: liability_account, date: Date.current, balance: 100)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: [ asset_account.id, liability_account.id ],
|
||||
|
@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
|||
account = accounts(:credit_card)
|
||||
account.balances.destroy_all
|
||||
|
||||
account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
|
||||
account.balances.create!(date: Date.current, balance: 500, currency: "USD")
|
||||
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
|
||||
create_balance(account: account, date: Date.current, balance: 500)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: [ account.id ],
|
||||
|
|
|
@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
|
@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 0.0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 },
|
||||
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
|
@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
flows: { market_flows: 0 },
|
||||
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
|
@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 5.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
|
||||
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
|
@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 5.days.ago.to_date,
|
||||
legacy_balances: { balance: 1000, cash_balance: 1000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
|
||||
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
|
@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 4.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
|
||||
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
|
@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 4.days.ago.to_date,
|
||||
legacy_balances: { balance: 100, cash_balance: 100 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
|
||||
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
|
@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 2.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 },
|
||||
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 1.day.ago.to_date,
|
||||
|
@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 500000, cash_balance: 0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 },
|
||||
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
|
@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 5000, cash_balance: 5000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
|
||||
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
|
@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
|||
)
|
||||
end
|
||||
|
||||
test "investment account can have valuations that override balance" do
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Investment, currency: "USD" },
|
||||
entries: [
|
||||
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 },
|
||||
{ type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 }
|
||||
],
|
||||
holdings: [
|
||||
{ date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
|
||||
{ date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
|
||||
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 },
|
||||
{ date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 }
|
||||
]
|
||||
)
|
||||
|
||||
# Given constant prices, overall balance (account value) should be constant
|
||||
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
|
||||
calculated = Balance::ForwardCalculator.new(account).calculate
|
||||
|
||||
assert_calculated_ledger_balances(
|
||||
calculated_data: calculated,
|
||||
expected_data: [
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
legacy_balances: { balance: 5000, cash_balance: 4000 },
|
||||
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },
|
||||
flows: 0,
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 1.day.ago.to_date,
|
||||
legacy_balances: { balance: 10000, cash_balance: 8900 },
|
||||
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 },
|
||||
flows: { net_market_flows: 100 },
|
||||
adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 }
|
||||
},
|
||||
{
|
||||
date: Date.current,
|
||||
legacy_balances: { balance: 10100, cash_balance: 8900 },
|
||||
balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 },
|
||||
flows: { net_market_flows: 100 },
|
||||
adjustments: 0
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def assert_balances(calculated_data:, expected_balances:)
|
||||
# Sort calculated data by date to ensure consistent ordering
|
||||
|
|
|
@ -2,6 +2,7 @@ require "test_helper"
|
|||
|
||||
class Balance::MaterializerTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
include BalanceTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
|
@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase
|
|||
test "syncs balances" do
|
||||
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
expected_balances = [
|
||||
Balance.new(
|
||||
date: 1.day.ago.to_date,
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
start_cash_balance: 500,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 500,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
),
|
||||
Balance.new(
|
||||
date: Date.current,
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
start_cash_balance: 1000,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
)
|
||||
]
|
||||
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
|
||||
end
|
||||
|
||||
assert_balance_fields_persisted(expected_balances)
|
||||
end
|
||||
|
||||
test "purges stale balances and holdings" do
|
||||
# Balance before start date is stale
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date).twice
|
||||
stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
|
||||
test "purges stale balances outside calculated range" do
|
||||
# Create existing balances that will be stale
|
||||
stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)
|
||||
stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)
|
||||
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
stale_balance,
|
||||
Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
# Calculator will return balances for only these dates
|
||||
expected_balances = [
|
||||
Balance.new(
|
||||
date: 2.days.ago.to_date,
|
||||
balance: 10000,
|
||||
cash_balance: 10000,
|
||||
currency: "USD",
|
||||
start_cash_balance: 10000,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
),
|
||||
Balance.new(
|
||||
date: 1.day.ago.to_date,
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
start_cash_balance: 10000,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 9000,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
),
|
||||
Balance.new(
|
||||
date: Date.current,
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
start_cash_balance: 1000,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
)
|
||||
]
|
||||
|
||||
assert_difference "@account.balances.count", 3 do
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
|
||||
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
|
||||
|
||||
# Should end up with 3 balances (stale ones deleted, new ones created)
|
||||
assert_difference "@account.balances.count", 1 do
|
||||
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
|
||||
end
|
||||
|
||||
# Verify stale balances were deleted
|
||||
assert_nil @account.balances.find_by(id: stale_old.id)
|
||||
assert_nil @account.balances.find_by(id: stale_future.id)
|
||||
|
||||
# Verify expected balances were persisted
|
||||
assert_balance_fields_persisted(expected_balances)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_balance_fields_persisted(expected_balances)
|
||||
expected_balances.each do |expected|
|
||||
persisted = @account.balances.find_by(date: expected.date)
|
||||
assert_not_nil persisted, "Balance for #{expected.date} should be persisted"
|
||||
|
||||
# Check all balance component fields
|
||||
assert_equal expected.balance, persisted.balance
|
||||
assert_equal expected.cash_balance, persisted.cash_balance
|
||||
assert_equal expected.start_cash_balance, persisted.start_cash_balance
|
||||
assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance
|
||||
assert_equal expected.cash_inflows, persisted.cash_inflows
|
||||
assert_equal expected.cash_outflows, persisted.cash_outflows
|
||||
assert_equal expected.non_cash_inflows, persisted.non_cash_inflows
|
||||
assert_equal expected.non_cash_outflows, persisted.non_cash_outflows
|
||||
assert_equal expected.net_market_flows, persisted.net_market_flows
|
||||
assert_equal expected.cash_adjustments, persisted.cash_adjustments
|
||||
assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments
|
||||
assert_equal expected.flows_factor, persisted.flows_factor
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
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