User-facing doc gains explicit 1xM (one goal, multiple accounts), N goals on shared accounts, and overlap (NxM) sections, plus the reallocation flow and "why is this goal behind?" diagnostic. Mechanics doc is rewritten against the actual code on the branch: file:line citations for current state, accurate corrections to the prior draft (Sure uses Account AASM status not archived_at, no Account#balance_at method, balance history via Balance::ChartSeriesBuilder CTE, Transaction::TRANSFER_KINDS for pace exclusion, advisory-lock pattern lifted from IdentifyRecurringTransactionsJob, partial-unique index precedent from entries[external_id, source]), concrete migration plan with seven steps, surface-by-surface STAY/CHANGE/DELETE verdict on every component, view, and Stimulus controller, day-one instrumentation events, and four pre-launch user tests.
22 KiB
Goals: account-linked model — engineering mechanics
Companion to goals-architecture.md. Grounded in the actual code on feat/savings-goals HEAD (commit a6bdfb73 at time of writing).
This doc is concrete about what exists in Sure today, what changes, and what's net-new. File:line citations throughout. Verdicts from a fresh read-only audit of the goal-domain code, the account/balance/transfer infrastructure, the four entry processors, and the async/locking surface.
Current state on the branch (as of a6bdfb73)
Goal-domain models
app/models/goal.rb— 284 lines. AASM states (active,paused,completed,archived), validations (linked-account presence, depository-type, family-scope, currency-match, currency-lock-after-contributions),current_balance(sumsgoal_contributions.amount),progress_percent,status,display_status,projection_payload,to_donut_segments_json,advisory_lock_key_for(family_id)(lines 41–43, currently unused).app/models/goal_contribution.rb— 57 lines.belongs_to :goal, :account.SOURCES = %w[manual initial]. Validations.app/models/goal_account.rb— 7 lines.belongs_to :goal, :account. Uniqueness on(goal_id, account_id). Noallocated_amountorallocation_modecolumns today.
Schema today
goals(id uuid PK, family_id uuid FK, name string, target_amount decimal(19,4),
currency string, target_date date, color string, icon string, notes text,
state string default 'active', timestamps)
goal_accounts(id uuid PK, goal_id uuid FK, account_id uuid FK, timestamps)
index: [goal_id, account_id] UNIQUE
goal_contributions(id uuid PK, goal_id uuid FK, account_id uuid FK,
amount decimal(19,4), currency string, source string default 'manual',
contributed_at date, notes text, timestamps)
Controllers + AI tool
app/controllers/goals_controller.rb— 312 lines. CRUD + AASM transitions +kpi_payload,funding_breakdown_for,stats_for.app/controllers/goal_contributions_controller.rb— 65 lines.new,create,destroy.app/models/assistant/function/create_goal.rb— 185 lines. JSON schema withlinked_account_names, optionalinitial_contribution.
UI surface
- 6 components under
app/components/goals/: avatar, card, status_pill, progress_ring, funding_accounts_breakdown, account_stack. - 5 Stimulus controllers:
goal_projection_chart_controller.js(495 lines),goal_stepper_controller.js(302 lines),goals_filter_controller.js(117 lines),goal_contribution_preview_controller.js(67 lines),color_icon_picker_controller.js(262 lines, shared with Categories). - Views under
app/views/goals/andapp/views/goal_contributions/. - Locale files under
config/locales/views/{goals,goal_contributions,layout}/en.ymlandconfig/locales/models/{goal,goal_contribution}/en.yml.
Account / balance / transfer infrastructure
Account#balanceis a denormalized cache column. Source-of-truth isEntryvaluation anchors. Writer:Account::CurrentBalanceManager#set_current_balance(app/models/account/current_balance_manager.rb:33-41).balancestable exists (created indb/migrate/20240212150110_create_account_balances.rb). Rich columns:balance,cash_balance,start_cash_balance,start_non_cash_balance,cash_inflows,cash_outflows,non_cash_inflows,non_cash_outflows,flows_factor, plus virtual columnsend_balance,start_balance. Unique index on(account_id, date, currency).- No
Account#balance_at(date)method exists. Pattern in use:Balance.where(account_id:, date:)directly, orBalance::ChartSeriesBuilder(app/models/balance/chart_series_builder.rb) which uses a CTE withgenerate_seriesfor windowed queries. This is the chart pattern Goals should reuse. - No
Family#total_depository_balance. Aggregation lives inBalanceSheet(app/models/balance_sheet.rb) →BalanceSheet::AccountTotals→BalanceSheet::ClassificationGroup(assets/liabilities). - Transfer matching lives in
Family#auto_transfer_matchable(app/models/family/auto_transfer_matchable.rb:51-64), not in entry processors.Transferrows pair an inflowTransactionwith an outflowTransaction. Entry hastransfer_id(nullable FK). Transaction::TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment investment_contribution](app/models/transaction.rb:79). Filtering pattern in use:where.not(kind: Transaction::TRANSFER_KINDS). Used byEntry.uncategorized_transactions,Transaction::Search, rule filters.Account.manualscope (app/models/account.rb:35-39): noaccount_providers, noplaid_account_id, nosimplefin_account_id.Account.visiblescope (app/models/account.rb:31):status IN ("draft", "active"). Account uses AASMstatus(:active,:draft,:disabled,:pending_deletion) — noarchived_atcolumn.Depository::SUBTYPES(app/models/depository.rb:4-10):checking,savings,hsa,cd,money_market. Exactly the subtype distinctions the architecture needs.
Entry processors and extra jsonb
- Four entry processors:
PlaidEntry::Processor(app/models/plaid_entry/processor.rb),SimplefinEntry::Processor,LunchflowEntry::Processor,EnableBankingEntry::Processor. All four callAccount::ProviderImportAdapter#import_transaction(app/models/account/provider_import_adapter.rb:29-242) — the hub. extrajsonb lives onTransaction, notEntrydirectly (db/migrate/20251029190000_add_extra_to_transactions.rb). GIN-indexed.- Existing
extranamespaces stamped today:simplefin,plaid,lunchflow,enable_banking,exchange_rate,potential_posted_match,manual_merge.extra["goal"]is free — no collision. - Existing partial-unique index precedent:
add_index :entries, [:external_id, :source], unique: true, where: "external_id IS NOT NULL AND source IS NOT NULL"(db/migrate/20251027110502_*.rb).
Async + locking
SyncJob(app/jobs/sync_job.rb). Queue:high_priority. Concurrency 4.Goal.advisory_lock_key_for(family_id)atapp/models/goal.rb:41-43. Currently unused — wired pattern doesn't exist yet.- Existing advisory-lock pattern:
IdentifyRecurringTransactionsJob#with_advisory_lock(app/jobs/identify_recurring_transactions_job.rb:62-77). Usespg_try_advisory_lock+pg_advisory_unlockwith ensure-block release. Copy this pattern verbatim for goal-allocation writes. - Sidekiq queues (
config/sidekiq.yml):scheduled(10),high_priority(4),medium_priority(2),low_priority(1),default(1). No manual-refresh queue today. Addplaid_manual_refreshto the config. - PlaidItem does not have
last_manual_refresh_ator per-day-counter columns today. Migration required.
What changes
Schema migrations (in order)
1. Add allocation columns to goal_accounts:
# db/migrate/<ts>_add_allocation_to_goal_accounts.rb
add_column :goal_accounts, :allocated_amount, :decimal, precision: 19, scale: 4, null: false, default: 0
create_enum :goal_account_allocation_mode, %w[full_balance fixed_amount]
add_column :goal_accounts, :allocation_mode, :goal_account_allocation_mode, null: false, default: "full_balance"
add_index :goal_accounts, [:account_id, :goal_id], include: [:allocated_amount, :allocation_mode], name: "ix_goal_accounts_backing"
The covering index keeps GoalBacking queries index-only.
2. Create goal_balance_snapshots:
create_table :goal_balance_snapshots, id: :uuid do |t|
t.references :goal, type: :uuid, null: false, foreign_key: true
t.date :date, null: false
t.decimal :amount, precision: 19, scale: 4, null: false
t.timestamp :computed_at, null: false
end
add_index :goal_balance_snapshots, [:goal_id, :date], unique: true
Per Daniel's matrix call: account_id is omitted. Per-account sparklines render live from GoalBacking.
3. Create goal_activities:
create_enum :goal_activity_visibility, %w[family owner_only shared_with]
create_table :goal_activities, id: :uuid do |t|
t.references :goal, type: :uuid, null: false, foreign_key: true
t.references :actor_user, type: :uuid, foreign_key: { to_table: :users }
t.string :action, null: false
t.jsonb :payload, null: false, default: {}
t.column :visibility, :goal_activity_visibility, null: false, default: "family"
t.timestamps
end
add_index :goal_activities, [:goal_id, :created_at]
v1 writes only 'family'. 'owner_only', 'shared_with' are v1.1 — no further migration.
4. Add Plaid manual-refresh throttle columns:
add_column :plaid_items, :last_manual_refresh_at, :datetime
add_column :plaid_items, :manual_refresh_count_day, :integer, null: false, default: 0
add_column :plaid_items, :manual_refresh_count_date, :date
The count_date lets a Sidekiq cron reset counters at midnight UTC without a separate truncate.
5. Account goal-retention state. Sure doesn't use archived_at — accounts have an AASM status. Add a new state rather than introducing a parallel column:
# Update the Account enum check constraint:
# status IN ('active', 'draft', 'disabled', 'pending_deletion', 'goal_retained')
add_column :accounts, :retained_for_goal_id, :uuid
add_index :accounts, :retained_for_goal_id, where: "retained_for_goal_id IS NOT NULL"
Update Account.visible scope to exclude goal_retained. Update BalanceSheet::NetWorthSeriesBuilder#visible_account_ids accordingly.
6. Add pledge_id unique constraint on transactions.extra:
add_index :transactions, "(extra -> 'goal' ->> 'pledge_id')",
unique: true,
where: "(extra -> 'goal' ->> 'pledge_id') IS NOT NULL",
name: "ix_transactions_extra_goal_pledge_id"
Mirrors the entries[:external_id, :source] partial-unique precedent.
7. Drop goal_contributions:
drop_table :goal_contributions
Same migration train. No dormant scaffolding — the previous five iterations of expert review unanimously rejected leaving it as a half-decision.
Models touched
app/models/goal.rb:- Replace
current_balance(today:goal_contributions.sum(:amount)) withallocated(sum ofgoal_accounts.allocated_amountoraccount.balanceperallocation_mode). - Add
backed(delegates toGoalBackingquery object). - Remove
last_contribution_at,last_contribution_days_ago,average_monthly_contribution. Their replacements are derived fromBalancehistory. - Update
projection_payloadto sourcesaved_seriesfromBalancehistory × allocation share, not contribution sum. - Drop
attr_accessor :initial_contribution_amount, :initial_contribution_account_idvirtual attrs. - Drop
has_many :goal_contributions. - Add
has_many :goal_activities. - Validations: remove
currency_locked_once_contributions_exist. Add allocation-mode-currency-consistency.
- Replace
app/models/goal_account.rb: addvalidates :allocated_amount, numericality: { greater_than_or_equal_to: 0 }. Add allocation_mode predicate.- Delete
app/models/goal_contribution.rb.
New service / query objects
app/models/goal_backing.rb— request-scoped query object. Single SQL pass using the covering index. Returns(goal_id, account_id, allocated, backed)rows. Replaces bothGoal#current_balance_totalSQL and per-row pro-rata math.app/models/goal/pledge.rb— pledge state model. Stimulus controller + Rails-side row. Fields:id,goal_id,account_id,amount,expires_at,status(pending|matched|expired|extended|cancelled).app/jobs/goal_snapshot_rebuild_job.rb— debounced 5s per(goal_id, family_id). Reads fromGoalBacking, upsertsgoal_balance_snapshots.app/jobs/plaid_manual_refresh_job.rb— wraps a Plaid sync. New queue:plaid_manual_refresh(concurrency 2 inconfig/sidekiq.yml).
Pledge reconciliation hook
Pledge matching runs inside Account::ProviderImportAdapter#import_transaction (app/models/account/provider_import_adapter.rb:29-242), specifically after the existing pending-to-posted reconciliation block (currently lines 79–115) and before the Transaction#save call.
# Pseudocode for the new reconciliation step:
if pledge = matching_pledge_for(account_id: entry.account_id, amount: entry.amount, date: entry.date)
transaction.extra["goal"] = { "pledge_id" => pledge.id, "matched_at" => Time.current }
pledge.update!(status: :matched, matched_entry_id: entry.id)
end
The match runs against rows where transfer_id IS NOT NULL (the entry is already paired into a Transfer by the post-sync Family#auto_transfer_matchable pass). Tolerance: ±5 days / ±$0.50 absolute or ±1% (whichever is larger). Multiple open pledges on one account: nearest-amount-first, then nearest-date. The partial-unique index on transactions.extra->goal->>pledge_id enforces first-match-wins at the DB level — second deposit matching the same pledge is just a deposit.
Advisory-lock wiring
Goal allocation writes go through a serializer using the existing Goal.advisory_lock_key_for(family_id) method plus the existing IdentifyRecurringTransactionsJob#with_advisory_lock pattern, lifted into a Goal::AllocationWriter service:
# app/models/goal/allocation_writer.rb (pseudocode)
def write!(family_id, allocations)
lock_key = Goal.advisory_lock_key_for(family_id)
acquired = ActiveRecord::Base.connection.select_value(
ActiveRecord::Base.sanitize_sql_array(["SELECT pg_try_advisory_lock(?)", lock_key])
)
return :busy unless acquired
begin
ActiveRecord::Base.transaction do
allocations.each(&:save!)
re_read_balance_and_validate!
end
ensure
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(["SELECT pg_advisory_unlock(?)", lock_key])
)
end
end
The re_read_balance_and_validate! step is what catches sync-against-split-prompt races: if a balance dropped mid-drag, surface the over-allocation gap inline instead of committing under-water.
Snapshot rebuild
GoalSnapshotRebuildJob debounce key is (goal_id, family_id) — Daniel's matrix correction. On any goal_accounts write (including a sibling goal's allocation change on the same account), enqueue with that key. Sidekiq's unique_for window of 5 seconds collapses slider-drag bursts.
On retroactive allocation edits, the job's read path: WHERE goals.updated_at > snapshot.computed_at OR goal_accounts.updated_at > snapshot.computed_at for the affected date window only (not since-account-creation). Read fallback: live GoalBacking if no fresh snapshot covers the date — never a stale number presented as live.
Pace and projection chart
Pace queries reuse the Balance::ChartSeriesBuilder CTE pattern (app/models/balance/chart_series_builder.rb:38-83). Filter on Transaction.where.not(kind: Transaction::TRANSFER_KINDS) for excluding inter-account moves. Top-decile inflow exclusion happens in Ruby on the result set — 90 days of data is small enough.
Projection chart controller (goal_projection_chart_controller.js) gets a third <path> element on the existing area generator: confirmed_series, pending_series, projection_dashed. Three styles, same generator, ~80 LoC delta. Animation: cubic-bezier(0.4, 0, 0.2, 1) at 400ms (Tailwind default ease-out). prefers-reduced-motion: reduce → snap-cut. aria-live="polite" on the chart wrapper announces "Transfer matched."
Rate-limit infrastructure
Per-PlaidItem rate-limit gate is a lockless conditional UPDATE, mirroring Security::HealthChecker's where(last_health_check_at: ..INTERVAL.ago) pattern (app/models/security/health_checker.rb:126):
PlaidItem.where(id: plaid_item.id)
.where("last_manual_refresh_at < ?", 60.seconds.ago)
.update_all(last_manual_refresh_at: Time.current,
manual_refresh_count_day: <<...>>)
# update_all returns row-count. 0 = throttled.
UI cooldown ring lives entirely in goal_detail_controller.js (new) — 60s local timer, independent of server state. When the server returns 429, the button surfaces the bank-side reason ("next slot at 2:14pm") instead of a spinning cooldown that lies about why.
UI surface verdict
From the read-only audit. STAY = no change. CHANGE = same surface, new data source / new copy. DELETE = goes away.
| Surface | Verdict | Notes |
|---|---|---|
Goals::AvatarComponent (rb + erb) |
STAY | Pure UI. |
Goals::StatusPillComponent (rb + erb) |
STAY | Status logic shifts to display_status derived from backed; component itself is reusable. |
Goals::AccountStackComponent (rb + erb) |
STAY | Index card avatar stack. |
Goals::ProgressRingComponent (rb + erb) |
CHANGE | Numerator becomes Goal#backed (or allocated — design pick). Denominator unchanged. |
Goals::CardComponent |
CHANGE | Drop pace_line, footer_line (no contributions). Show share-of-account-backing per linked account in the avatar stack. |
Goals::FundingAccountsBreakdownComponent |
CHANGE | Rename internal data source from goal_contributions.group(:account) to GoalBacking rows. Add 90-day per-account sparkline. Add "Also funding N other goals" caption. |
goal_projection_chart_controller.js |
CHANGE | New pending_series path. saved_series data source swaps from contributions to Balance history × allocation share. |
goal_stepper_controller.js |
CHANGE | Step 2's initialContributionAmount / initialContributionAccountSelect targets → allocationInputs[]. Validation logic shifts from "min contribution" to "allocation per account ≤ account.balance." |
goals_filter_controller.js |
CHANGE | Status chip set unchanged in structure; data feeds backed-derived :behind. |
goal_contribution_preview_controller.js |
DELETE | Contribution form is gone. |
color_icon_picker_controller.js |
STAY | Shared with Categories. |
app/views/goals/index.html.erb |
CHANGE | KPI strip: Unallocated chip added. velocity_30d data source swaps to Balance-history-derived. |
app/views/goals/show.html.erb |
CHANGE | Replace _contributions_list.html.erb render with the funding-widget per-account expand. Drop "Add contribution" action button. Add "I just transferred" / "I just saved" verb-branched action. |
app/views/goals/new.html.erb + _form_stepper.html.erb |
CHANGE | Step 1 keeps name/target/date/color/icon/accounts/notes. Step 2 replaces "initial contribution" disclosure with optional per-account allocation inputs. |
app/views/goals/edit.html.erb + _form_edit.html.erb |
CHANGE | Add per-account allocation inputs. |
app/views/goals/_color_picker.html.erb |
STAY | Unchanged. |
app/views/goals/_contributions_list.html.erb |
DELETE | |
app/views/goal_contributions/new.html.erb |
DELETE | Replaced by a new goal_pledges/new.html.erb for the "I just transferred" sheet. |
app/controllers/goals_controller.rb |
CHANGE | kpi_payload data sources rewritten. funding_breakdown_for swaps to GoalBacking rows. sync_linked_accounts! extended with allocation diff handling. |
app/controllers/goal_contributions_controller.rb |
DELETE | Replaced by Goal::PledgesController. |
app/models/assistant/function/create_goal.rb |
CHANGE | JSON schema: drop initial_contribution. Add allocation_per_account (optional). Logic simplified — no create_initial_contribution_if_provided!. |
app/models/demo/generator.rb#generate_goals! |
CHANGE | Stop creating contributions. Set goal_accounts.allocated_amount directly. Use Account#balance history to give the projection chart shape. |
Test changes
test/models/goal_test.rb— rewrite all tests touchingcurrent_balanceand contributions. Add tests forallocated,backed,GoalBackingaggregation, allocation_mode transitions on contention.test/models/goal_contribution_test.rb— delete.test/controllers/goals_controller_test.rb— dropwith initial contributionflow. Replace with allocation-on-create flow.test/controllers/goal_contributions_controller_test.rb— delete. Replace withtest/controllers/goal/pledges_controller_test.rb.test/models/assistant/function/create_goal_test.rb— update json schema test. Drop initial-contribution tests.- Fixtures:
goal_contributions.ymldeleted.goal_accounts.ymlupdated withallocated_amountandallocation_mode.
Day-one instrumentation
goal.pledge.created(goal_id, account_id, account_type, amount_bucket)goal.pledge.matched(goal_id, account_id, time_to_match_seconds)goal.pledge.expired(goal_id, account_id)goal.pledge.extended(goal_id)goal.allocation.committed_under_water(goal_id, account_id, gap_amount) — telemetry for the split-prompt-vs-sync race.goal.snapshot_rebuild.duration— watch the p99 for sibling-fanout regressions.
If expired / created > 0.4 in week one, tune the ±5 day / ±1% match window.
Pre-launch user tests
- Pledge-pause test. Mobile Safari, iPhone 13-class, real user with one synced savings account. Task: "You just moved $500 from checking to savings for House. Tell Sure." Signal: pause ≥ 3 seconds on the goal page after confirming.
- Borrow-frame test. Real user already funding two goals from one account. Walk through linking a second goal to a shared account. Signal: does "How much should House borrow?" parse as fair or as theft?
- Pledge-expiry-extend ratio. Instrument the extend-vs-resolve-vs-abandon split on first expiry. Hypothesis: > 60% choose "Extend 7 days." Disproof: < 35% means the pledge isn't carrying weight.
- Overlap legibility (Tessa's test). Six users running 3+ active goals across 2+ accounts. Show them the funding-widget overlap line. Ask: "If you spent $500 from Ally tomorrow, which goal would feel it?" Score: how many name the mathematically correct goal vs. the goal with the largest Ally allocation. < 50% correct = pro-rata is correct but illegible, widget needs a visual cue beyond text.
Deferred to v1.1+
Priority ordering for the over-allocation split. Tag-based annotation. Auto-fund from budget surplus. FX-aware allocation (v1 locks goal_accounts.currency to accounts.currency). Family-member-private goals (schema breadcrumb is in place via goal_activities.visibility). Balance-derived weekly-savings indicator. Auto-rebalancing on sibling-goal allocation changes (v1 ships the preview-diff, v1.1 considers auto-applying with confirmation).