Files
sure/docs/notes/goals-architecture-mechanics.md
Guillem Arias 8b547c1857 docs(goals): expand matrix coverage + ground mechanics in current code
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.
2026-05-12 16:59:20 +02:00

22 KiB
Raw Blame History

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 (sums goal_contributions.amount), progress_percent, status, display_status, projection_payload, to_donut_segments_json, advisory_lock_key_for(family_id) (lines 4143, 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). No allocated_amount or allocation_mode columns 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 with linked_account_names, optional initial_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/ and app/views/goal_contributions/.
  • Locale files under config/locales/views/{goals,goal_contributions,layout}/en.yml and config/locales/models/{goal,goal_contribution}/en.yml.

Account / balance / transfer infrastructure

  • Account#balance is a denormalized cache column. Source-of-truth is Entry valuation anchors. Writer: Account::CurrentBalanceManager#set_current_balance (app/models/account/current_balance_manager.rb:33-41).
  • balances table exists (created in db/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 columns end_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, or Balance::ChartSeriesBuilder (app/models/balance/chart_series_builder.rb) which uses a CTE with generate_series for windowed queries. This is the chart pattern Goals should reuse.
  • No Family#total_depository_balance. Aggregation lives in BalanceSheet (app/models/balance_sheet.rb) → BalanceSheet::AccountTotalsBalanceSheet::ClassificationGroup (assets/liabilities).
  • Transfer matching lives in Family#auto_transfer_matchable (app/models/family/auto_transfer_matchable.rb:51-64), not in entry processors. Transfer rows pair an inflow Transaction with an outflow Transaction. Entry has transfer_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 by Entry.uncategorized_transactions, Transaction::Search, rule filters.
  • Account.manual scope (app/models/account.rb:35-39): no account_providers, no plaid_account_id, no simplefin_account_id.
  • Account.visible scope (app/models/account.rb:31): status IN ("draft", "active"). Account uses AASM status (:active, :draft, :disabled, :pending_deletion) — no archived_at column.
  • 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 call Account::ProviderImportAdapter#import_transaction (app/models/account/provider_import_adapter.rb:29-242) — the hub.
  • extra jsonb lives on Transaction, not Entry directly (db/migrate/20251029190000_add_extra_to_transactions.rb). GIN-indexed.
  • Existing extra namespaces 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) at app/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). Uses pg_try_advisory_lock + pg_advisory_unlock with 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. Add plaid_manual_refresh to the config.
  • PlaidItem does not have last_manual_refresh_at or 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)) with allocated (sum of goal_accounts.allocated_amount or account.balance per allocation_mode).
    • Add backed (delegates to GoalBacking query object).
    • Remove last_contribution_at, last_contribution_days_ago, average_monthly_contribution. Their replacements are derived from Balance history.
    • Update projection_payload to source saved_series from Balance history × allocation share, not contribution sum.
    • Drop attr_accessor :initial_contribution_amount, :initial_contribution_account_id virtual attrs.
    • Drop has_many :goal_contributions.
    • Add has_many :goal_activities.
    • Validations: remove currency_locked_once_contributions_exist. Add allocation-mode-currency-consistency.
  • app/models/goal_account.rb: add validates :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 both Goal#current_balance_total SQL 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 from GoalBacking, upserts goal_balance_snapshots.
  • app/jobs/plaid_manual_refresh_job.rb — wraps a Plaid sync. New queue: plaid_manual_refresh (concurrency 2 in config/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 79115) 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 touching current_balance and contributions. Add tests for allocated, backed, GoalBacking aggregation, allocation_mode transitions on contention.
  • test/models/goal_contribution_test.rb — delete.
  • test/controllers/goals_controller_test.rb — drop with initial contribution flow. Replace with allocation-on-create flow.
  • test/controllers/goal_contributions_controller_test.rb — delete. Replace with test/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.yml deleted. goal_accounts.yml updated with allocated_amount and allocation_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

  1. 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.
  2. 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?
  3. 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.
  4. 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).