require "test_helper" class Balance::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper include BalanceTestHelper setup do @account = families(:empty).accounts.create!( name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new ) end test "syncs balances" do Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once 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(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 "incremental sync preserves balances before window_start_date and purges only beyond calc_end_date" do # Add an opening anchor so opening_anchor_date is well in the past. @account.entries.create!( name: "Opening Balance", date: 10.days.ago.to_date, amount: 5000, currency: "USD", entryable: Valuation.new(kind: "opening_anchor") ) preserved_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 10000) preserved_mid = create_balance(account: @account, date: 3.days.ago.to_date, balance: 12000) stale_future = create_balance(account: @account, date: 5.days.from_now.to_date, balance: 99000) # Calculator returns only the window being recalculated (2.days.ago). recalculated = [ Balance.new( date: 2.days.ago.to_date, balance: 15000, cash_balance: 15000, currency: "USD", start_cash_balance: 12000, start_non_cash_balance: 0, cash_inflows: 3000, 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(recalculated) Balance::ForwardCalculator.any_instance.stubs(:incremental?).returns(true) Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once Balance::Materializer.new(@account, strategy: :forward, window_start_date: 2.days.ago.to_date).materialize_balances # Balances before window_start_date must be preserved. assert_not_nil @account.balances.find_by(id: preserved_old.id), "Balance at 5.days.ago should be preserved (before window_start_date)" assert_not_nil @account.balances.find_by(id: preserved_mid.id), "Balance at 3.days.ago should be preserved (before window_start_date)" # Balance after calc_end_date must be purged. assert_nil @account.balances.find_by(id: stale_future.id), "Balance at 5.days.from_now should be purged (after calc_end_date)" # Recalculated balance must be present. assert_not_nil @account.balances.find_by(date: 2.days.ago.to_date), "Recalculated balance for 2.days.ago should be persisted" end test "falls back to full recalculation when window_start_date is given but no prior balance exists" do @account.entries.create!( name: "Opening Balance", date: 5.days.ago.to_date, amount: 20000, currency: "USD", entryable: Valuation.new(kind: "opening_anchor") ) @account.entries.create!( name: "Test transaction", date: 3.days.ago.to_date, amount: -1000, currency: "USD", entryable: Transaction.new ) # A stale pre-window balance with a wrong value. # In successful incremental mode this would be preserved as-is; # in fallback (no prior balance) the full recalc must overwrite it. wrong_pre_window = create_balance(account: @account, date: 4.days.ago.to_date, balance: 99999) # A stale balance before opening_anchor_date — must be purged in both modes. stale_before_anchor = create_balance(account: @account, date: 8.days.ago.to_date, balance: 99999) Holding::Materializer.any_instance.stubs(:materialize_holdings).returns([]) # No prior balance exists for window_start_date - 1 (3.days.ago) → calculator falls back to full recalc. Balance::Materializer.new(@account, strategy: :forward, window_start_date: 2.days.ago.to_date).materialize_balances # After fallback the pre-window balance must be recalculated with the correct value, not preserved. recalculated = @account.balances.find_by(date: wrong_pre_window.date) assert_not_nil recalculated, "Balance at 4.days.ago should exist after full recalculation" assert_equal 20000, recalculated.balance, "Balance should reflect full recalculation, not the stale value (99999)" # Stale balance before opening_anchor_date should be purged. assert_nil @account.balances.find_by(id: stale_before_anchor.id), "Balance before opening_anchor_date should be purged" end 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) # 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 ) ] 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