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:
Guillem Arias
2026-05-14 16:07:14 +02:00
parent 62bc766b0c
commit 88032ce020
47 changed files with 1162 additions and 883 deletions

View File

@@ -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)