Files
sure/app/models/goal.rb
Guillem Arias Fauste 9d3879a859 feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab

Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.

The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.

Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
  family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
  extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests

* fix(plan): address review feedback on the Plan hub

- Keep the "All goals" footer link rendered when the family has only
  completed/archived goals — the hub is a preview user's only route to
  the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
  (variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
  "right" — the initializer symbolizes it, so right-positioned icons
  never rendered on links (DS::Button already compared symbols);
  existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
  upper bound (CodeRabbit)

* refactor(plan): single source of truth for goal loading, sorting, and counts

Addresses jjmata's draft review notes:

- GoalsController#index now builds on the shared Goal loaders instead
  of hand-rolling its own copy: Goal.prepared_for (preloads + family-
  wide backing-math injection, scope-able) and Goal.active_display_sort
  carry the algorithm once; active_prepared_for composes them for the
  hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
  pausing stops the pace clock on purpose). Both the Plan hub summary
  and GoalsController#kpi_payload's behind/needs-this-month figures use
  it, so adjacent pages can't disagree. While there, the kpi on-track
  numerator also excludes paused goals — it was counted against a
  paused-excluding denominator, so the "X of Y" fraction could exceed
  its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
  instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
  controller: Budget#top_spending_categories(limit:) and
  Goal.summary_for(goals, currency:)

* fix(plan): move Edit budget/New goal into their own cards

Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).

Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.

* fix(plan): match established DS precedent for the card header actions

Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.

Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.

Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.

* fix(plan): lead the budget card header with status, not the edit action

On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.

* fix(plan): put the status pill on the left, next to the title

Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).

* fix(plan): lead Edit budget with its icon, matching same-shape precedent

Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.

* fix(plan): use the divider token for row separators, not border-primary

Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.

* fix(plan): lift the duplicated card shell into DS::Card

Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.

Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.

* fix(plan): batch pace queries before sorting goals

Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.

Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
2026-08-01 08:51:08 +02:00

750 lines
30 KiB
Ruby

class Goal < ApplicationRecord
include AASM, Monetizable
COLORS = Category::COLORS
ICONS = Category.icon_codes
validates :icon, inclusion: { in: ICONS, allow_nil: true }
validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true
belongs_to :family
# autosave so earmark (allocated_amount) edits on already-linked accounts
# persist through goal.save! — without it Rails only saves newly built
# children, silently dropping changes to existing goal_accounts.
has_many :goal_accounts, dependent: :destroy, autosave: true
has_many :linked_accounts, through: :goal_accounts, source: :account
has_many :goal_pledges, dependent: :destroy
has_many :open_pledges,
-> { where(status: "open").where("expires_at >= ?", Time.current) },
class_name: "GoalPledge"
validates :name, presence: true, length: { maximum: 255 }
validates :target_amount, presence: true, numericality: { greater_than: 0 }
validates :currency, presence: true
# before_save (not before_validation) so it only mutates on persistence, not
# on every valid? call — a goal can be inspected without its basis flipping.
before_save :default_progress_basis_for_investment
validate :must_have_at_least_one_linked_account
validate :linked_accounts_must_be_fundable
validate :linked_accounts_must_match_goal_currency
validate :linked_accounts_must_belong_to_family
validate :currency_locked_once_linked
monetize :target_amount
# Account types that can back a goal (see linked_accounts_must_be_fundable).
FUNDABLE_ACCOUNT_TYPES = %w[Depository Investment].freeze
# Display order for active (non-completed/non-archived) goals: behind
# first, then on-track, then open-ended. Paused sorts after all of these.
ACTIVE_DISPLAY_STATUS_RANK = { behind: 0, on_track: 1, no_target_date: 2 }.freeze
scope :alphabetically, -> { order(Arel.sql("LOWER(name) ASC")) }
scope :active_first, lambda {
order(Arel.sql("CASE state WHEN 'active' THEN 0 WHEN 'paused' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END"))
}
def self.advisory_lock_key_for(family_id)
Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63)
end
# Family-wide map of non-archived goal earmarks, grouped by account_id:
# { account_id => [{ goal_id:, allocated_amount: }, ...] }. The controller
# assigns this to each goal on index (goal.pooled_allocations = ...) so the
# shared-pool backing math runs ONE query for the whole page instead of one
# per goal.
def self.pooled_allocations_for(family)
GoalAccount.joins(:goal)
.where(goals: { family_id: family.id })
.where.not(goals: { state: "archived" })
.pluck(:account_id, :goal_id, :allocated_amount)
.group_by(&:first)
.transform_values do |triples|
triples.map { |(_, goal_id, amount)| { goal_id: goal_id, allocated_amount: amount } }
end
end
attr_writer :pooled_allocations
# Family-wide map of cumulative market gain/loss per account_id (sum of
# balances.net_market_flows). Injected on index alongside pooled_allocations
# so contributions-basis goals don't fire one Balance aggregate per account
# per goal (N+1).
def self.market_flows_for(family)
account_ids = GoalAccount.joins(:goal).where(goals: { family_id: family.id }).distinct.pluck(:account_id)
return {} if account_ids.empty?
Balance.where(account_id: account_ids).group(:account_id).sum(:net_market_flows)
end
attr_writer :market_flows
# Family-wide map of each linked account's net inflow over the trailing
# 90 days (account_id => net Entry#amount sum) — the same aggregate #pace
# computes per goal. Injected alongside pooled_allocations/market_flows so
# sorting/rendering N goals (active_display_sort calls goal.status, which
# reaches #pace for any goal with a target_date) fires one grouped query
# instead of one Entry.sum per goal.
def self.pace_for(family)
account_ids = GoalAccount.joins(:goal).where(goals: { family_id: family.id }).distinct.pluck(:account_id)
return {} if account_ids.empty?
Entry
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(account_id: account_ids, date: 90.days.ago.to_date..Date.current)
.where(excluded: false)
.merge(Transaction.excluding_pending)
.group(:account_id)
.sum(:amount)
end
attr_writer :pooled_pace
# Goals loaded ready to render: association preloads for the card/row
# partials plus the family-wide pooled-allocations + market-flows injection
# so the per-goal backing math doesn't fire a query per row (N+1). Pass a
# narrower scope to limit which of the family's goals are loaded.
def self.prepared_for(family, scope: family.goals)
goals = scope.alphabetically
.includes(:open_pledges, :goal_accounts, linked_accounts: :account_providers)
.to_a
inject_backing_math!(goals, family)
goals
end
# One family-wide earmark-pool + market-flows + pace read shared across
# every goal in the list (see pooled_allocations_for / market_flows_for /
# pace_for).
def self.inject_backing_math!(goals, family)
pooled = pooled_allocations_for(family)
flows = market_flows_for(family)
pace_map = pace_for(family)
goals.each do |goal|
goal.pooled_allocations = pooled
goal.market_flows = flows
goal.pooled_pace = pace_map
end
end
# Display order shared by the goals index and the Plan hub: behind first,
# then on-track, then open-ended, paused last, name as tie-breaker.
def self.active_display_sort(goals)
goals.sort_by { |goal| [ goal.paused? ? 3 : ACTIVE_DISPLAY_STATUS_RANK.fetch(goal.status, 4), goal.name.downcase ] }
end
# Active goals ready to render outside the goals index (e.g. the Plan hub).
def self.active_prepared_for(family)
active_display_sort(
prepared_for(family, scope: family.goals.where.not(state: %w[completed archived]))
)
end
# Aggregates for a summary card over an already-prepared goal list. Money
# sums only make sense in one currency, so goals denominated differently
# stay in the caller's row list but out of the totals.
def self.summary_for(goals, currency:)
summable = goals.select { |goal| goal.currency == currency }
targeted = summable.select { |goal| goal.target_amount.to_d.positive? }
{
saved_money: Money.new(summable.sum { |goal| goal.current_balance.to_d }, currency),
target_money: Money.new(targeted.sum { |goal| goal.target_amount.to_d }, currency),
behind_count: goals.count(&:behind_pace?),
pending_count: goals.sum { |goal| goal.open_pledges.size }
}
end
aasm column: :state do
after_all_transitions :reset_state_dependent_caches!
state :active, initial: true
state :paused
state :completed
state :archived
event :pause do
transitions from: :active, to: :paused
end
event :resume do
transitions from: :paused, to: :active
end
event :complete do
transitions from: [ :active, :paused ], to: :completed
end
event :archive do
transitions from: [ :active, :paused, :completed ], to: :archived
end
event :unarchive do
transitions from: :archived, to: :active
end
event :reopen do
transitions from: :completed, to: :active
end
end
# Balance is this goal's backing across its linked depository accounts that
# match the goal's currency. Each linked account contributes either its
# earmarked slice (goal_accounts.allocated_amount) or — when unallocated —
# the whole balance left after other goals' earmarks (see
# #backing_balance_for). The model validates the currency invariant at write
# time, but the defensive filter + telemetry here guards against drift from
# direct DB writes, account-currency edits outside goal validation, or
# future code that bypasses the validation chain.
def current_balance
@current_balance ||= begin
matching = linked_accounts.select { |a| a.currency == currency }
if matching.size != linked_accounts.size
Rails.logger.warn("Goal##{id} linked-account currency drift: #{linked_accounts.size - matching.size} of #{linked_accounts.size} mismatched (expected #{currency})")
Sentry.capture_message("Goal linked-account currency drift", level: :warning, extra: { goal_id: id, expected_currency: currency }) if defined?(Sentry)
end
matching.sum { |account| account_amount_for(account) }
end
end
def current_balance_money
@current_balance_money ||= Money.new(current_balance, currency)
end
# This goal's backing from a single linked account — the earmarked slice, or
# the whole-balance remainder when the link is unallocated — as Money. Used
# by the funding breakdown so the per-account rows reconcile with the ring.
def account_backing(account)
Money.new(account_amount_for(account), currency)
end
def contributions_basis?
progress_basis == "contributions"
end
# Market value of the goal's backing (balance basis), regardless of the
# progress basis — the "what it's worth today" figure shown next to
# contributions on an investment-backed goal.
def market_value_money
amount = linked_accounts.select { |a| a.currency == currency }.sum { |a| backing_share_for(a, a.balance.to_d) }
Money.new(amount, currency)
end
def remaining_amount
@remaining_amount ||= [ target_amount - current_balance, 0 ].max
end
def remaining_amount_money
@remaining_amount_money ||= Money.new(remaining_amount, currency)
end
def progress_percent
return @progress_percent if defined?(@progress_percent)
@progress_percent = if completed?
100
elsif target_amount.to_d.zero?
0
elsif remaining_amount.to_d.zero?
100
else
((current_balance.to_d / target_amount.to_d) * 100).floor.clamp(0, 99)
end
end
# Day-precision so the near-deadline cliff doesn't kick in: at
# calendar-month precision, May 30 → June 1 returned 1 ("save $5k this
# month") then June 1 → June 1 returned 0 (falls through to
# "remaining_amount in one month"). Now a 2-day-out deadline reports
# ~0.07 months and `monthly_target_amount` scales accordingly.
def months_remaining
return nil unless target_date
days = (target_date - Date.current).to_i
[ (days / 30.0), 0.0 ].max
end
def monthly_target_amount
return @monthly_target_amount if defined?(@monthly_target_amount)
@monthly_target_amount = if target_date.nil?
nil
elsif months_remaining.zero?
remaining_amount
else
(remaining_amount.to_d / months_remaining.to_d).ceil(2)
end
end
# 90-day rolling monthly pace: net inflow into linked accounts divided by
# three months. Transfers between linked accounts net to zero (both sides
# land inside this account set). Transfers from outside (e.g. checking
# into linked savings) net positive, which is the behaviour we want: the
# user records a pledge, the transfer arrives, balance goes up, pace
# goes up, status flips off "behind". Excludes user-flagged-excluded
# entries. Entry amount sign convention in Sure: inflow is negative.
#
# NOTE: pace is whole-account inflow by design in this phase, even for an
# earmarked goal whose current_balance is only a slice — so runway/status
# mix a whole-account numerator with an earmark-scoped balance. Earmark-aware
# pace is a deliberate follow-up; don't "fix" the basis without that work.
def pace
return @pace if defined?(@pace)
@pace = if linked_accounts.empty?
0
else
net = linked_accounts.sum { |account| pooled_pace.fetch(account.id, 0).to_d }
(-net.to_d / 3).round(2)
end
end
def pace_money
@pace_money ||= Money.new(pace, currency)
end
# Months of cash on hand at current pace (open-ended goals).
def months_of_runway
return nil if target_date.present?
return nil if pace.zero? || pace.negative?
(current_balance.to_d / pace.to_d).round(1)
end
def to_donut_segments_json
filled = current_balance.to_d
rem = remaining_amount.to_d
if filled.zero? && rem.zero?
return [ { color: "var(--budget-unused-fill)", amount: 1, id: "unused" } ]
end
segments = []
segments << { color: color.presence || "var(--color-blue-500)", amount: filled, id: "saved" } if filled.positive?
segments << { color: "var(--budget-unused-fill)", amount: rem, id: "unused" } if rem.positive?
segments
end
# 90-day balance trajectory of linked accounts. Used by the projection chart
# to render the saved-to-date line. Returns an empty series when the linked
# account lacks ≥30 days of history. Ships pre-formatted labels for the
# static chart annotations (target line, projection-end / shortfall,
# pending-pledge badge) so the Stimulus controller only has to render
# strings server-side rather than build them with its own Intl calls.
def projection_payload
series_values = balance_series_values
# The historical series tracks the whole linked-account balances. Scale it
# to this goal's backing so the saved line meets current_balance at "today"
# instead of dropping off a cliff for earmarked goals. Assumes the earmark
# ratio held over the window (an approximation); exact for unallocated
# goals, where ratio == 1 and the series is unchanged.
whole_total = linked_accounts.select { |a| a.currency == currency }.sum { |a| a.balance.to_d }
# 0 when the linked-account total is non-positive: current_balance is forced
# to 0 there, so the saved series must end at 0 too (no stray non-zero tail).
backing_ratio = whole_total.positive? ? (current_balance.to_d / whole_total) : 0.to_d
saved_series = series_values.map { |v| { date: v.date.to_s, value: (v.value.amount.to_d * backing_ratio).to_f } }
earliest = series_values.first&.date || created_at.to_date
target_amt = target_amount.to_d
proj_end = projection_end_amount
{
saved_series: saved_series,
start_date: earliest.to_s,
today: Date.current.to_s,
target_date: target_date&.to_s,
target_amount: target_amt.to_f,
target_amount_label: Money.new(target_amt, currency).format(precision: 0),
target_amount_short_label: short_money(target_amt, currency),
currency_symbol: Money.new(0, currency).currency.symbol,
current_amount: current_balance.to_f,
avg_monthly: pace.to_f,
required_monthly: monthly_target_amount&.to_f,
currency: currency,
status: status.to_s,
projection_end_value: proj_end.to_f,
projection_end_label: Money.new(proj_end, currency).format(precision: 0),
projection_shortfall_label: (target_amt > proj_end ? Money.new(target_amt - proj_end, currency).format(precision: 0) : nil)
}
end
# Projected balance at the target_date given the current pace. Mirrors
# the JS calculation so the server can pre-format the chart annotation
# without re-rendering after each Stimulus draw.
def projection_end_amount
return current_balance.to_d if target_date.nil?
months = ((target_date - Date.current).to_f / 30.44).clamp(0.0, Float::INFINITY)
projected = current_balance.to_d + (pace.to_d * months)
[ current_balance.to_d, projected ].max
end
def display_status
return @display_status if defined?(@display_status)
@display_status = if archived?
:archived
elsif paused?
:paused
elsif completed?
:completed
else
status
end
end
# :reached → completed, or no remaining amount
# :on_track → has target_date and pace >= required monthly
# :behind → has target_date and pace < required monthly
# :no_target_date → open-ended
def status
return @status if defined?(@status)
@status = if completed? || remaining_amount.to_d.zero?
:reached
elsif target_date.nil?
:no_target_date
elsif monthly_target_amount.to_d <= pace.to_d
:on_track
else
:behind
end
end
# The single definition of "behind pace" for counts and copy. Paused goals
# are excluded even when their raw status computes :behind — pausing stops
# the pace clock on purpose, so surfacing them as behind (or summing them
# into "needs this month") would nag the user about a goal they shelved.
def behind_pace?
!paused? && status == :behind
end
# Date of the most-recently-matched pledge's underlying entry. Used by the
# show header to display "Last saved N days ago". Anchoring on the entry's
# date keeps the readout stable under sync re-runs (which would bump
# pledge#updated_at). Returns nil if no pledge has resolved yet.
def last_matched_pledge_at
return @last_matched_pledge_at if defined?(@last_matched_pledge_at)
@last_matched_pledge_at = Entry
.where(entryable_type: "Transaction")
.joins("INNER JOIN goal_pledges ON goal_pledges.matched_transaction_id = entries.entryable_id")
.where(goal_pledges: { goal_id: id, status: "matched" })
.maximum(:date)
end
def last_matched_pledge_days_ago
last = last_matched_pledge_at
return nil if last.nil?
(Date.current - last).to_i
end
# True when any linked account is wired to a live sync provider (Plaid,
# SimpleFIN, or any AccountProvider. Brex, Enable Banking, IBKR, Kraken,
# SnapTrade, Lunchflow). Drives the pledge-create copy: connected accounts
# get the "I just transferred…" path; manual-only accounts get "I just
# saved…" so users aren't told to wait for a sync that won't happen.
def any_connected_account?
linked_accounts.any? { |a| !a.manual? }
end
# "I just transferred" when any linked account resolves pledges via a transfer
# (synced accounts AND investment accounts, per default_pledge_kind); "I just
# saved" only for manual cash accounts. Keyed off default_pledge_kind so the
# copy matches the kind actually saved — a manual brokerage uses transfer, not
# manual_save, so it must not show the "update your manual balance" path.
def pledge_action_label_key
pledges_use_transfer? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved"
end
def pledges_use_transfer?
linked_accounts.any? { |a| a.default_pledge_kind == "transfer" }
end
# { account_id => palette_hex } for this goal's linked accounts. Stable
# within a goal (so the preview-card avatar stack on the index and the
# funding-widget rows + distribution bar on the show page agree on which
# color belongs to which account) and collision-free up to PALETTE size
# (10 colors). Sort by id so the assignment doesn't shuffle when the
# accounts are re-loaded in a different order.
def account_color_map
@account_color_map ||= begin
palette = Goals::AvatarComponent::PALETTE
linked_accounts.sort_by(&:id).each_with_index.to_h do |account, i|
[ account.id, palette[i % palette.size] ]
end
end
end
# Single-line state summary rendered between the header and the ring on
# the show page. Replaces the stacked catch-up alert + inline status pill;
# carries the same actionable copy without owning a CTA. Returns nil when
# the projection-side cards already convey state (paused / archived /
# completed / reached) so the callout doesn't double up.
def status_callout_context
return nil if paused? || archived? || completed? || status == :reached
case status
when :behind
delta = catch_up_delta_money.amount
if delta.positive?
I18n.t("goals.show.status_callout.behind",
amount: catch_up_delta_money.format(precision: 0))
else
I18n.t("goals.show.status_callout.behind_covered")
end
when :on_track
if target_date && pace.to_d.positive?
months = (remaining_amount.to_d / pace.to_d).ceil
I18n.t("goals.show.status_callout.on_track",
date: I18n.l(Date.current >> months.to_i, format: "%b %Y"))
end
when :no_target_date
I18n.t("goals.show.status_callout.no_target_date")
end
end
# Header copy under the goal title on show. Used to live as a multi-line
# if/elsif block in show.html.erb. Keeps the view template free of date
# math + i18n key picking.
def header_summary
parts = []
if target_date
days = (target_date - Date.current).to_i
past_due = days < 0 && !(completed? || status == :reached)
if past_due
parts << I18n.t("goals.show.header.target_by_past",
amount: target_amount_money.format(precision: 0),
date: I18n.l(target_date, format: :long))
else
parts << I18n.t("goals.show.header.target_by",
amount: target_amount_money.format(precision: 0),
date: I18n.l(target_date, format: :long))
if days > 0 && !(completed? || status == :reached)
parts << I18n.t("goals.goal_card.days_left", count: days)
end
end
else
parts << I18n.t("goals.show.header.target",
amount: target_amount_money.format(precision: 0))
end
parts.join(" · ")
end
# Single source of truth for the projection-chart subtitle / chart-aria
# description. Used to live inline in show.html.erb as a 17-line if/elsif
# chain. Returns an `html_safe` string when it picks the `_html` variant.
def projection_summary
return @projection_summary if defined?(@projection_summary)
@projection_summary =
if completed? || progress_percent >= 100
I18n.t("goals.show.projection.reached")
elsif target_date.nil?
I18n.t("goals.show.projection.no_target_date")
elsif monthly_target_amount && pace.to_d < monthly_target_amount.to_d
I18n.t("goals.show.projection.behind")
elsif pace.positive?
months = (remaining_amount.to_d / pace.to_d).ceil
I18n.t(
"goals.show.projection.on_track_html",
date: I18n.l(Date.current >> months.to_i, format: "%b %Y")
)
else
I18n.t("goals.show.projection.no_pace")
end
end
# Monthly extra needed beyond the current pace + currently-open pledges
# to hit the target on time. Pending pledges are approximate (one-off
# amounts treated as this-month inflow) but excluding them produced the
# bad case where the alert demanded $X/mo while the user had already
# pledged $X, telling them to act on top of the action they just took.
# Clamps at zero so a fully-covered goal doesn't surface a $0 demand.
def catch_up_delta_money
return Money.new(0, currency) if monthly_target_amount.nil?
pending = open_pledges.sum(:amount).to_d
delta = [ monthly_target_amount.to_d - pace.to_d - pending, 0 ].max
Money.new(delta, currency)
end
private
# This goal's amount from one linked account under the active progress
# basis: net contributions (market-gain-excluded, floored at 0) on the
# contributions basis, or the allocation-aware backing balance otherwise.
def account_amount_for(account)
base = contributions_basis? ? net_contributed_for(account) : account.balance.to_d
backing_share_for(account, base)
end
# Net contributions into `account` to date = current value minus cumulative
# market gain/loss (sum of balances.net_market_flows), floored at 0.
# Depository accounts have zero net_market_flows, so this equals their
# balance. The per-account base on the contributions basis.
def net_contributed_for(account)
market_gain = (market_flows[account.id] || 0).to_d
[ account.balance.to_d - market_gain, 0.to_d ].max
end
# This goal's share of one linked account given a per-account `base` amount
# (the live balance on the balance basis, net contributions on the
# contributions basis). Shared-pool semantics are the same either way: the
# goal's OWN earmark is read from its own goal_accounts (reliable even for
# an archived goal, which is excluded from the pool); OTHER non-archived
# goals' fixed earmarks come from the shared pool. A fixed earmark takes its
# slice; an unallocated link takes the remainder after others' fixed
# earmarks. When fixed earmarks exceed the base they're scaled down pro-rata
# (to within sub-cent rounding) so shares never sum past it — no
# double-counting. A non-positive base backs nothing.
def backing_share_for(account, base)
base = base.to_d
return 0.to_d if base <= 0
mine = own_allocation_for(account)
others_fixed = (pooled_allocations[account.id] || [])
.reject { |r| r[:goal_id] == id }
.sum { |r| r[:allocated_amount].to_d }
if mine
total_fixed = others_fixed + mine
if total_fixed > base && total_fixed.positive?
(mine * (base / total_fixed)).round(4) # pro-rata haircut
else
mine
end
else
[ base - others_fixed, 0 ].max # unallocated link: the remainder
end
end
# This goal's own earmark on `account` (a BigDecimal, or nil for a
# whole-balance link). Read from the loaded goal_accounts association so it
# is correct even for archived goals, which are excluded from the pool.
def own_allocation_for(account)
goal_accounts.find { |ga| ga.account_id == account.id }&.allocated_amount
end
# Family-wide map of non-archived goal earmarks. Injected once per request
# by the controller on index (one query for the whole page); falls back to
# a single query for the standalone (show) case.
def pooled_allocations
@pooled_allocations ||= self.class.pooled_allocations_for(family)
end
def market_flows
@market_flows ||= self.class.market_flows_for(family)
end
def pooled_pace
@pooled_pace ||= self.class.pace_for(family)
end
# Cleared after every AASM transition. The state column drives the
# display_status / projection_summary memos; without this the same
# instance keeps returning the pre-transition value if a controller
# calls archive! / pause! and then renders without reload.
def reset_state_dependent_caches!
# current_balance now depends on the goal's own archived state (an
# archived goal is excluded from the shared pool), so the balance-derived
# memos must be cleared on a transition too, not just the status memos.
%i[
@display_status @projection_summary
@current_balance @current_balance_money
@remaining_amount @remaining_amount_money
@progress_percent @monthly_target_amount
@pace @pace_money @status @pooled_allocations
].each do |ivar|
remove_instance_variable(ivar) if instance_variable_defined?(ivar)
end
end
# K/M shorthand for narrow chart annotations (axis ticks, projection
# short-form, pending-pledge badge). Locale-aware currency symbol via
# Money so the chart matches the rest of the app for EUR/GBP families.
def short_money(amount, code)
amount_f = amount.to_f
symbol = Money.new(0, code).currency.symbol
abs = amount_f.abs
if abs >= 1_000_000
short = (amount_f / 1_000_000.0).round(1)
"#{symbol}#{short == short.to_i ? short.to_i : short}M"
elsif abs >= 1_000
short = (amount_f / 1_000.0).round(1)
"#{symbol}#{short == short.to_i ? short.to_i : short}K"
else
"#{symbol}#{amount_f.round.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse}"
end
end
def balance_series_values
return [] if linked_accounts.empty?
Balance::ChartSeriesBuilder.new(
account_ids: linked_accounts.map(&:id),
currency: currency,
period: Period.last_90_days
).balance_series.values
rescue StandardError => e
# Degrade gracefully (chart drops to target-line-only) but surface
# the failure; silent fallbacks here masked real Builder bugs.
Rails.logger.error("Goal##{id} balance series failed: #{e.class}: #{e.message}")
Sentry.capture_exception(e) if defined?(Sentry)
[]
end
def must_have_at_least_one_linked_account
return unless goal_accounts.reject(&:marked_for_destruction?).empty?
errors.add(:base, :at_least_one_linked_account_required)
end
def linked_accounts_must_be_fundable
offending = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account&.depository? || sga.account&.investment?
end
return if offending.empty?
errors.add(:linked_accounts, :must_be_fundable)
end
# Goals funded by an investment account default to the contributions basis
# (so a market swing doesn't move them); depository-only goals stay on the
# balance basis. Only auto-set when the basis is still the default.
def default_progress_basis_for_investment
return unless goal_accounts.any? { |ga| ga.account&.investment? }
return unless progress_basis.blank? || progress_basis == "balance"
self.progress_basis = "contributions"
end
def linked_accounts_must_match_goal_currency
return if currency.blank?
mismatched = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.currency == currency
end
return if mismatched.empty?
errors.add(:linked_accounts, :currency_mismatch)
end
def linked_accounts_must_belong_to_family
return if family.nil?
foreign = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.family_id == family_id
end
return if foreign.empty?
errors.add(:linked_accounts, :must_belong_to_family)
end
def currency_locked_once_linked
return unless persisted? && currency_changed?
return unless goal_accounts.where.not(id: nil).exists?
errors.add(:currency, :locked_after_linked)
end
end