Files
sure/app/models/assistant/function/create_goal.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

185 lines
6.2 KiB
Ruby

class Assistant::Function::CreateGoal < Assistant::Function
class << self
def name
"create_goal"
end
def description
<<~INSTRUCTIONS
Creates a goal for the user's family.
Use when the user describes a target they want to save toward — e.g.
"vacation in 4 months for $5000", "downpayment for a car next year",
"build an emergency fund of $10k".
Before calling, confirm the key details by paraphrasing back to the
user: the name, target amount, target date (if mentioned), and which
of their accounts will fund it. Only call once they've confirmed.
Constraints:
- The goal must link to at least one of the user's Depository
accounts (checking, savings, HSA, CD, money-market).
- All linked accounts must share the same currency.
- Use account names exactly as listed in the user's Depository
accounts.
On success returns the new goal's URL so you can point the user to
it. On a soft failure (e.g. account name doesn't match), the
response includes the available account list so you can re-ask.
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: %w[name target_amount linked_account_names],
properties: {
name: {
type: "string",
description: "Short goal name, e.g. 'Vacation in Italy'."
},
target_amount: {
type: "number",
description: "Total amount to save, in the linked accounts' currency."
},
target_date: {
type: "string",
description: "Optional ISO 8601 date (YYYY-MM-DD) for when the user wants to reach the target."
},
linked_account_names: {
type: "array",
items: { type: "string" },
description: "Names of the user's Depository accounts to link. Must contain at least one. Use names exactly as they appear in the available accounts list. The goal's balance is the balance of these accounts."
},
notes: {
type: "string",
description: "Optional freeform notes."
}
}
)
end
def call(params = {})
name = params["name"].to_s.strip
target_amount = parse_decimal(params["target_amount"])
target_date = parse_date(params["target_date"])
linked_account_names = Array(params["linked_account_names"]).map { |n| n.to_s.strip }.reject(&:blank?)
notes = params["notes"].to_s.strip
return error("name_required", "Please provide a name for the goal.") if name.blank?
return error("target_amount_invalid", "Target amount must be greater than zero.") unless target_amount && target_amount > 0
if linked_account_names.empty?
return error(
"no_linked_accounts",
"Please specify at least one Depository account to link to this goal.",
available_accounts: depository_account_payload
)
end
available = family.accounts.where(accountable_type: "Depository").visible.where(name: linked_account_names)
missing = linked_account_names - available.pluck(:name).uniq
if missing.any?
return error(
"unknown_accounts",
"Some account names didn't match the user's Depository accounts.",
unknown_names: missing,
available_accounts: depository_account_payload
)
end
# Multiple accounts can share a name. Block silent over-linking by
# surfacing the ambiguity so the assistant re-asks with disambiguated
# input rather than attaching every same-named account to the goal.
grouped = available.group_by(&:name)
ambiguous_names = grouped.select { |_, accts| accts.size > 1 }.keys
if ambiguous_names.any?
return error(
"ambiguous_accounts",
"Multiple accounts share a name. Ask the user which one to use.",
ambiguous_names: ambiguous_names,
available_accounts: depository_account_payload
)
end
matched = linked_account_names.map { |name| grouped[name].first }
currencies = matched.map(&:currency).uniq
if currencies.size > 1
return error(
"currency_mismatch",
"All linked accounts must share the same currency. Found: #{currencies.join(', ')}."
)
end
goal = nil
Goal.transaction do
goal = family.goals.new(
name: name,
target_amount: target_amount,
target_date: target_date,
currency: currencies.first,
notes: notes.presence,
color: Goal::COLORS.sample
)
matched.each { |a| goal.goal_accounts.build(account: a) }
goal.save!
end
{
success: true,
goal_id: goal.id,
name: goal.name,
target_amount_formatted: goal.target_amount_money.format,
currency: goal.currency,
target_date: goal.target_date&.iso8601,
url: absolute_url_for(goal),
linked_account_names: matched.map(&:name),
message: "Created goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{absolute_url_for(goal)}."
}
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
end
private
# Build an absolute URL for the new goal so chat clients (which render
# outside the request that produced the goal) can link directly. Falls
# back to the relative path when no host is configured (e.g. self-hosted
# in a job without ENV).
def absolute_url_for(goal)
host_opts = Rails.application.config.action_mailer.default_url_options || {}
if host_opts[:host].present?
Rails.application.routes.url_helpers.goal_url(goal, host_opts)
else
Rails.application.routes.url_helpers.goal_path(goal)
end
end
def parse_decimal(value)
return nil if value.nil?
BigDecimal(value.to_s)
rescue ArgumentError, TypeError
nil
end
def parse_date(value)
return nil if value.blank?
Date.iso8601(value.to_s)
rescue Date::Error
nil
end
def depository_account_payload
family.accounts.where(accountable_type: "Depository").visible.pluck(:name, :currency).map { |n, c| { name: n, currency: c } }
end
def error(key, message, extras = {})
{ success: false, error: key, message: message }.merge(extras)
end
end