diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 0e4b5d24a8..319362e0d2 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -66,6 +66,11 @@ def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows) end_non_cash - start_non_cash - non_cash_flows end + # Keeps asset/liability flow sign conventions centralized for persisted balances. + def flows_factor + account.classification == "asset" ? 1 : -1 + end + # If holdings value goes from $100 -> $200 (change_holdings_value is $100) # And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50) # That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell @@ -159,7 +164,7 @@ def build_balance(date:, **args) cash_adjustments: args[:cash_adjustments] || 0, non_cash_adjustments: args[:non_cash_adjustments] || 0, net_market_flows: args[:net_market_flows] || 0, - flows_factor: account.classification == "asset" ? 1 : -1 + flows_factor: flows_factor ) end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 29163b7c6e..521473693c 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -156,8 +156,4 @@ def derive_end_cash_balance(start_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) end - - def flows_factor - account.asset? ? 1 : -1 - end end diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index d073beda74..64e9993757 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -18,6 +18,8 @@ def calculate account.current_anchor_date.downto(calculation_start_date).map do |date| flows = flows_for_date(date) valuation = sync_cache.get_valuation(date) + cash_adjustments = 0 + non_cash_adjustments = 0 if use_opening_anchor_for_date?(date) end_cash_balance = derive_cash_balance_on_date_from_total( @@ -50,6 +52,20 @@ def calculate market_value_change = market_value_change_on_date(date, flows) end + if use_opening_boundary_adjustment_for_date?(date) + boundary_adjustment = opening_boundary_adjustment( + end_cash_balance: end_cash_balance, + end_non_cash_balance: end_non_cash_balance, + flows: flows, + market_value_change: market_value_change + ) + + start_cash_balance = boundary_adjustment[:start_cash_balance] + start_non_cash_balance = boundary_adjustment[:start_non_cash_balance] + cash_adjustments = boundary_adjustment[:cash_adjustments] + non_cash_adjustments = boundary_adjustment[:non_cash_adjustments] + end + output_balance = build_balance( date: date, balance: end_cash_balance + end_non_cash_balance, @@ -60,6 +76,8 @@ def calculate cash_outflows: flows[:cash_outflows], non_cash_inflows: flows[:non_cash_inflows], non_cash_outflows: flows[:non_cash_outflows], + cash_adjustments: cash_adjustments, + non_cash_adjustments: non_cash_adjustments, net_market_flows: market_value_change ) @@ -100,4 +118,51 @@ def derive_start_non_cash_balance(end_non_cash_balance:, date:) def use_opening_anchor_for_date?(date) account.has_opening_anchor? && date == account.opening_anchor_date end + + # Applies the one-day bridge from the opening anchor to the first derived day. + def use_opening_boundary_adjustment_for_date?(date) + account.has_opening_anchor? && date == account.opening_anchor_date.next_day + end + + # Builds explicit adjustments that make the opening-boundary row auditably reconcile. + def opening_boundary_adjustment(end_cash_balance:, end_non_cash_balance:, flows:, market_value_change:) + opening_cash_balance, opening_non_cash_balance = opening_balance_components + + { + start_cash_balance: opening_cash_balance, + start_non_cash_balance: opening_non_cash_balance, + cash_adjustments: cash_adjustments_for_date(opening_cash_balance, end_cash_balance, cash_flows_total(flows)), + non_cash_adjustments: opening_boundary_non_cash_adjustments( + opening_non_cash_balance: opening_non_cash_balance, + end_non_cash_balance: end_non_cash_balance, + flows: flows, + market_value_change: market_value_change + ) + } + end + + # Splits the opening anchor total into the calculator's persisted components. + def opening_balance_components + opening_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: account.opening_anchor_balance, + date: account.opening_anchor_date + ) + + [ opening_cash_balance, account.opening_anchor_balance - opening_cash_balance ] + end + + # Converts same-day cash flow columns into their signed balance impact. + def cash_flows_total(flows) + (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor + end + + # Converts same-day non-cash flow columns into their signed balance impact. + def non_cash_flows_total(flows) + (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor + end + + # Keeps boundary non-cash math market-value-aware for investment accounts. + def opening_boundary_non_cash_adjustments(opening_non_cash_balance:, end_non_cash_balance:, flows:, market_value_change:) + end_non_cash_balance - opening_non_cash_balance - non_cash_flows_total(flows) - market_value_change + end end diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb index 6ee91d99db..741260601d 100644 --- a/test/models/balance/materializer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -268,6 +268,59 @@ class Balance::MaterializerTest < ActiveSupport::TestCase assert_balance_fields_persisted(expected_balances) end + test "reverse materialization persists opening boundary adjustment" do + account = families(:empty).accounts.create!( + name: "Linked Depository", + balance: 1000, + cash_balance: 1000, + currency: "USD", + accountable: Depository.new + ) + opening_date = Date.new(2024, 1, 1) + boundary_date = opening_date + 1.day + transaction_date = opening_date + 2.days + current_anchor_date = opening_date + 3.days + + account.entries.create!( + name: "Current Balance", + date: current_anchor_date, + amount: 1000, + currency: "USD", + entryable: Valuation.new(kind: "current_anchor") + ) + account.entries.create!( + name: "Transaction", + date: transaction_date, + amount: 200, + currency: "USD", + entryable: Transaction.new + ) + account.entries.create!( + name: "Opening Balance", + date: opening_date, + amount: 1000, + currency: "USD", + entryable: Valuation.new(kind: "opening_anchor") + ) + + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once + + Balance::Materializer.new(account, strategy: :reverse).materialize_balances + + opening_balance = account.balances.find_by!(date: opening_date) + boundary_balance = account.balances.find_by!(date: boundary_date) + transaction_balance = account.balances.find_by!(date: transaction_date) + + assert_equal 1000, opening_balance.end_balance + assert_equal 1000, boundary_balance.start_balance + assert_equal 1200, boundary_balance.end_balance + assert_equal 200, boundary_balance.cash_adjustments + assert_equal 0, boundary_balance.cash_inflows + assert_equal 0, boundary_balance.cash_outflows + assert_equal 1200, transaction_balance.start_balance + assert_equal 1000, transaction_balance.end_balance + end + private def assert_balance_fields_persisted(expected_balances) diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 070e1f0ff0..f40a134b1a 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -28,6 +28,102 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase ) end + test "opening boundary difference is surfaced as an adjustment instead of a silent jump" do + opening_date = Date.new(2024, 1, 1) + day_after_opening = opening_date + 1.day + transaction_date = opening_date + 2.days + current_anchor_date = transaction_date + 1.day + + account = create_account_with_ledger( + account: { type: Depository, balance: 1000, cash_balance: 1000, currency: "USD" }, + entries: [ + { type: "current_anchor", date: current_anchor_date, balance: 1000 }, + { type: "transaction", date: transaction_date, amount: 200 }, + { type: "opening_anchor", date: opening_date, balance: 1000 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: opening_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: day_after_opening, + legacy_balances: { balance: 1200, cash_balance: 1200 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1200, end_non_cash: 0, end: 1200 }, + flows: 0, + adjustments: { cash_adjustments: 200, non_cash_adjustments: 0 } + }, + { + date: transaction_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1200, start_cash: 1200, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: { cash_inflows: 0, cash_outflows: 200 }, + adjustments: 0 + }, + { + date: current_anchor_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + } + ] + ) + end + + test "opening boundary adjustment uses liability flow direction" do + opening_date = Date.new(2024, 1, 1) + boundary_date = opening_date + 1.day + current_anchor_date = boundary_date + 1.day + + account = create_account_with_ledger( + account: { type: CreditCard, balance: 500, cash_balance: 500, currency: "USD" }, + entries: [ + { type: "current_anchor", date: current_anchor_date, balance: 500 }, + { type: "transaction", date: boundary_date, amount: 100 }, + { type: "opening_anchor", date: opening_date, balance: 1000 } + ] + ) + + calculated = Balance::ReverseCalculator.new(account).calculate + + assert_calculated_ledger_balances( + calculated_data: calculated, + expected_data: [ + { + date: opening_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: 0 + }, + { + date: boundary_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: { cash_adjustments: -600, non_cash_adjustments: 0 } + }, + { + date: current_anchor_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: 0, + adjustments: 0 + } + ] + ) + end + # Reconciliation valuations act as waypoints during reverse syncs. This ensures that # historical balances accurately reflect the API-reported values, even if the transaction # history is incomplete or missing. @@ -43,6 +139,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase calculated = Balance::ReverseCalculator.new(account).calculate + # The day after the opening anchor now carries an explicit adjustment so + # the gap to the first reconciliation waypoint is auditable. assert_calculated_ledger_balances( calculated_data: calculated, expected_data: [ @@ -70,10 +168,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } - }, # Derived from Reconciliation waypoint + adjustments: { cash_adjustments: 2000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap from opening anchor to waypoint { date: 4.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 }, @@ -154,10 +252,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 6.days.ago, legacy_balances: { balance: 22500, cash_balance: 22500 }, - balances: { start: 22200, start_cash: 22200, start_non_cash: 0, end_cash: 22500, end_non_cash: 0, end: 22500 }, + balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 22500, end_non_cash: 0, end: 22500 }, flows: { cash_inflows: 300, cash_outflows: 0 }, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } - }, # Income derived further back, right before opening_anchor + adjustments: { cash_adjustments: 4200, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap from opening anchor to derived balance { date: 7.days.ago, legacy_balances: { balance: 18000, cash_balance: 18000 }, @@ -216,9 +314,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago, legacy_balances: { balance: 18000, cash_balance: 18000 }, - balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 3000, non_cash_adjustments: 0 } }, { date: 4.days.ago, @@ -314,10 +412,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 10000 }, - balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, + balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, flows: { market_flows: 0 }, - adjustments: 0 - }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap to the current provider anchor { date: 1.day.ago, legacy_balances: { balance: 15000, cash_balance: 5000 }, @@ -672,9 +770,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, flows: { market_flows: 0 }, - adjustments: 0 + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } }, { date: 2.days.ago, @@ -719,10 +817,10 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: 0 - }, # Gap above the anchor carries down with no flows + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + }, # Opening boundary adjustment explains the gap above the anchor { date: 2.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 },