Files
sure/app/models/goal_pledge.rb
Guillem Arias Fauste c5ca0431c9 feat(goals): investment-backed goals (Phase 2) (#2491)
* feat(goals): earmark a portion of an account toward a goal

Goals currently count each linked account's whole balance, so an account
shared across goals double-counts and one account can't fund several goals
in distinct slices. Add a per-account earmark — the "GoalBacking" the v1
model already foreshadowed (goal.rb).

- goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole
  balance" (the v1 default: no backfill, existing goals unchanged); a set
  amount reserves a fixed slice.
- Goal#current_balance is now the single chokepoint computing each account's
  backing under a family-wide shared pool: fixed earmarks take their slice,
  an unallocated link takes the remainder, and when fixed earmarks exceed the
  balance every slice is scaled down pro-rata so the goals' shares can never
  sum past the account balance (no double-counting).
- Account#free_to_earmark / #goal_earmarked_total (mirror Budget's
  available_to_allocate) back a soft, non-blocking over-allocation hint.
- GoalsController threads a goal[allocations] hash through create/update.

Phase 1 of the goals earmarking work; investment-backed goals follow.

* feat(goals): earmark UI on the goal form + backing-aware funding breakdown

- Goal form: a per-account "earmark amount" input (blank = whole balance)
  next to each funding-account checkbox, prefilled from the saved
  allocation on edit.
- Goal#account_backing exposes a single linked account's share so the
  funding-accounts breakdown shows each account's earmarked contribution
  and percent instead of its whole balance — keeping the show page
  consistent with the (now allocation-aware) progress ring.
- English strings for the earmark controls and the "earmarked of balance"
  breakdown line.

* fix(goals): address review on the earmark shared-pool math

- Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and
  whole-balance paths. The fixed path previously produced negative backing and
  let a goal claim money the account doesn't hold.
- An archived goal reads its OWN earmark from its own goal_accounts instead of
  the shared pool (which excludes archived goals), so it no longer mis-reports
  the whole account balance for itself.
- goals#index injects one family-wide earmark pool into every card
  (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and
  preloads goal_accounts.
- The projection chart scales its whole-account historical series by the
  backing ratio so the saved line meets current_balance at "today" rather than
  dropping off a cliff for earmarked goals.
- Honest comments: free_to_earmark no longer claims a form warning that doesn't
  exist yet; pace documents its deliberate whole-account basis.

* fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped

* fix(goals): address review on #2490

- autosave: true on goal_accounts so earmark edits to already-linked accounts
  persist through goal.save! (Rails only auto-saves newly built children, so
  changing/clearing an existing earmark was silently dropped). + test.
- Reset the balance/progress memos on AASM transitions, not just the status
  memos, so a same-instance render after complete!/archive! isn't stale. + test.
- backing_ratio is 0 (not 1) when the linked-account total is non-positive, so
  the projection saved series ends at 0 to match the forced-zero current_balance.
- Localize the funding-row subtype label via goals.form.subtypes.*.
- Add the earmark strings to zh-CN (the maintained second locale; goals has no
  ca locale, so Catalan keeps falling back to en like the rest of goals).

* feat(goals): investment-backed goals (Phase 2)

Goals can now be funded by investment accounts, not just depository.

- Relax linked_accounts_must_be_depository -> _must_be_fundable
  (depository || investment); the funding picker + counts include investment
  accounts.
- Add goals.progress_basis ('balance' | 'contributions', default 'balance').
  Investment-backed goals default to 'contributions' so a market swing doesn't
  move the goal: current_balance = value - cumulative market gain
  (Sum of balances.net_market_flows); depository accounts have zero
  net_market_flows, so they're unchanged. Goal#market_value_money shows what
  it's worth today next to the contributed figure on the show page.
- Pledge false-match guard: investment accounts never use manual_save /
  valuation-delta matching (a market move isn't a deposit) - they resolve on
  transfer (cash-inflow) entries only. Guarded in both
  Account#default_pledge_kind and GoalPledge#matches?.
- Add a `reopen` AASM event (completed -> active) + route/action/menu item so a
  manually-completed goal whose value later dips can be reopened.

Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests.

* fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update

- Contributions basis now goes through the same earmark/shared-pool logic as
  the balance basis: backing_balance_for -> backing_share_for(account, base),
  where base is the live balance (balance basis) or net contributions
  (contributions basis). Earmarks are respected and shared accounts no longer
  double-count on contributions goals; market_value_money stays consistent.
- Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject
  it on index like pooled_allocations, killing the N+1 for contributions goals.
- Default the basis on update too (not just create), so adding an investment
  account to an existing depository goal flips it to contributions instead of
  silently tracking market value.
- Fix the stale reconciliation_manager comment (renamed validation) and the
  orphaned zh-CN must_be_depository key.

* fix(goals): address review on #2491

- before_save (not before_validation) for the progress_basis default, so a goal
  can be inspected via valid? without its basis flipping as a side effect (jjmata).
- Pledge copy keys off default_pledge_kind, not manual?, so a manual investment
  account — which pledges via transfer — shows the transfer prompt instead of the
  "update your manual balance" flow (codex). pledge_action_label_key and the
  pledge modal's per-account helper flag both use it.
- Add the Phase 2 strings (reopen success/invalid_transition, show.reopen,
  ring.market_value) to zh-CN.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-30 07:26:23 +02:00

193 lines
6.7 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%.
#
# The amount being compared is the money that actually moved IN:
# - transfer pledges resolve against a Transaction inflow (Sure
# convention: inflow < 0), so the entry amount IS the contribution.
# - manual_save pledges resolve against a Valuation, whose amount is the
# account's full new TOTAL balance — not the contribution. The caller
# (Account::ReconciliationManager) passes the balance delta
# (new_balance prior_balance) via `valuation_delta`; that delta is
# the contribution we match against. Comparing the raw valuation amount
# would only ever match on an account whose entire balance equals the
# pledge (i.e. one starting from ~$0).
#
# Both kinds only fire on money coming in: transfers require an inflow
# entry, manual_save requires a positive balance delta. Without these
# guards, .abs below would let a $200 outflow / a $200 drawdown satisfy a
# $200 pledge as readily as a $200 deposit.
def matches?(entry, valuation_delta: nil)
return false unless status_open?
return false unless entry.account_id == account_id
is_valuation = entry.entryable.is_a?(Valuation)
if is_valuation
# Never match a valuation delta on an investment account: it may be a
# market move, not a deposit, and would false-match (investment goals).
return false if account&.investment?
return false if valuation_delta.nil? || valuation_delta.to_d <= 0
elsif kind_transfer? && !entry.amount.to_d.negative?
return false
end
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 = (is_valuation ? valuation_delta.to_d : 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