Files
sure/app/controllers/goal_pledges_controller.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

89 lines
2.7 KiB
Ruby

class GoalPledgesController < ApplicationController
before_action :set_goal
before_action :set_pledge, only: %i[renew destroy]
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
def new
# The form is dialog-only. A direct GET (F5, bookmark, deep-link
# gone stale) lands the user back on the goal show page with the
# modal auto-opened via the catch-up CTA params, rather than
# rendering a freestanding DS::Dialog over an empty page.
unless turbo_frame_request?
redirect_to goal_path(@goal) and return
end
account = preselected_account
@pledge = @goal.goal_pledges.new(
currency: @goal.currency,
account: account,
kind: account&.default_pledge_kind || "transfer",
amount: params[:amount].presence
)
end
def create
@pledge = @goal.goal_pledges.new(pledge_params)
@pledge.account = lookup_account(params.dig(:goal_pledge, :account_id))
@pledge.kind = @pledge.account&.default_pledge_kind || "transfer"
@pledge.currency = @goal.currency
if @pledge.save
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
else
render :new, status: :unprocessable_entity
end
end
def renew
@pledge.extend!
redirect_to goal_path(@goal), notice: t(".success")
rescue GoalPledge::NotOpenError
redirect_to goal_path(@goal), alert: t(".not_open")
end
def destroy
@pledge.cancel!
redirect_to goal_path(@goal), notice: t(".success")
rescue GoalPledge::NotOpenError
redirect_to goal_path(@goal), alert: t(".not_open")
end
private
def set_goal
# Preload linked accounts + their providers so any_connected_account?
# and the new-pledge form's per-account helpers don't trigger N+1
# queries on account_providers.
@goal = Current.family.goals
.includes(:open_pledges, linked_accounts: :account_providers)
.find(params[:goal_id])
end
def set_pledge
@pledge = @goal.goal_pledges.find(params[:id])
end
def pledge_params
params.require(:goal_pledge).permit(:amount)
end
def lookup_account(id)
return nil if id.blank?
@goal.linked_accounts.find_by(id: id)
end
def preselected_account
requested = params[:account_id].presence && @goal.linked_accounts.find_by(id: params[:account_id])
requested || @goal.linked_accounts.first
end
def record_not_found
redirect_to goals_path, alert: t("goals.errors.not_found")
end
end