Files
sure/app/views/goals/show.html.erb
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

269 lines
14 KiB
Plaintext

<div class="space-y-4 pb-6 lg:pb-12">
<header class="flex items-start gap-3 sm:gap-4">
<div class="hidden sm:block">
<%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
</div>
<div class="min-w-0 flex-1">
<h1 class="text-2xl font-semibold text-primary break-words"><%= @goal.name %></h1>
<p class="text-sm text-secondary mt-1 privacy-sensitive"><%= @goal.header_summary %></p>
<% last_days = @goal.last_matched_pledge_days_ago %>
<% unless last_days.nil? %>
<p class="text-xs text-subdued mt-0.5">
<%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %>
</p>
<% end %>
</div>
<div class="shrink-0">
<%= render DS::Menu.new do |menu| %>
<%# Edit lives in the kebab, matching the rest of Sure (accounts,
categories, rules, family_merchants, chats, transactions all
put their Edit action inside the kebab dropdown). Keeps the
header to one primary action. %>
<% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_goal_path(@goal), data: { turbo_frame: :modal }) %>
<% if @goal.may_pause? %>
<% menu.with_item(variant: "button", text: t(".pause"), icon: "pause", href: pause_goal_path(@goal), method: :patch) %>
<% end %>
<% if @goal.may_resume? %>
<% menu.with_item(variant: "button", text: t(".resume"), icon: "play", href: resume_goal_path(@goal), method: :patch) %>
<% end %>
<% if @goal.may_complete? %>
<% complete_body = if @goal.progress_percent < 100
t(".confirm_complete_body_short",
progress: @goal.progress_percent,
saved: @goal.current_balance_money.format(precision: 0),
target: @goal.target_amount_money.format(precision: 0))
else
t(".confirm_complete_body")
end %>
<% menu.with_item(
variant: "button",
text: t(".complete"),
icon: "circle-check-big",
href: complete_goal_path(@goal),
method: :patch,
confirm: CustomConfirm.new(
title: t(".confirm_complete_title"),
body: complete_body,
btn_text: t(".confirm_complete_cta")
)
) %>
<% end %>
<% if @goal.may_archive? %>
<% menu.with_item(
variant: "button",
text: t(".archive"),
icon: "archive",
href: archive_goal_path(@goal),
method: :patch,
confirm: CustomConfirm.new(
title: t(".confirm_archive_title"),
body: t(".confirm_archive_body"),
btn_text: t(".confirm_archive_cta")
)
) %>
<% end %>
<% if @goal.may_unarchive? %>
<% menu.with_item(variant: "button", text: t(".unarchive"), icon: "archive-restore", href: unarchive_goal_path(@goal), method: :patch) %>
<% end %>
<% if @goal.may_reopen? %>
<% menu.with_item(variant: "button", text: t(".reopen"), icon: "rotate-ccw", href: reopen_goal_path(@goal), method: :patch) %>
<% end %>
<% if @goal.archived? %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: goal_path(@goal),
method: :delete,
destructive: true,
confirm: CustomConfirm.for_resource_deletion(@goal.name, high_severity: true)
) %>
<% end %>
<% end %>
</div>
</header>
<%= render "status_callout", goal: @goal %>
<% @open_pledges.each do |pledge| %>
<%= render "pending_pledge_banner", pledge: pledge %>
<% end %>
<% if @goal.paused? %>
<%= render DS::Alert.new(variant: "info", title: t("goals.show.paused_banner.title")) do %>
<p class="text-secondary"><%= t("goals.show.paused_banner.body") %></p>
<div class="mt-2">
<%= render DS::Button.new(
text: t("goals.show.paused_banner.resume_cta"),
href: resume_goal_path(@goal),
variant: "primary",
size: "sm",
method: :patch
) %>
</div>
<% end %>
<% elsif @goal.archived? %>
<%= render DS::Alert.new(variant: "info", title: t("goals.show.archived_banner.title")) do %>
<p class="text-secondary"><%= t("goals.show.archived_banner.body") %></p>
<% if @goal.may_unarchive? %>
<div class="mt-2">
<%= render DS::Button.new(
text: t("goals.show.archived_banner.restore_cta"),
href: unarchive_goal_path(@goal),
variant: "primary",
size: "sm",
method: :patch
) %>
</div>
<% end %>
<% end %>
<% end %>
<%# Top row: ring panel (status, no elevation) + projection chart card %>
<section class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-3">
<div class="rounded-xl p-5 flex flex-col items-center justify-center text-center">
<%= render Goals::ProgressRingComponent.new(goal: @goal, size: 180) %>
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @goal.current_balance_money.format(precision: 0) %></p>
<% if @goal.contributions_basis? %>
<p class="text-xs text-secondary tabular-nums mt-0.5 privacy-sensitive"><%= t(".ring.market_value", amount: @goal.market_value_money.format(precision: 0)) %></p>
<% end %>
<% unless @goal.completed? %>
<p class="text-xs text-subdued tabular-nums mt-0.5 privacy-sensitive"><%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %></p>
<% end %>
<% unless @goal.completed? || @goal.status == :reached || @goal.paused? || @goal.archived? %>
<%# Single Record pledge entry point on the page. Pre-filled with the
catch-up delta when behind so accepting once funds the gap. %>
<% prefill_amount = @goal.status == :behind && @goal.catch_up_delta_money.amount.positive? ? @goal.catch_up_delta_money.amount.to_f : nil %>
<div class="mt-4">
<%= render DS::Link.new(
text: t(".record_pledge_cta"),
variant: "primary",
size: "sm",
href: new_goal_pledge_path(@goal, amount: prefill_amount),
icon: "plus",
frame: :modal
) %>
</div>
<% end %>
</div>
<% if @goal.archived? || @goal.paused? %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
<%= icon(@goal.archived? ? "archive" : "pause", size: "2xl") %>
</div>
<h2 class="text-lg font-semibold text-primary">
<%= t(@goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
</h2>
<p class="text-sm text-secondary mt-1 max-w-md tabular-nums privacy-sensitive">
<%= t(".inactive.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %>
</p>
</div>
<% elsif @goal.completed? || @goal.status == :reached %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-success/10 inline-flex items-center justify-center text-success mb-3">
<%= icon("party-popper", size: "2xl", color: "success") %>
</div>
<h2 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h2>
<p class="text-sm text-secondary mt-1 max-w-md privacy-sensitive"><%= t(".celebration.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %></p>
<% if @goal.may_archive? %>
<div class="mt-4">
<%= render DS::Button.new(
text: t(".celebration.archive_cta"),
href: archive_goal_path(@goal),
variant: "outline",
size: "sm",
method: :patch
) %>
</div>
<% end %>
</div>
<% elsif @goal.current_balance.to_d.zero? && @goal.pace.to_d.zero? %>
<%# No movement yet on the linked account: render an inline "make your first transfer"
CTA card instead of a flat-at-$0 chart that looks broken. %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
<%= icon("piggy-bank", size: "2xl") %>
</div>
<h2 class="text-lg font-semibold text-primary"><%= t(".empty.heading") %></h2>
<p class="text-sm text-secondary mt-1 max-w-md"><%= t(".empty.body") %></p>
<div class="mt-4">
<%= render DS::Link.new(
text: t(".record_pledge_cta"),
variant: "primary",
size: "sm",
href: new_goal_pledge_path(@goal),
icon: "plus",
frame: :modal
) %>
</div>
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col">
<div class="flex flex-col gap-2 mb-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div class="min-w-0">
<h2 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h2>
<p class="text-xs text-secondary mt-0.5"><%= sanitize @goal.projection_summary %></p>
<% if @goal.status == :behind && @goal.monthly_target_amount %>
<p class="text-xs text-secondary mt-0.5 tabular-nums privacy-sensitive">
<%= t("goals.show.catch_up.body", avg: @goal.pace_money.format(precision: 0), required: Money.new(@goal.monthly_target_amount, @goal.currency).format(precision: 0)) %>
</p>
<% end %>
</div>
<% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
<div class="flex items-center flex-wrap gap-x-5 gap-y-1 text-[11px] text-secondary sm:gap-x-3 sm:shrink-0">
<span class="inline-flex items-center gap-1.5">
<svg width="18" height="6" class="text-primary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" /></svg>
<%= t(".projection.legend_saved") %>
</span>
<% unless @goal.target_date.nil? %>
<span class="inline-flex items-center gap-1.5">
<svg width="18" height="6"><line x1="0" y1="3" x2="18" y2="3" stroke="<%= projection_color %>" stroke-width="2" stroke-dasharray="3 3" /></svg>
<%= t(".projection.legend_projection") %>
</span>
<% if @goal.monthly_target_amount.to_d.positive? && @goal.remaining_amount.to_d.positive? %>
<span class="inline-flex items-center gap-1.5">
<svg width="18" height="6" class="text-secondary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" stroke-dasharray="2 4" opacity="0.5" /></svg>
<%= t(".projection.legend_required") %>
</span>
<% end %>
<% end %>
</div>
</div>
<div class="flex-1 min-h-[200px] privacy-sensitive"
data-controller="goal-projection-chart"
data-goal-projection-chart-data-value="<%= @goal.projection_payload.to_json %>"
data-goal-projection-chart-aria-label-value="<%= t("goals.show.projection.aria_label", name: @goal.name) %>"
data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>"
data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>"
data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>"
data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"
data-goal-projection-chart-target-relation-template-value="<%= t("goals.show.projection.tooltip_target_relation", percent: "{percent}", target: "{target}") %>"></div>
<% if @goal.target_date.nil? %>
<div class="mt-3 flex items-center gap-2 text-xs text-secondary">
<span class="text-subdued"><%= icon("calendar-plus", size: "sm") %></span>
<span class="flex-1"><%= t(".no_target_date.body") %></span>
<%= link_to t(".no_target_date.cta"),
edit_goal_path(@goal),
data: { turbo_frame: :modal },
class: "font-medium text-primary underline-offset-2 hover:underline" %>
</div>
<% end %>
</div>
<% end %>
</section>
<% if @goal.linked_accounts.any? %>
<section class="bg-container rounded-xl shadow-border-xs p-5">
<%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal) %>
</section>
<% end %>
<% if @goal.notes.present? %>
<section class="bg-container rounded-xl shadow-border-xs p-5">
<h2 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h2>
<p class="text-sm text-secondary whitespace-pre-line"><%= @goal.notes %></p>
</section>
<% end %>
</div>