Files
sure/app/models/goal_pledge.rb
Guillem Arias 9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
  purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
  currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
  reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
  and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
  accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
  to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
  querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
  the non-existent "category" controller

UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
  today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)

Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
  GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
  wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
  use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
  the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
  cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
  instead of looking it up by hardcoded name

Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
  unconditional guard
2026-05-15 00:01:13 +02:00

170 lines
5.5 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%.
# Transfer pledges only fire on inflows (Sure convention: inflow < 0).
# Without this guard, .abs below lets a $200 outflow satisfy a $200
# transfer pledge as readily as a $200 deposit.
def matches?(entry)
return false unless status_open?
return false unless entry.account_id == account_id
return false if kind_transfer? && !entry.amount.to_d.negative?
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")
if pledge_id_in_extra.present? && pledge_id_in_extra != id
raise AlreadyClaimedError, "Transaction ##{transaction.id} already claimed by pledge ##{pledge_id_in_extra}"
end
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
# Raised when a Transaction is already claimed by a different open
# pledge. Lets the reconciler distinguish a known race ("another worker
# got there first") from a generic validation failure.
class AlreadyClaimedError < 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