mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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.
156 lines
5.1 KiB
Ruby
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
|