Files
sure/app/models/assistant/function/create_goal.rb
Guillem Arias 88032ce020 feat(goals): v2 architecture — drop ledger, derive balance, add pledge
Reshape the goals feature to live on top of linked-account balances.
A goal's balance is now the live balance of every depository account
linked to it — no parallel ledger, no "log a contribution" step.

The "Add contribution" affordance is replaced by a 7-day GoalPledge
(kind: transfer | manual_save). GoalPledge::Reconciler matches incoming
Transactions (via Account::ProviderImportAdapter) and Valuations (via
Account::ReconciliationManager) against open pledges within ±5 days,
±$0.50, or ±1% — single hook covers every provider (Plaid, SimpleFIN,
Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) plus manual
balance edits. A 15-minute Sidekiq cron sweeps expired pledges.

Goal model: balance derived from linked_accounts.sum(&:balance), new
pace (90-day net non-transfer inflow), months_of_runway,
last_matched_pledge_*, pledge_action_label_key (the "I just
transferred…" vs "I just saved…" verb switch).

UI:
- Index gets a 3-card KPI strip (Contributed last 30d / Needs this
  month / On track) plus a pending-pledges callout.
- Show page swaps the "Add contribution" CTA for the pledge modal,
  replaces the contribution list with a pending-pledge banner, and
  rebuilds the funding widget into per-account rows with a 12-bucket
  weekly sparkline and last-30 inflow.
- Projection chart adds a required-line (dashed light from
  today → target) and a translucent pending-pledge bump at today's X.

Schema (3 migrations):
1. goal_pledges table with PG enums (goal_pledge_kind, goal_pledge_status),
   open-by-expiry index, and unique-when-not-null matched_transaction_id.
2. Drop goal_contributions.
3. Partial unique index on
   transactions ((extra -> 'goal' ->> 'pledge_id')) built CONCURRENTLY
   so it doesn't block prod.

After pulling: run bin/rails db:migrate, then commit the schema.rb sync
separately (or let CI regenerate).

Deferred to v1.1: allocation columns, contention/archived banners,
"why is this behind?" diagnostic, reallocate flow, refresh-sync +
Plaid throttle, unallocated-cash chip, joint-account approval,
goal_activities log, polymorphic matched_entry_id/type for manual
pledge audit.
2026-05-14 16:07:14 +02:00

156 lines
5.1 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
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!
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 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