Files
sure/test/models/balance/waypoint_gui_breakdown_test.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

114 lines
5.5 KiB
Ruby

require "test_helper"
# Proves what the day-detail "Details" popup (UI::Account::BalanceReconciliation)
# shows for a daily-waypoint (EnableBanking-style) account with a transaction on
# every waypoint day. Persists real balances and reads the actual PG generated
# columns through the real GUI component.
class Balance::WaypointGuiBreakdownTest < ActiveSupport::TestCase
include LedgerTestingHelper
def persist_and_load(account)
calculated = Balance::ReverseCalculator.new(account).calculate
account.balances.upsert_all(
calculated.map { |b|
b.attributes.slice(
"date", "balance", "cash_balance", "currency",
"start_cash_balance", "start_non_cash_balance",
"cash_inflows", "cash_outflows",
"non_cash_inflows", "non_cash_outflows",
"net_market_flows", "cash_adjustments", "non_cash_adjustments",
"flows_factor"
).merge("updated_at" => Time.now)
},
unique_by: %i[account_id date currency]
)
account.balances.order(:date).to_a
end
# Reads the three lines exactly as the GUI Details popup renders them.
def gui_lines(balance, account)
items = UI::Account::BalanceReconciliation.new(balance: balance, account: account).reconciliation_items
{
start: items.find { |i| i[:style] == :start }[:value].amount,
net_flow: items.find { |i| i[:style] == :flow }[:value].amount,
final: items.find { |i| i[:style] == :final }[:value].amount
}
end
def build_account
create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
entries: [
{ type: "current_anchor", date: Date.current, balance: 20000 },
{ type: "reconciliation", date: 1.day.ago, balance: 19000 },
{ type: "transaction", date: 1.day.ago, amount: -1000 }, # +1000 deposit
{ type: "reconciliation", date: 2.days.ago, balance: 17000 },
{ type: "transaction", date: 2.days.ago, amount: -2000 }, # +2000 deposit
{ type: "reconciliation", date: 3.days.ago, balance: 16000 },
{ type: "transaction", date: 3.days.ago, amount: 500 }, # -500 expense
# 4.days.ago is a GAP day: no waypoint, no transaction
{ type: "opening_anchor", date: 5.days.ago, balance: 15000 }
]
)
end
# Hard assertions for the fix currently in the tree (derive-start).
test "derive-start fix: Net cash flow preserved on every waypoint day and it reconciles" do
account = build_account
balances = persist_and_load(account).index_by(&:date)
expectations = {
1.day.ago.to_date => { start: 18000, net_flow: 1000, final: 19000 },
2.days.ago.to_date => { start: 15000, net_flow: 2000, final: 17000 },
3.days.ago.to_date => { start: 16500, net_flow: -500, final: 16000 }
}
expectations.each do |date, exp|
g = gui_lines(balances[date], account)
assert_equal exp[:start], g[:start], "Start balance wrong on #{date}"
assert_equal exp[:net_flow], g[:net_flow], "Net cash flow wrong on #{date} (should NOT be zeroed)"
assert_equal exp[:final], g[:final], "Final balance wrong on #{date}"
assert_equal g[:start] + g[:net_flow], g[:final], "Breakdown does not reconcile on #{date}"
refute_equal 0, g[:net_flow], "Net cash flow was zeroed on #{date} — transaction hidden"
end
end
# Investment account: reconciliation waypoint on a day with a same-day trade.
# End total must match the API-reported waypoint (not waypoint + trade flows).
# Cash/non-cash split must be preserved and no double-count on the non-cash side.
test "investment reconciliation waypoint with same-day trade is not double-counted" do
account = create_account_with_ledger(
account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" },
entries: [
{ type: "current_anchor", date: Date.current, balance: 20000 },
{ type: "reconciliation", date: 1.day.ago, balance: 18000 }, # Bank says total was 18000
{ type: "trade", date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100 }, # Buy $1000 of AAPL
{ type: "opening_anchor", date: 3.days.ago, balance: 15000 }
],
holdings: [
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 2.days.ago.to_date, ticker: "AAPL", qty: 0, price: 100, amount: 0 },
{ date: 3.days.ago.to_date, ticker: "AAPL", qty: 0, price: 100, amount: 0 }
]
)
balances = persist_and_load(account).index_by(&:date)
waypoint = balances[1.day.ago.to_date]
# End total must equal the API-reported waypoint — not 18000 + trade flows (which would be 20000).
assert_equal 18000, waypoint.end_balance, "Investment waypoint end_balance must equal 18000, not double-count the trade"
# Cash/non-cash flows from the trade must still be present (not zeroed).
assert waypoint.cash_outflows > 0 || waypoint.non_cash_inflows > 0,
"Trade flows should be preserved on the waypoint day, not zeroed"
# A trade moves cash → holdings but doesn't change the total balance, so
# the gap day correctly stays at the same total as the waypoint — this is
# expected, not a phantom. The key check is that the waypoint day itself
# didn't inflate above 18000 by double-counting the trade.
gap_day = balances[2.days.ago.to_date]
assert_equal 18000, gap_day.end_balance, "Gap day total should equal pre-trade balance (trade doesn't change total)"
end
end