Files
sure/test/models/account/reconciliation_manager_test.rb
Guillem Arias Fauste 3b4aa55516 test(goals): pledge-delta integration coverage (follow-up to #2178) (#2206)
* fix(goals): match manual_save pledges by contribution delta, not full balance

Closes #2177

* fix(goals): clarify depository-only contribution; lowercase test names

Address review: document that valuation_contribution's delta is only consumed
for goal-linked Depository accounts, so the positive-delta guard is correct and
no liability paydown sign case can arise. Lowercase NOT in two test names to
match project convention.

* test(goals): pin pledge matching through the reconciliation manager

Follow-up to #2178, which matched manual_save pledges by contribution
delta but only unit-tested the matcher with an injected delta. The
delta derivation itself (the balances-table lookup and the nil-to-0
fallback in valuation_contribution) had no coverage, and
reconciliation_manager_test never touched pledges.

Two manager-level tests pin the wiring end to end: a 2000 -> 2150
reconcile matches an open 150 pledge, and a same-balance reconcile
(zero delta) leaves it open, closing the <= 0 boundary.

Also documents the staleness window on valuation_contribution: the
prior balance reads the balances table, which recomputes async after
the valuation saves, so a second same-day reconcile racing that sync
derives a stale delta and self-heals on the next save.

* fix(goals): derive same-day re-reconcile delta from the prior valuation

The balances table recomputes asynchronously after a reconcile, so a
second same-day reconcile racing that sync derived its delta from the
pre-first-reconcile row. An overstated delta could wrongly close a
larger open pledge — and a wrong match, unlike a miss, never
self-heals.

Prefer the valuation's own pre-save amount as the prior balance when
the reconcile updates an existing valuation; fall back to the balances
row, then 0. Same-date re-reconciles are immune to the race; only the
cross-date window remains, and only in the miss direction.

---------

Co-authored-by: galuis116 <galuis116@gmail.com>
2026-06-08 21:45:35 +02:00

171 lines
6.4 KiB
Ruby

require "test_helper"
class Account::ReconciliationManagerTest < ActiveSupport::TestCase
include BalanceTestHelper
setup do
@account = accounts(:investment)
@manager = Account::ReconciliationManager.new(@account)
end
test "new reconciliation" do
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
assert_equal 1200, result.new_balance
assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance
assert_equal 1000, result.old_balance
assert_equal 500, result.old_cash_balance
assert_equal true, result.success?
end
test "updates existing reconciliation without date change" do
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
# Existing reconciliation entry
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
result = @manager.reconcile_balance(balance: 1200, date: Date.current, existing_valuation_entry: existing_entry)
assert_equal 1200, result.new_balance
assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance
assert_equal 1000, result.old_balance
assert_equal 500, result.old_cash_balance
assert_equal true, result.success?
end
test "updates existing reconciliation with date and amount change" do
create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)
create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)
# Existing reconciliation entry (5 days ago)
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
# Should update and change date for existing entry; not create a new one
assert_no_difference "Valuation.count" do
# "Update valuation from 5 days ago to today, set balance from 1000 to 1500"
result = @manager.reconcile_balance(balance: 1500, date: Date.current, existing_valuation_entry: existing_entry)
assert_equal true, result.success?
# Reconciliation
assert_equal 1500, result.new_balance # Equal to new valuation amount
assert_equal 1000, result.new_cash_balance # Get non-cash balance today (1200 - 700 = 500). Then subtract this from new valuation (1500 - 500 = 1000)
# Prior valuation
assert_equal 1000, result.old_balance # This is the balance from the old valuation, NOT the date we're reconciling to
assert_equal 500, result.old_cash_balance
end
end
test "handles date conflicts" do
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
# Existing reconciliation entry
@account.entries.create!(
name: "Test",
amount: 1000,
date: Date.current,
entryable: Valuation.new(kind: "reconciliation"),
currency: @account.currency
)
# Doesn't pass existing_valuation_entry, but reconciliation manager should recognize its the same date and update the existing entry
assert_no_difference "Valuation.count" do
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
assert result.success?
assert_equal 1200, result.new_balance
end
end
test "dry run does not persist account" do
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
assert_no_difference "Valuation.count" do
@manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)
end
assert_difference "Valuation.count", 1 do
@manager.reconcile_balance(balance: 1200, date: Date.current)
end
end
test "reconciliation matches an open manual_save pledge by contribution delta" do
account = accounts(:depository)
manager = Account::ReconciliationManager.new(account)
create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000)
pledge = goal_pledges(:open_transfer).goal.goal_pledges.create!(
account: account,
amount: 150,
currency: "USD",
kind: "manual_save"
)
result = manager.reconcile_balance(balance: 2150, date: Date.current)
assert result.success?
assert pledge.reload.status_matched?
end
test "reconciliation to the same balance leaves manual_save pledges open" do
account = accounts(:depository)
manager = Account::ReconciliationManager.new(account)
create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000)
pledge = goal_pledges(:open_transfer).goal.goal_pledges.create!(
account: account,
amount: 150,
currency: "USD",
kind: "manual_save"
)
result = manager.reconcile_balance(balance: 2000, date: Date.current)
assert result.success?
assert_not pledge.reload.status_matched?
end
test "second same-day reconcile derives its delta from the valuation it updates, not the stale balance row" do
account = accounts(:depository)
manager = Account::ReconciliationManager.new(account)
create_balance(account: account, date: Date.current, balance: 2000, cash_balance: 2000)
goal = goal_pledges(:open_transfer).goal
first_pledge = goal.goal_pledges.create!(
account: account,
amount: 150,
currency: "USD",
kind: "manual_save"
)
assert manager.reconcile_balance(balance: 2150, date: Date.current).success?
assert first_pledge.reload.status_matched?
# The post-reconcile balance sync is async and hasn't run, so the
# balances row still says $2,000. The second save of $150 (2150 → 2300)
# must not be read as a $300 contribution off that stale row — that
# would wrongly close the larger pledge, and a wrong match never
# self-heals.
oversized_pledge = goal.goal_pledges.create!(
account: account,
amount: 300,
currency: "USD",
kind: "manual_save"
)
second_pledge = goal.goal_pledges.create!(
account: account,
amount: 150,
currency: "USD",
kind: "manual_save"
)
assert manager.reconcile_balance(balance: 2300, date: Date.current).success?
assert_not oversized_pledge.reload.status_matched?
assert second_pledge.reload.status_matched?
end
end