Files
sure/app/models/balance/reverse_calculator.rb
CrossDrain 2620653b2a fix(balance): derive waypoint start from day's flows to prevent double-counting and phantom bumps (#2031)
* fix(balance): fix double-counting on reconciliation waypoints with same-day transactions

Waypoint branch was setting start = end = waypoint and passing real flows
to build_balance. Since end_balance is a PG generated column that recomputes
from flows, transactions were double-counted on waypoint days and the prior
gap day inherited a phantom jump.

Fix: pin only the end to the API value, derive start from the day's own
flows (same as current_anchor). Transaction attributed once, gap day
correct, investment cash/holdings split correct.

Adds regression test + GUI breakdown test verified against real PG columns
through UI::Account::BalanceReconciliation.

Fixes #2007.

* test(balance): add investment waypoint regression test

Covers reconciliation waypoint + same-day trade on investment accounts:
end_balance must match API-reported total (not double-count trade flows),
cash/non-cash flows must be preserved, and gap day total must be correct.
2026-05-28 19:37:57 +02:00

99 lines
4.6 KiB
Ruby

class Balance::ReverseCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ReverseCalculator") do
# Since it's a reverse sync, we're starting with the "end of day" balance components and
# calculating backwards to derive the "start of day" balance components.
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.current_anchor_balance,
date: account.current_anchor_date
)
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
# Calculates in reverse-chronological order (End of day -> Start of day)
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
flows = flows_for_date(date)
valuation = sync_cache.get_valuation(date)
if use_opening_anchor_for_date?(date)
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: date
)
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
market_value_change = 0
elsif valuation && valuation.entryable.reconciliation?
# Reconciliation waypoint: hard-reset the END-of-day balance to the
# API-reported value, neutralizing any drift accumulated from missing
# transactions between here and the next anchor. The START is still
# derived from this day's own flows, so a same-day transaction is
# attributed exactly once (and not added on top of the waypoint).
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: valuation.amount,
date: date
)
end_non_cash_balance = valuation.amount - end_cash_balance
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
market_value_change = market_value_change_on_date(date, flows)
else
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
market_value_change = market_value_change_on_date(date, flows)
end
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
net_market_flows: market_value_change
)
end_cash_balance = start_cash_balance
end_non_cash_balance = start_non_cash_balance
output_balance
end
end
end
private
# Negative entries amount on an "asset" account means, "account value has increased"
# Negative entries amount on a "liability" account means, "account debt has decreased"
# Positive entries amount on an "asset" account means, "account value has decreased"
# Positive entries amount on a "liability" account means, "account debt has increased"
def signed_entry_flows(entries)
entry_flows = entries.sum(&:amount)
account.asset? ? entry_flows : -entry_flows
end
# Alias method, for algorithmic clarity
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
def derive_start_cash_balance(end_cash_balance:, date:)
derive_cash_balance(end_cash_balance, date)
end
# Alias method, for algorithmic clarity
# Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
end
# Checks if this date should use the opening anchor balance instead of deriving it.
# Only the opening_anchor_date itself gets this treatment — reconciliation waypoints
# are handled separately in the calculate loop above.
def use_opening_anchor_for_date?(date)
account.has_opening_anchor? && date == account.opening_anchor_date
end
end