Files
sure/test/models/goal_pledge_test.rb
Guillem Arias 83c64b9e94 fix(goals): pledge lifecycle + connected-account detection
Behavioural fixes touching Goal, GoalPledge, the reconciler and the
goals controller. No schema change.

B5 — connected-account detection covered only Plaid. SimpleFIN, Brex,
Enable Banking, IBKR, Kraken, SnapTrade and Lunchflow users got
"manual_save" pledges by default; their auto-synced Transactions then
failed to match (reconciler matches Transactions to "transfer" pledges
only). Pledges sat in the yellow banner until expiry. Switch the
detection to !Account#manual?, which mirrors the existing
`Account.manual` scope (no account_providers, no plaid_account_id, no
simplefin_account_id). Add `Account#manual?` so the per-instance and
per-query checks can't drift.

B7 — `extend!` widens `expires_at` but `matches?` was anchored on
`created_at ± 5d`, so an extension that pushed the expiry past day 5
didn't actually buy any match runway. Widen the upper bound to
`max(created_at + 5d, expires_at)`. The lower bound stays at
`created_at − 5d`.

B8 — `Goal#open_pledges` returned `status: open` regardless of expiry.
Between a pledge timing out (day 7) and the 15-min sweep job marking
it `expired`, the show page rendered a ghost yellow banner with
"0 days left" that the reconciler would no longer touch. Add
`expires_at >= NOW` to the scope so the visible state matches the
match-eligible state.

B9 — Double-click on Record pledge produced two identical open
pledges, which then stacked as two yellow banners. Add a create-time
validation rejecting duplicates against (goal_id, account_id, amount,
status=open, expires_at >= NOW).

B10 — The reconciler used `transaction.with_lock` but didn't lock the
pledge. Two concurrent reconcile attempts on different transactions
could both target the same pledge; one would lose to the partial
unique index on `transactions.extra->'goal'->>'pledge_id'` and the
RecordNotUnique was caught by the outer StandardError rescue, which
silently dropped the other transaction's match attempt entirely.
Lock the pledge first, re-check `status_open?` inside the lock, and
catch RecordNotUnique alongside RecordInvalid/NotOpenError in the
reconciler — so on a lost race we fall through to the next candidate
pledge instead of exiting the loop. Extract the Valuation-match path
to `GoalPledge#resolve_with_valuation!` so it goes through the same
locked status-recheck.

B12 — When a goal is destroyed, `dependent: :destroy` reaped pledges
but left `transactions.extra["goal"]["pledge_id"]` pointing at the
now-deleted UUIDs. The partial unique index on that JSON path then
indexed stale references. Add a `before_destroy` on GoalPledge that
clears the matching transaction's `extra` if it still points back to
the pledge.

B6 — `last_matched_pledge_at` used `goal_pledges.maximum(:updated_at)`
on matched rows. Any backfill or sync-resync that touches a matched
pledge bumped `updated_at`, so a single resync set every goal's "Last
saved N days ago" header back to "today". Switch to the entry's
`date` via a join through `matched_transaction_id`, which reflects the
date the money actually moved.

B22 — `scope :chronological` ordered DESC, the opposite of what the
name promises. Rename to `:reverse_chronological` and update the one
caller in `goals#show`. (Other models' `chronological` scopes are
unrelated and ordered correctly.)

Also: preload `account_providers` on `linked_accounts` in the index
and show controllers so `Account#manual?` walks the in-memory
collection instead of triggering N queries.

Tests: add fixture-backed coverage for extend-widens-match-window,
post-extend rejection beyond expiry, and the duplicate-pledge
validation. Existing assertions still hold against the new
`matches?` window math.
2026-05-14 19:12:28 +02:00

139 lines
4.7 KiB
Ruby

require "test_helper"
class GoalPledgeTest < ActiveSupport::TestCase
setup do
@goal = goals(:vacation_italy)
@account = accounts(:depository)
@pledge = goal_pledges(:open_transfer)
end
test "valid fixture pledge saves" do
assert @pledge.valid?
end
test "amount must be positive" do
@pledge.amount = 0
assert_not @pledge.valid?
end
test "account must be linked to goal" do
other_account = accounts(:investment)
pledge = @goal.goal_pledges.new(account: other_account, amount: 50, currency: "USD")
assert_not pledge.valid?
assert_includes pledge.errors[:account], "Pick one of the goal's linked accounts."
end
test "currency must match goal currency" do
@pledge.currency = "EUR"
assert_not @pledge.valid?
assert_includes @pledge.errors[:currency], "Pledge currency must match the goal currency."
end
test "defaults populate on create" do
pledge = @goal.goal_pledges.new(account: @account, amount: 50)
pledge.valid?
assert_equal "open", pledge.status
assert_equal "transfer", pledge.kind
assert_not_nil pledge.expires_at
assert pledge.expires_at > Time.current
assert_equal @goal.currency, pledge.currency
end
test "matches? returns true within tolerances" do
entry = build_entry(account: @account, amount: -200.25, date: @pledge.created_at.to_date + 1.day)
assert @pledge.matches?(entry)
end
test "matches? returns false outside date window" do
entry = build_entry(account: @account, amount: -200, date: @pledge.created_at.to_date + 10.days)
assert_not @pledge.matches?(entry)
end
test "matches? returns false outside amount tolerance" do
entry = build_entry(account: @account, amount: -250, date: @pledge.created_at.to_date)
assert_not @pledge.matches?(entry)
end
test "matches? returns true within ratio tolerance" do
entry = build_entry(account: @account, amount: -201.99, date: @pledge.created_at.to_date)
assert @pledge.matches?(entry)
end
test "matches? returns false on wrong account" do
other_account = accounts(:connected)
entry = build_entry(account: other_account, amount: -200, date: @pledge.created_at.to_date)
assert_not @pledge.matches?(entry)
end
test "matches? returns false on already-matched pledge" do
matched = goal_pledges(:matched_transfer)
entry = build_entry(account: matched.account, amount: -matched.amount.to_d, date: matched.created_at.to_date)
assert_not matched.matches?(entry)
end
test "extend! pushes expires_at forward" do
before = @pledge.expires_at
@pledge.extend!
assert @pledge.expires_at > before + 6.days
end
test "matches? widens upper bound to expires_at after extend!" do
# Day 8 — past the default 5-day creation-anchored window but inside the
# extended expiry window. Without the widening this would be a regression
# of B7 (extend doesn't actually buy match runway).
@pledge.extend!
far_date = @pledge.created_at.to_date + 8.days
assert far_date <= @pledge.expires_at.to_date
entry = build_entry(account: @account, amount: -200, date: far_date)
assert @pledge.matches?(entry)
end
test "matches? rejects entries past extended expires_at" do
@pledge.extend!
far_date = @pledge.expires_at.to_date + 1.day
entry = build_entry(account: @account, amount: -200, date: far_date)
assert_not @pledge.matches?(entry)
end
test "duplicate open pledge for same goal+account+amount is rejected on create" do
dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount, currency: @goal.currency)
assert_not dup.valid?
assert dup.errors[:base].any? { |m| m.include?("open pledge") }
end
test "duplicate validation does not block different amounts" do
dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount.to_d + 1, currency: @goal.currency)
assert dup.valid?, dup.errors.full_messages.to_sentence
end
test "extend! raises for non-open pledge" do
pledge = goal_pledges(:matched_transfer)
assert_raises(GoalPledge::NotOpenError) { pledge.extend! }
end
test "cancel! transitions open to cancelled" do
@pledge.cancel!
assert @pledge.status_cancelled?
end
test "expire! transitions open to expired" do
@pledge.expire!
assert @pledge.status_expired?
end
test "days_left counts down" do
@pledge.expires_at = 3.days.from_now
assert_includes 2..3, @pledge.days_left
end
test "days_left returns 0 for non-open" do
pledge = goal_pledges(:matched_transfer)
assert_equal 0, pledge.days_left
end
private
def build_entry(account:, amount:, date:)
OpenStruct.new(account_id: account.id, amount: BigDecimal(amount.to_s), date: date.to_date)
end
end