mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 15:14:19 +00:00
* 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>
241 lines
9.0 KiB
Ruby
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
|