mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +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.
160 lines
4.9 KiB
Ruby
160 lines
4.9 KiB
Ruby
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
|