Files
sure/app/models/assistant/function/create_goal.rb
T
b6029c1e28 feat(goals): show what each account still has room to earmark (#3166)
* feat(goals): show what each account still has room to earmark

`Account#free_to_earmark` has existed, unused, since earmarks shipped —
its own comment said the UI was a follow-up. This is that follow-up, and
the wording is the substance of it.

It does not say "over-allocated". `free_to_earmark` is negative for as
long as the saving is unfinished, which is the normal condition of anyone
with goals in progress: a 6,000 account backing two goals of 5,000 gives
−4,000 and is a perfectly correct setup. A warning phrased as a fault
would fire permanently and teach people to ignore it. The message states
the consequence instead — the goals come to X for a balance of Y, so they
progress pro rata — and is never styled as an error.

The trap is the goal being edited. `goal_earmarked_total` counts every
goal including that one, so reopening a goal that earmarks 5,000 on a
6,000 account shows 1,000 of headroom, and re-entering the same 5,000
trips a message about a setup the user has not touched.
`earmarked_by_other_goals` excludes it, and only when it is persisted —
a goal being created has nothing to exclude.

The pool is read once per render and passed down, never per account: the
form lists every fundable account the user can see. A test counts the
query and fails at two.

The Stimulus controller is its own, with 3 targets. goal_form_controller
is at 10 against the 7 the project guidelines suggest, needs none of this
state, and is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4

* fix(goals): read the typed amount strictly, and format it in the app's locale

Addresses review feedback on #3166.

`Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare
comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the
preview described an amount the user had not typed — and the second case is a
habit from another locale, not a typo, so it would have gone unnoticed. The
value now has to match a complete number before anything is computed.

`Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a
French user on an English-locale browser read separators and symbol placement
matching nothing else on the page. The amounts cannot be formatted server-side
— they change with every keystroke — so the server passes `I18n.locale` and the
client applies it. That puts the decision where the rest of the app's
formatting already lives.

bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): let the assistant create a second goal on a claimed account

Review on #3166. The function always built whole-account links and had no
way to express an earmark, so once exclusivity landed, asking for a second
goal on an account another goal already claimed came back as a bare
`validation_failed` — while the account list still advertised the account
as available. A common request became an unexplained refusal.

Three changes, and the list is the important one: it now says what is left
on each account and which are claimed in full, because the assistant
reasons from that list and had no way to know otherwise.

`earmarks` is an optional map of account name to amount, so the assistant
can reserve a slice rather than the whole balance. Accounts left out keep
the previous behaviour and take whatever is spare.

The refusal is named before the save — `account_claimed_in_full`, with the
account names — so the assistant gets a reason it can act on and ask about,
rather than a validation message it can only relay. Checked after the
currency check, which is the more fundamental of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(goals): move the spend tests back out of the private section

The merge of `main` into this branch landed #3176's tests between
`count_pool_queries` and the helpers below it, inside the `private`
section and at the wrong indentation. `ci / lint` has been failing on
`Layout/IndentationConsistency` since.

They still ran — `test` is a class method, so `private` does not hide them
— which is why the unit job stayed green while lint went red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-26 21:12:06 +02:00

241 lines
9.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. The goal's balance is the balance of these accounts."
},
earmarks: {
type: "object",
description: "Optional map of account name to the amount to reserve from that account, e.g. {\"Livret A\": 2000}. Required for an account already claimed in full by another goal — the available accounts list says which, and how much room is left. Accounts left out of this map reserve whatever the account has spare.",
additionalProperties: { type: "number" }
},
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
earmarks = parse_earmarks(params["earmarks"])
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
# Named before the save, so the assistant gets a reason it can act on
# rather than a generic validation failure it can only relay. Claiming an
# account in full is exclusive; joining one that is already claimed needs
# an explicit earmark, and the assistant can ask for one.
over_claimed = matched.select { |a| whole_account_claimed_ids.include?(a.id) && earmarks[a.name].nil? }
if over_claimed.any?
return error(
"account_claimed_in_full",
"Another goal already claims #{over_claimed.map(&:name).to_sentence} in full. " \
"Ask the user how much to reserve from #{'it'.pluralize(over_claimed.size)}, then pass it in `earmarks`.",
claimed_account_names: over_claimed.map(&:name),
available_accounts: depository_account_payload
)
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, allocated_amount: earmarks[a.name]) }
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
# Says what is left, not just what exists. A goal that claims an account in
# full is exclusive, so an account already claimed can only be joined with
# an explicit earmark — and the assistant has no way to know that unless
# the list says so.
def depository_account_payload
claimed = whole_account_claimed_ids
family.accounts.where(accountable_type: "Depository").visible.map do |account|
{
name: account.name,
currency: account.currency,
free_to_earmark: Money.new(account.free_to_earmark, account.currency).format,
claimed_in_full: claimed.include?(account.id)
}
end
end
def whole_account_claimed_ids
@whole_account_claimed_ids ||= GoalAccount.joins(:goal)
.where(allocated_amount: nil)
.where(goals: { family_id: family.id })
.where.not(goals: { state: Goal::RELEASED_STATES })
.pluck(:account_id)
.to_set
end
# Names are the assistant's handle on an account, so the map is keyed by
# them. Non-positive amounts are dropped rather than refused: a zero
# earmark and no earmark mean different things to the model, and neither
# is what the user asked for.
def parse_earmarks(raw)
return {} unless raw.is_a?(Hash)
raw.each_with_object({}) do |(account_name, amount), acc|
value = parse_decimal(amount)
acc[account_name.to_s.strip] = value if value && value.positive?
end
end
def error(key, message, extras = {})
{ success: false, error: key, message: message }.merge(extras)
end
end