Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/models/balance/base_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 0 additions & 4 deletions app/models/balance/forward_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 65 additions & 0 deletions app/models/balance/reverse_calculator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
)

Expand Down Expand Up @@ -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
53 changes: 53 additions & 0 deletions test/models/balance/materializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
130 changes: 114 additions & 16 deletions test/models/balance/reverse_calculator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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: [
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
Loading