Files
sure/app/models/assistant/function/create_goal.rb
Guillem Arias 9b61e4a41b refactor: rename Savings Goals feature to Goals
User-facing rename + structural rename. Feature is now called just
"Goals" everywhere — page title, sidebar nav, modal headings, flash
messages, AI assistant tool. Code identifiers follow:

- Models: SavingsGoal → Goal, SavingsContribution → GoalContribution,
  SavingsGoalAccount → GoalAccount.
- Tables: savings_goals → goals, savings_contributions → goal_contributions,
  savings_goal_accounts → goal_accounts. FK columns savings_goal_id →
  goal_id. New migration db/migrate/20260511100003_rename_savings_to_goals.rb
  uses rename_table + rename_column; PG handles index renaming and FK
  redirection automatically.
- Controllers: SavingsGoalsController → GoalsController,
  SavingsContributionsController → GoalContributionsController.
- Routes: /savings_goals → /goals, nested /goals/:id/contributions
  (resource name shifts; old route name aliases dropped).
- ViewComponent namespace: Savings::* → Goals::*. Component class
  names drop their redundant "Goal" prefix where the namespace already
  carries it: Savings::GoalCardComponent → Goals::CardComponent,
  Savings::GoalAvatarComponent → Goals::AvatarComponent. Others keep
  their names (Goals::ProgressRingComponent, Goals::StatusPillComponent,
  Goals::AccountStackComponent, Goals::FundingAccountsBreakdownComponent).
- Stimulus controllers: savings_goal_* → goal_*, savings_goals_filter
  → goals_filter. Stimulus identifiers in data-controller / data-*
  attributes follow.
- Locale keys: savings_goals: → goals: (top level), savings_contributions:
  → goal_contributions: (top level). All t() callers updated.
- AI assistant tool: Assistant::Function::CreateSavingsGoal →
  Assistant::Function::CreateGoal, tool name "create_savings_goal" →
  "create_goal", description / response text updated.
- Sidebar nav label "Savings" → "Goals". Goals/show + index page title
  "Savings" → "Goals". Empty goals_section heading/subtitle dropped
  (duplicated the page title post-rename).

Original migrations create_savings_goals / create_savings_goal_accounts /
create_savings_contributions remain untouched so historical replay
still works; the rename migration runs on top.
2026-05-11 20:08:32 +02:00

185 lines
6.0 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."
},
initial_contribution: {
type: "object",
description: "Optional starting contribution at creation time.",
properties: {
amount: { type: "number" },
source_account_name: { type: "string", description: "Must be one of the linked_account_names." }
}
},
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?)
initial = params["initial_contribution"]
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
matched = family.accounts.where(accountable_type: "Depository").visible.where(name: linked_account_names).to_a
missing = linked_account_names - matched.map(&:name)
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
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!
create_initial_contribution!(goal, matched, initial)
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: Rails.application.routes.url_helpers.goal_path(goal),
linked_account_names: matched.map(&:name),
message: "Created goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{Rails.application.routes.url_helpers.goal_path(goal)}."
}
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
end
private
def create_initial_contribution!(goal, matched_accounts, initial)
return unless initial.is_a?(Hash)
amount = parse_decimal(initial["amount"])
return unless amount && amount > 0
source = matched_accounts.find { |a| a.name == initial["source_account_name"].to_s }
raise ActiveRecord::RecordInvalid.new(goal) unless source
goal.goal_contributions.create!(
account: source,
amount: amount,
currency: goal.currency,
source: "initial",
contributed_at: Date.current
)
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