mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 15:59:02 +00:00
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.