Files
sure/app/models/goal_pledge.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

160 lines
4.9 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class GoalPledge < ApplicationRecord
include Monetizable
KINDS = %w[transfer manual_save].freeze
STATUSES = %w[open matched cancelled expired].freeze
DEFAULT_WINDOW_DAYS = 7
EXTEND_DAYS = 7
MATCH_DATE_TOLERANCE_DAYS = 5
MATCH_AMOUNT_TOLERANCE_ABSOLUTE = BigDecimal("0.50")
MATCH_AMOUNT_TOLERANCE_RATIO = BigDecimal("0.01")
belongs_to :goal
belongs_to :account
belongs_to :matched_transaction, class_name: "Transaction", optional: true
enum :kind, KINDS.index_by(&:itself), prefix: :kind
enum :status, STATUSES.index_by(&:itself), prefix: :status
validates :amount, presence: true, numericality: { greater_than: 0 }
validates :currency, presence: true
validates :expires_at, presence: true
validate :account_must_be_linked_to_goal
validate :currency_matches_goal
validate :no_duplicate_open_pledge, on: :create
monetize :amount
# Newest first. Used by the show page to render pending-pledge banners in
# "most-recent on top" order. Not actually chronological — kept for clarity.
scope :reverse_chronological, -> { order(created_at: :desc) }
scope :open_and_expired_now, -> {
where(status: "open").where("expires_at < ?", Time.current)
}
before_validation :assign_defaults, on: :create
before_destroy :clear_matched_transaction_extra
# Tolerance check: entry date within [created_at 5d, expires_at] (so
# extend! widens the upper bound) and amount within ±$0.50 OR ±1%.
def matches?(entry)
return false unless status_open?
return false unless entry.account_id == account_id
earliest = created_at.to_date - MATCH_DATE_TOLERANCE_DAYS.days
latest = [ created_at.to_date + MATCH_DATE_TOLERANCE_DAYS.days, expires_at.to_date ].max
return false unless entry.date >= earliest && entry.date <= latest
txn_amount = entry.amount.to_d.abs
pledge_amount = amount.to_d
diff_abs = (txn_amount - pledge_amount).abs
return true if diff_abs <= MATCH_AMOUNT_TOLERANCE_ABSOLUTE
return true if pledge_amount.positive? && (diff_abs / pledge_amount) <= MATCH_AMOUNT_TOLERANCE_RATIO
false
end
def resolve_with!(transaction)
with_lock do
raise NotOpenError, "Pledge no longer open" unless status_open?
transaction.with_lock do
pledge_id_in_extra = transaction.extra.dig("goal", "pledge_id")
raise ActiveRecord::RecordInvalid if pledge_id_in_extra.present? && pledge_id_in_extra != id
extra = transaction.extra || {}
extra["goal"] = (extra["goal"] || {}).merge("pledge_id" => id)
transaction.update!(extra: extra)
update!(status: "matched", matched_transaction_id: transaction.id)
end
end
end
# Valuation-backed match: no transaction to stamp, just flip the pledge.
def resolve_with_valuation!
with_lock do
raise NotOpenError, "Pledge no longer open" unless status_open?
update!(status: "matched")
end
end
class NotOpenError < StandardError; end
def extend!(days: EXTEND_DAYS)
raise NotOpenError, "Only open pledges can be extended" unless status_open?
update!(expires_at: expires_at + days.days)
end
def cancel!
raise NotOpenError, "Only open pledges can be cancelled" unless status_open?
update!(status: "cancelled")
end
def expire!
return unless status_open?
update!(status: "expired")
end
def days_left
return 0 unless status_open?
delta = ((expires_at - Time.current) / 1.day).ceil
[ delta, 0 ].max
end
private
def assign_defaults
self.kind ||= "transfer"
self.status ||= "open"
self.expires_at ||= Time.current + DEFAULT_WINDOW_DAYS.days
self.currency ||= goal&.currency
end
def account_must_be_linked_to_goal
return if goal.nil? || account.nil?
return if goal.goal_accounts.where(account_id: account_id).exists?
errors.add(:account, :must_be_linked_to_goal)
end
def currency_matches_goal
return if goal.nil? || currency.blank?
return if currency == goal.currency
errors.add(:currency, :must_match_goal)
end
# Guards against a double-click that creates two identical open pledges,
# which would render two yellow banners and leave one orphaned to expiry.
def no_duplicate_open_pledge
return unless goal_id && account_id && amount && status_open?
exists = GoalPledge
.where(goal_id: goal_id, account_id: account_id, amount: amount, status: "open")
.where("expires_at >= ?", Time.current)
.exists?
errors.add(:base, :duplicate_open_pledge) if exists
end
def clear_matched_transaction_extra
return if matched_transaction_id.blank?
txn = Transaction.find_by(id: matched_transaction_id)
return if txn.nil?
return unless txn.extra.dig("goal", "pledge_id") == id
new_extra = txn.extra.deep_dup
new_extra["goal"]&.delete("pledge_id")
new_extra.delete("goal") if new_extra["goal"]&.empty?
txn.update!(extra: new_extra)
end
end