fix(balance): surface reverse opening boundary adjustments (#2502)

This commit is contained in:
Orange🍊
2026-07-04 02:47:46 +02:00
committed by GitHub
parent edb91cad6f
commit 59c47c4c66
5 changed files with 238 additions and 21 deletions
+53
View File
@@ -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)
+114 -16
View File
@@ -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 },