mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
feat(savings): add savings goals
Adds a standalone Savings goals feature: a piggy-bank style tracker that lets a family set a target, link one or more Depository accounts as funding sources, and log manual contributions over time. Supersedes #1569 (closed) — same intent, redesigned per reviewer + Discord feedback. What this adds: - New `/savings_goals` sidebar entry (piggy-bank icon) with index, show, state-filtered tabs (all/active/paused/completed/archived), and a 2-step modal stepper for creation (Identity → Review). - Multi-account funding via a `SavingsGoalAccount` join: a goal requires ≥1 linked Depository account (checking/savings/HSA/CD/money-market), and all linked accounts must share the goal's currency. - Tracker balance model: goal balance = SUM(contributions.amount). No auto-flow from account balances. Contributions are pure logical records and don't move money between accounts. - Manual contributions modal scoped to the goal's linked accounts. Initial contributions seeded at creation can't be deleted; manual ones can. - AASM lifecycle: active / paused / completed / archived. Hard-delete only after archive. - Status pills (On track / Behind / Reached / No date) derived from pace vs target_date. - AI Assistant tool `create_savings_goal` lets the sidebar chat create a goal end-to-end from a natural-language prompt; soft errors carry the available-accounts list back to the LLM (mirrors the existing `import_bank_statement` pattern). - Family-scoped throughout (`Current.family`-only access, account family-scoping enforced both in controllers and the AI tool). - Demo data seed wires up 4 sample goals across the Depository accounts. Intentionally out of scope (separate PRs / v1.1): - Auto-fund from budget surplus + Sidekiq cron + budget-show card. - Dashboard "Savings goals" widget. - "Behind pace" projection chart on the detail page. - `evaluate_savings_goal_feasibility` LLM tool (level-setting before create_savings_goal). - Spend-less goals inside Budgets. - Family-member-private goals (deferred investigation).
This commit is contained in:
184
app/models/assistant/function/create_savings_goal.rb
Normal file
184
app/models/assistant/function/create_savings_goal.rb
Normal file
@@ -0,0 +1,184 @@
|
||||
class Assistant::Function::CreateSavingsGoal < Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
"create_savings_goal"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Creates a savings 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
|
||||
SavingsGoal.transaction do
|
||||
goal = family.savings_goals.new(
|
||||
name: name,
|
||||
target_amount: target_amount,
|
||||
target_date: target_date,
|
||||
currency: currencies.first,
|
||||
notes: notes.presence,
|
||||
color: SavingsGoal::COLORS.sample
|
||||
)
|
||||
matched.each { |a| goal.savings_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.savings_goal_path(goal),
|
||||
linked_account_names: matched.map(&:name),
|
||||
message: "Created savings goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{Rails.application.routes.url_helpers.savings_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.savings_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
|
||||
Reference in New Issue
Block a user