mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 13:21:17 +00:00
* fix(goals): allow deleting a goal without archiving it first
Goals could only be deleted after being archived. `GoalsController#destroy`
redirected with "Archive the goal before deleting it." unless the goal was
already archived, and the Delete item in the show-page kebab was wrapped in
`if @goal.archived?`. Nothing in the archive confirm copy hinted that
archiving was the prerequisite, so in practice an active goal had no delete
affordance anywhere in the UI.
The gate bought no safety. Destroying a goal cascades only to its own
`goal_accounts` and `goal_pledges`, and `GoalPledge#clear_matched_transaction_extra`
unstamps `extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is touched. Every other
resource in Sure (accounts, categories, rules, family merchants) deletes in
one step.
Drop the gate, render Delete unconditionally, and shorten the label from
"Delete permanently" to "Delete" now that it no longer needs to contrast
with an archive-first step.
The confirm copy moves to `Goal#deletion_confirm` and spells out what
survives. The generic `CustomConfirm.for_resource_deletion` only says "This
is not reversible", which overstates it for a goal.
Index cards deliberately keep no actions — the card stays a single click
target, and the show-page kebab is one click away.
* fix(goals): escape the goal name in the delete confirmation
`confirm_dialog_controller` assigns the confirm `body` to `innerHTML` — bodies
such as the accounts' `confirm_body_html` legitimately carry markup — so a goal
named "<img src=x onerror=…>" ran as soon as a family member opened the delete
confirmation. Verified in a browser: parsing the rendered `data-turbo-confirm`
and assigning its body produced a live `<img>` element with a working `onerror`
handler.
Escape the interpolated name. Only `body` needs it; the dialog sets its title
and button label with `textContent`.
`CustomConfirm.for_resource_deletion` interpolates a record name into the same
HTML-rendered body and was already reachable from accounts, categories, rules
and family merchants, so it is escaped here too rather than left as a known
hole next to the fixed one.
Also add the three `confirm_delete_*` keys to every locale that ships goal
translations. Fallbacks meant these silently rendered English rather than
breaking, so this is untranslated copy rather than a fault — ru is included,
which the review list omitted.
* i18n(confirm): move the resource-deletion copy to locale keys
`for_resource_deletion` built its title, body and button label as English
string interpolation, against the project's rule that user-facing strings go
through `t()`. It backs ~39 call sites — accounts, rules, tags, chats, every
provider item — so all of them were English-only.
Moved to `shared.custom_confirm.resource_deletion_*`, alongside the
`default_*` keys the same class already used.
`titleize` / `downcase` stay applied to the record name so the English output
is byte-identical to what the hardcoded strings produced; a locale needing
different casing can absorb it in its own string. Pinned by a test, along with
the escaping of the one field the dialog renders as HTML.
* i18n(confirm): translate the resource-deletion copy
The keys added when this copy moved out of hardcoded English only landed in
en.yml, leaving ~40 call sites falling back to English in every other locale.
Added to the eight other shared locale files that already carry the sibling
`custom_confirm.default_*` strings: ca, fr, hu, it, ru, tr, vi, zh-CN. Each
body reuses that locale's own "this is not reversible" sentence, so the
generic and resource-specific confirmations read the same, and each follows
the register its `default_title` already set (vous / siz / Вы, tu for ca).
The remaining shared locale files (de, es, nb, nl, pl, pt-BR, ro, zh-TW) have
no `custom_confirm` block at all, so they are left alone — adding one would
invent structure they have not adopted, and fallbacks already cover them. The
test derives its locale list from which files define the sibling key rather
than hardcoding it, so it follows that set as it grows.
* test(goals): restore the active-goal destroy test lost in the merge
Merging main into this branch hit a conflict in
`test/controllers/goals_controller_test.rb`: main had added two tests
immediately above the destroy block, and the resolution took main's side
wholesale for that hunk. That resurrected `destroy on non-archived is
rejected` — the test this PR replaces — and dropped its replacement.
The resurrected test failed against the new controller, since destroy no
longer gates on `archived?`:
GoalsControllerTest#test_destroy_on_non-archived_is_rejected
`Goal.count` didn't change by 0, but by -1.
Swap it back for `destroy deletes an active goal and cascades to its
links and pledges`. Main's two new tests stay.
* i18n(goals): finish the delete copy in de and zh-TW
Nine locales ship goals translations, not seven. `de.yml` and `zh-TW.yml`
were left behind: both still carried the dead `goals.destroy.archive_first`
key, still labelled the kebab item "Delete permanently" (Endgültig löschen
/ 永久刪除) after it was shortened elsewhere, and had none of the
`confirm_delete_*` keys, so a German or Traditional Chinese family saw the
new delete dialog in English.
Add the three confirm keys using each file's existing vocabulary — Zusagen
for pledges in German (informal du, matching the rest of the file), 投入 in
Traditional Chinese — drop `archive_first`, and shorten the label.
`confirm_delete copy resolves in every locale that ships goal translations`
could not have caught this. It hardcoded the seven locales, and its
assertions went through plain `I18n.t`: the backend has
I18n::Backend::Fallbacks mixed in, so a missing German key resolved to the
English string and `.present?` passed anyway. Verified — deleting
`confirm_delete_title` from `de.yml` left the test green.
Derive the locale list from the goals YAMLs and look the keys up with
`fallback: false, default: nil`. The same deletion now fails with
"de is missing goals.show.confirm_delete_title".
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
328 lines
13 KiB
Ruby
328 lines
13 KiB
Ruby
class GoalsController < ApplicationController
|
||
before_action :require_preview_features!
|
||
before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive reopen]
|
||
|
||
FUNDABLE_TYPES = Goal::FUNDABLE_ACCOUNT_TYPES
|
||
rescue_from ActiveRecord::RecordNotFound, with: :goal_not_found
|
||
|
||
STATE_FILTERS = %w[all active paused completed archived].freeze
|
||
|
||
def index
|
||
state_counts = Current.family.goals.group(:state).count
|
||
@counts = STATE_FILTERS.each_with_object({}) do |state, h|
|
||
h[state] = state == "all" ? state_counts.values.sum : (state_counts[state] || 0)
|
||
end
|
||
|
||
# Preloads + the family-wide backing-math injection (N+1 guard) live in
|
||
# Goal.prepared_for, shared with the Plan hub's active_prepared_for.
|
||
all_goals = Goal.prepared_for(Current.family)
|
||
@active_goals = Goal.active_display_sort(all_goals.reject { |g| %w[completed archived].include?(g.state) })
|
||
@completed_goals = all_goals.select { |g| g.state == "completed" }.sort_by { |g| g.name.downcase }
|
||
@archived_goals = all_goals.select { |g| g.state == "archived" }
|
||
# Completed goals join the chip-filterable grid below the active ones
|
||
# so the `completed` chip can isolate them. Archived stays in a
|
||
# separate collapsed-by-default section, opted out of the filter
|
||
# entirely (rendered with filterable: false).
|
||
@grid_goals = @active_goals + @completed_goals
|
||
|
||
@linkable_account_count = Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.count
|
||
@kpi = kpi_payload(@active_goals)
|
||
@any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? }
|
||
@show_search = @grid_goals.size > 6
|
||
@breadcrumbs = plan_breadcrumb_prefix + [ [ t("goals.index.title"), nil ] ]
|
||
end
|
||
|
||
def show
|
||
@open_pledges = @goal.open_pledges.reverse_chronological.to_a
|
||
@breadcrumbs = plan_breadcrumb_prefix + [
|
||
[ t("goals.index.title"), goals_path ],
|
||
[ @goal.name, nil ]
|
||
]
|
||
end
|
||
|
||
def new
|
||
@goal = Current.family.goals.new(
|
||
color: Goal::COLORS.sample,
|
||
currency: Current.family.primary_currency_code
|
||
)
|
||
@linkable_accounts = linkable_accounts_for_new
|
||
@breadcrumbs = plan_breadcrumb_prefix + [
|
||
[ t("goals.index.title"), goals_path ],
|
||
[ t("goals.new.heading"), nil ]
|
||
]
|
||
end
|
||
|
||
def create
|
||
@goal = Current.family.goals.new(goal_params)
|
||
accounts = lookup_accounts(params.dig(:goal, :account_ids))
|
||
@goal.currency = (accounts.first&.currency || Current.family.primary_currency_code) if @goal.currency.blank?
|
||
|
||
allocations = submitted_allocations
|
||
Goal.transaction do
|
||
accounts.each { |a| @goal.goal_accounts.build(account: a, allocated_amount: allocations[a.id.to_s]) }
|
||
@goal.save!
|
||
end
|
||
|
||
flash[:notice] = t(".success")
|
||
respond_to do |format|
|
||
format.html { redirect_to goal_path(@goal) }
|
||
format.turbo_stream do
|
||
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
|
||
end
|
||
end
|
||
rescue ActiveRecord::RecordInvalid
|
||
@linkable_accounts = linkable_accounts_for_new
|
||
render :new, status: :unprocessable_entity
|
||
end
|
||
|
||
def edit
|
||
@linkable_accounts = linkable_accounts_for_new
|
||
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
|
||
end
|
||
|
||
def update
|
||
account_ids = params.dig(:goal, :account_ids)
|
||
accounts_supplied = !account_ids.nil?
|
||
accounts = accounts_supplied ? lookup_accounts(account_ids) : []
|
||
|
||
if accounts_supplied && accounts.empty?
|
||
@goal.errors.add(:base, :at_least_one_linked_account_required)
|
||
@linkable_accounts = linkable_accounts_for_new
|
||
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
|
||
render :edit, status: :unprocessable_entity
|
||
return
|
||
end
|
||
|
||
# Assign first, sync the links, then persist once. Saving the attributes
|
||
# up front ran `must_have_at_least_one_linked_account` against the goal's
|
||
# OLD links, so a goal orphaned by account deletion (Account has_many
|
||
# :goal_accounts, dependent: :destroy) could never be edited back to
|
||
# health: the save raised before the submitted accounts were attached.
|
||
# One save over the fully-assembled goal validates what the user actually
|
||
# submitted.
|
||
Goal.transaction do
|
||
@goal.assign_attributes(goal_update_params)
|
||
if accounts_supplied
|
||
sync_linked_accounts!(@goal, accounts, submitted_allocations)
|
||
else
|
||
@goal.save!
|
||
end
|
||
end
|
||
|
||
flash[:notice] = t(".success")
|
||
respond_to do |format|
|
||
format.html { redirect_to goal_path(@goal) }
|
||
format.turbo_stream do
|
||
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
|
||
end
|
||
end
|
||
rescue ActiveRecord::RecordInvalid
|
||
@linkable_accounts = linkable_accounts_for_new
|
||
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
|
||
render :edit, status: :unprocessable_entity
|
||
end
|
||
|
||
# Deletable from any state. Destroying a goal cascades only to its own
|
||
# goal_accounts / goal_pledges — and GoalPledge#clear_matched_transaction_extra
|
||
# unstamps the pledge id it wrote onto a matched transaction. No account,
|
||
# balance, entry or transaction is removed, so the archive-first gate this
|
||
# used to enforce bought no safety; it only hid the action behind a two-step
|
||
# flow no other Sure resource requires.
|
||
def destroy
|
||
@goal.destroy!
|
||
redirect_to goals_path, notice: t(".success")
|
||
end
|
||
|
||
def pause
|
||
perform_transition!(:pause)
|
||
end
|
||
|
||
def resume
|
||
perform_transition!(:resume)
|
||
end
|
||
|
||
def complete
|
||
perform_transition!(:complete)
|
||
end
|
||
|
||
def archive
|
||
perform_transition!(:archive)
|
||
end
|
||
|
||
def unarchive
|
||
perform_transition!(:unarchive)
|
||
end
|
||
|
||
def reopen
|
||
perform_transition!(:reopen)
|
||
end
|
||
|
||
private
|
||
def set_goal
|
||
@goal = Current.family.goals
|
||
.includes(:open_pledges, linked_accounts: :account_providers)
|
||
.find(params[:id])
|
||
end
|
||
|
||
def goal_not_found
|
||
redirect_to goals_path, alert: t("goals.errors.not_found")
|
||
end
|
||
|
||
def goal_params
|
||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes)
|
||
end
|
||
|
||
def goal_update_params
|
||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes)
|
||
end
|
||
|
||
def lookup_accounts(ids)
|
||
return [] if ids.blank?
|
||
|
||
ids = Array(ids).reject(&:blank?)
|
||
Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.where(id: ids).to_a
|
||
end
|
||
|
||
def linkable_accounts_for_new
|
||
Current.user.accessible_accounts.where(accountable_type: FUNDABLE_TYPES).visible.alphabetically.to_a
|
||
end
|
||
|
||
def sync_linked_accounts!(goal, accounts, allocations = {})
|
||
desired_ids = accounts.map(&:id).to_set
|
||
current_ids = goal.goal_accounts.pluck(:account_id).to_set
|
||
|
||
# Only unlink accounts the current user can actually see in the picker.
|
||
# A family goal may be linked to another member's private account, which
|
||
# never renders as a checkbox — so its absence from the submitted set is
|
||
# not an intentional removal and must not destroy the link.
|
||
removable_ids = Current.user.accessible_accounts.where(id: current_ids.to_a).pluck(:id).to_set
|
||
|
||
((current_ids & removable_ids) - desired_ids).each do |id|
|
||
goal.goal_accounts.where(account_id: id).destroy_all
|
||
end
|
||
goal.goal_accounts.reload
|
||
|
||
# Add new links and refresh the earmark on kept links. Only touch the
|
||
# allocation when the form actually submitted a value for that account
|
||
# (allocations.key?), so a caller that omits the hash leaves earmarks
|
||
# untouched. Save through the goal so currency / depository / family
|
||
# validations fire — create! on goal_accounts bypasses them.
|
||
accounts.each do |account|
|
||
existing = goal.goal_accounts.find { |ga| ga.account_id == account.id }
|
||
if existing
|
||
existing.allocated_amount = allocations[account.id.to_s] if allocations.key?(account.id.to_s)
|
||
else
|
||
goal.goal_accounts.build(account: account, allocated_amount: allocations[account.id.to_s])
|
||
end
|
||
end
|
||
goal.save!
|
||
end
|
||
|
||
# { account_id_string => amount_string_or_nil } from goal[allocations].
|
||
# A blank amount means "dedicate the whole balance" (NULL allocated_amount).
|
||
def submitted_allocations
|
||
raw = params.dig(:goal, :allocations)
|
||
return {} if raw.blank?
|
||
|
||
hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
||
hash.each_with_object({}) do |(account_id, amount), memo|
|
||
memo[account_id.to_s] = amount.to_s.strip.presence
|
||
end
|
||
end
|
||
|
||
def kpi_payload(active_goals)
|
||
family = Current.family
|
||
currency = family.primary_currency_code
|
||
today = Date.current
|
||
|
||
windows = family.savings_inflow_windows(window_days: 30, now: today)
|
||
velocity_30d = windows[:current]
|
||
velocity_prior_30d = windows[:prior]
|
||
delta_amount = velocity_30d - velocity_prior_30d
|
||
delta_percent = velocity_prior_30d.zero? ? nil : ((delta_amount / velocity_prior_30d.abs) * 100).round(1)
|
||
|
||
# Sign decoupling: the headline-amount sign reflects this month's
|
||
# direction ("−$200 last 30d" = net outflow); the delta direction
|
||
# (↑/↓ vs prior 30d) goes on the subline. Conflating them produced the
|
||
# "−$1234" + "↓ 27%" tile where the minus looked like a loss but the
|
||
# $1234 was actually the (positive) amount contributed.
|
||
headline_sign = velocity_30d.negative? ? "−" : ""
|
||
delta_direction = if delta_amount.positive? then :up
|
||
elsif delta_amount.negative? then :down
|
||
else :flat
|
||
end
|
||
|
||
# behind_pace? excludes paused goals — pausing stops the pace clock,
|
||
# so a paused-but-behind goal belongs in the paused count (below),
|
||
# not in "N behind" or the "needs this month" sum. Keeps this tile
|
||
# consistent with the Plan hub's summary (Goal.summary_for).
|
||
needs = active_goals
|
||
.select(&:behind_pace?)
|
||
.sum { |g| g.monthly_target_amount.to_d }
|
||
behind = active_goals.count(&:behind_pace?)
|
||
# Paused goals are excluded from the on-track numerator for the same
|
||
# reason they're excluded from tracked_total below — otherwise the
|
||
# "X of Y" fraction could exceed its own denominator.
|
||
on_track = active_goals.count { |g| !g.paused? && g.status == :on_track }
|
||
reached = active_goals.count { |g| g.status == :reached }
|
||
no_date = active_goals.count { |g| g.status == :no_target_date }
|
||
paused = active_goals.count(&:paused?)
|
||
|
||
# Denominator of the "Goals on track" tile. A goal only belongs in
|
||
# the fraction if there is a benchmark to compare against:
|
||
# - reached → target already hit, no longer tracked toward pace
|
||
# - paused → user stopped the pace clock on purpose
|
||
# - no_target_date → open-ended saving (emergency fund, sabbatical
|
||
# fund, etc.) has no required monthly pace, so "on track" is
|
||
# undefined. Counting it would penalise the user for having
|
||
# open-ended goals — they'd never improve the ratio.
|
||
# When this hits zero the tile swaps to a celebration / empty
|
||
# state in the view.
|
||
tracked_total = active_goals.count do |g|
|
||
!g.paused? && g.status != :reached && g.status != :no_target_date
|
||
end
|
||
|
||
{
|
||
currency: currency,
|
||
velocity_30d_money: Money.new(velocity_30d.abs, currency),
|
||
velocity_prior_30d_money: Money.new(velocity_prior_30d.abs, currency),
|
||
velocity_30d_sign: headline_sign,
|
||
velocity_delta_percent: delta_percent,
|
||
velocity_direction: delta_direction,
|
||
needs_this_month_money: Money.new(needs, currency),
|
||
on_track_count: on_track,
|
||
reached_count: reached,
|
||
behind_count: behind,
|
||
no_date_count: no_date,
|
||
paused_count: paused,
|
||
tracked_total: tracked_total,
|
||
active_total: active_goals.size
|
||
}
|
||
end
|
||
|
||
def perform_transition!(event)
|
||
unless @goal.aasm.may_fire_event?(event)
|
||
redirect_to goal_path(@goal), alert: t(".invalid_transition")
|
||
return
|
||
end
|
||
|
||
# AASM's bang event returns false — it does NOT raise — when the save
|
||
# that persists the new state fails validation. The return value used to
|
||
# be discarded, so an invalid goal (e.g. orphaned by account deletion)
|
||
# flashed "Goal archived." while the state never moved. Surface the
|
||
# validation error instead of claiming success.
|
||
unless @goal.public_send("#{event}!")
|
||
redirect_to goal_path(@goal),
|
||
alert: @goal.errors.full_messages.to_sentence.presence || t(".invalid_transition")
|
||
return
|
||
end
|
||
|
||
respond_to do |format|
|
||
format.html { redirect_to goal_path(@goal), notice: t(".success") }
|
||
format.turbo_stream do
|
||
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
|
||
end
|
||
end
|
||
end
|
||
end
|