mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +00:00
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.
This commit is contained in:
@@ -53,15 +53,7 @@ class Assistant::Function::CreateGoal < Assistant::Function
|
||||
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." }
|
||||
}
|
||||
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",
|
||||
@@ -76,7 +68,6 @@ class Assistant::Function::CreateGoal < Assistant::Function
|
||||
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?
|
||||
@@ -122,8 +113,6 @@ class Assistant::Function::CreateGoal < Assistant::Function
|
||||
)
|
||||
matched.each { |a| goal.goal_accounts.build(account: a) }
|
||||
goal.save!
|
||||
|
||||
create_initial_contribution!(goal, matched, initial)
|
||||
end
|
||||
|
||||
{
|
||||
@@ -142,24 +131,6 @@ class Assistant::Function::CreateGoal < Assistant::Function
|
||||
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.goal_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)
|
||||
|
||||
Reference in New Issue
Block a user