mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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.
This commit is contained in:
@@ -1,106 +1,301 @@
|
||||
# Goals: account-linked model — engineering mechanics
|
||||
|
||||
*Companion to [`goals-architecture.md`](goals-architecture.md). Final cut after five iterations of expert review.*
|
||||
*Companion to [`goals-architecture.md`](goals-architecture.md). Grounded in the actual code on `feat/savings-goals` HEAD (commit `a6bdfb73` at time of writing).*
|
||||
|
||||
## Schema
|
||||
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.
|
||||
|
||||
- `goals`: id, family_id, name, target_amount, currency, target_date, color, icon, notes, state.
|
||||
- `goal_accounts`: id, goal_id, account_id, `allocated_amount decimal NOT NULL DEFAULT 0`, `allocation_mode enum('full_balance', 'fixed_amount')`, currency.
|
||||
- `goal_balance_snapshots`: goal_id, date, amount, computed_at. `UNIQUE(goal_id, date)`. Rebuild-on-demand cache.
|
||||
- `goal_activities`: id, goal_id, actor_user_id, action, payload jsonb, `visibility enum DEFAULT 'family'`.
|
||||
- `accounts.archived_at timestamptz NULL`.
|
||||
- `accounts.retained_for_goal_id uuid NULL` — soft pointer.
|
||||
- **Dropped:** `goal_contributions` table, in the same migration train. No dormant scaffolding.
|
||||
## Current state on the branch (as of `a6bdfb73`)
|
||||
|
||||
## Computed properties
|
||||
### Goal-domain models
|
||||
|
||||
```ruby
|
||||
Goal#allocated = goal_accounts.sum do |ga|
|
||||
ga.allocation_mode == 'full_balance' ? ga.account.balance : ga.allocated_amount
|
||||
end
|
||||
- `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 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)`. **No `allocated_amount` or `allocation_mode` columns today.**
|
||||
|
||||
Goal#backed = GoalBacking.for(family).fetch(goal.id, :backed)
|
||||
### 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)
|
||||
```
|
||||
|
||||
`GoalBacking` is a request-scoped query object. Single SQL pass, covering index on `goal_accounts(account_id, goal_id) INCLUDE (allocated_amount, allocation_mode)`. Pro-rata in a CTE; returns `(goal_id, account_id, allocated, backed)` rows. Index, show, and funding-widget all read from the same materialized result inside one request.
|
||||
### Controllers + AI tool
|
||||
|
||||
## Pro-rata under contention
|
||||
- `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`.
|
||||
|
||||
Per linked account with balance B and allocations a₁ … aₙ:
|
||||
### UI surface
|
||||
|
||||
- `Σ aᵢ ≤ B`: each goal backed by aᵢ.
|
||||
- `Σ aᵢ > B`: each goal backed by `aᵢ × B / Σ aᵢ`.
|
||||
- 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`.
|
||||
|
||||
Fair-share, no priority. Priority ordering is deferred to v1.1; the schema doesn't need to change to add it.
|
||||
### Account / balance / transfer infrastructure
|
||||
|
||||
## Pledge reconciliation
|
||||
- **`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::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. `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.
|
||||
|
||||
Pledge state is held as Stimulus state plus an `extra->goal->pledge_id` UUID stamped on the matched `Entry` after reconciliation. Uniqueness constraint on `pledge_id`. Match runs inside `PlaidEntry::Processor` and the SimpleFIN equivalent: same account, signed amount within ±$0.50 absolute or ±1% (whichever is larger), date within ±5 days, only against rows where `transfer_id IS NOT NULL` (reuses the pace classifier — single source of truth for "saving event"). Pending provider rows do not consume the match.
|
||||
### Entry processors and `extra` jsonb
|
||||
|
||||
Multiple open pledges on one account: nearest-amount-first, then nearest-date. Manual-account pledges reconcile against the user's next balance update.
|
||||
- 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`).
|
||||
|
||||
## Sync race against split-prompt commit
|
||||
### Async + locking
|
||||
|
||||
Family-level advisory lock on allocation writes via `Goal.advisory_lock_key_for(family_id)`. On commit, re-read balance. If it dropped below `Σ allocations` mid-drag, surface the over-allocation gap inline instead of committing under-water.
|
||||
- **`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.
|
||||
|
||||
## Rate limits
|
||||
## What changes
|
||||
|
||||
Two clocks:
|
||||
### Schema migrations (in order)
|
||||
|
||||
- **Plaid quota** lives server-side, scoped to `PlaidItem`: 1 manual refresh per 60s, 5/hour, 20/day. Stored on `plaid_items.last_manual_refresh_at` plus a daily-reset counter. Dedicated Sidekiq queue `plaid_manual_refresh`, concurrency 2, so payday-Friday traffic doesn't starve the nightly sync queue. Lockless gate via conditional UPDATE.
|
||||
- **UI cooldown ring** lives per-goal in the goal-detail Stimulus controller. 60s local. Independent of Plaid bucket state. When the bank bucket is exhausted but the local ring isn't, the button surfaces the bank-side reason ("next slot at 2:14pm") instead of a spinning cooldown.
|
||||
**1. Add allocation columns to `goal_accounts`:**
|
||||
|
||||
## Pace
|
||||
```ruby
|
||||
# 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"
|
||||
```
|
||||
|
||||
90-day rolling average of family-level net inflow into the goal's linked accounts, computed from `Entry` rows where `transfer_id IS NULL` (or excluded via the `Transfer.exclude` scope, whichever is canonical at implementation time). Top-decile inflows excluded from the pace calculation but visible in the saved area as annotated dots with a per-dot opt-in to apply the windfall to pace.
|
||||
The covering index keeps `GoalBacking` queries index-only.
|
||||
|
||||
Window shrinks to available history above 30 days; below 30, no projection segment.
|
||||
**2. Create `goal_balance_snapshots`:**
|
||||
|
||||
## Snapshot rebuild
|
||||
```ruby
|
||||
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
|
||||
```
|
||||
|
||||
`goal_balance_snapshots(goal_id, date)` UNIQUE with `ON CONFLICT (goal_id, date) DO UPDATE SET amount = EXCLUDED.amount, computed_at = NOW()`. Snapshots are a derived cache, not source of truth.
|
||||
Per Daniel's matrix call: `account_id` is omitted. Per-account sparklines render live from `GoalBacking`.
|
||||
|
||||
Rebuild on demand for any window where `goals.updated_at > snapshot.computed_at OR goal_accounts.updated_at > snapshot.computed_at`. `GoalSnapshotRebuildJob` is debounced 5 seconds per goal so slider drags don't queue 40 jobs. Read path falls back to live `GoalBacking` if no fresh snapshot covers the date — never a stale number presented as live.
|
||||
**3. Create `goal_activities`:**
|
||||
|
||||
## Account archival
|
||||
```ruby
|
||||
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]
|
||||
```
|
||||
|
||||
`accounts.archived_at = now()` on close.
|
||||
v1 writes only `'family'`. `'owner_only'`, `'shared_with'` are v1.1 — no further migration.
|
||||
|
||||
- Excluded from `Account#linkable_for_goals`.
|
||||
- Excluded from `family.total_depository_balance` and the global sidebar.
|
||||
- Visible inside any goal's funding widget as a muted row, so the saved-series history doesn't break.
|
||||
**4. Add Plaid manual-refresh throttle columns:**
|
||||
|
||||
`accounts.retained_for_goal_id` is a soft pointer checked on read against `goals.state != 'archived'`. On goal hard-delete, `Goal#before_destroy` callback nullifies the pointer and re-evaluates auto-archive eligibility in the same transaction.
|
||||
```ruby
|
||||
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
|
||||
```
|
||||
|
||||
Auto-archive trigger: 180 days no activity AND zero balance AND no linked goals with future `target_date`. Heads-up at 150 days inside the funding widget caption. 30-day reversal grace post-archive.
|
||||
The `count_date` lets a Sidekiq cron reset counters at midnight UTC without a separate truncate.
|
||||
|
||||
## Currency
|
||||
**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:
|
||||
|
||||
v1 locks `goal_accounts.currency` to `accounts.currency`. Cross-currency goals deferred to v1.1.
|
||||
```ruby
|
||||
# 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"
|
||||
```
|
||||
|
||||
## Activity log visibility
|
||||
Update `Account.visible` scope to exclude `goal_retained`. Update `BalanceSheet::NetWorthSeriesBuilder#visible_account_ids` accordingly.
|
||||
|
||||
`goal_activities.visibility` column NOT NULL DEFAULT `'family'` from day one. Only one value in v1. v1.1 adds `'owner_only'`, `'shared_with'` without a migration on a table already accumulating rows.
|
||||
**6. Add `pledge_id` unique constraint on `transactions.extra`:**
|
||||
|
||||
## Animation
|
||||
```ruby
|
||||
add_index :transactions, "(extra -> 'goal' ->> 'pledge_id')",
|
||||
unique: true,
|
||||
where: "(extra -> 'goal' ->> 'pledge_id') IS NOT NULL",
|
||||
name: "ix_transactions_extra_goal_pledge_id"
|
||||
```
|
||||
|
||||
In-chart pending-segment rendering: third style on the existing `area` generator in `goal_projection_chart_controller.js`, approximately 80 LoC delta. 400ms ease-out (`cubic-bezier(0.4, 0, 0.2, 1)`) on solidify. Snap-cut under `prefers-reduced-motion: reduce`. `aria-live="polite"` on the chart wrapper announces "Transfer matched."
|
||||
Mirrors the `entries[:external_id, :source]` partial-unique precedent.
|
||||
|
||||
**7. Drop `goal_contributions`:**
|
||||
|
||||
```ruby
|
||||
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 79–115) and before the `Transaction#save` call.
|
||||
|
||||
```ruby
|
||||
# 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:
|
||||
|
||||
```ruby
|
||||
# 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`):
|
||||
|
||||
```ruby
|
||||
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_type`, `amount_bucket`)
|
||||
- `goal.pledge.matched` (`goal_id`, `account_type`, `time_to_match_seconds`)
|
||||
- `goal.pledge.expired` (`goal_id`, `account_type`)
|
||||
- `goal.pledge.extended` (`goal_id`)
|
||||
- Tune ±5 day / ±1% match window if `expired / created > 0.4` in week one.
|
||||
- `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, or app close. Pause means the pledge segment is doing its job.
|
||||
2. **Borrow-frame test.** Real user who already funds two life goals from one savings account. Walk through linking a second goal to an account that fully backs the first. Signal: does "How much should House borrow?" read as fair or as zero-sum theft?
|
||||
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. **Reconciliation telemetry from day one.** Monitor pledge outcome distribution as above.
|
||||
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, tag-based annotation, auto-fund from budget surplus, FX-aware allocation, family-member-private goals (`goal_activities.visibility` already accommodates), balance-derived weekly-savings indicator.
|
||||
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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Goals: how the balance is computed
|
||||
|
||||
*Posted 2026-05-12. Tied to PR [#1757](https://github.com/we-promise/sure/pull/1757) on branch `feat/savings-goals`. Final cut after five iterations of expert review.*
|
||||
*Posted 2026-05-12. Tied to PR [#1757](https://github.com/we-promise/sure/pull/1757) on branch `feat/savings-goals`. Final cut after five iterations of expert review plus a focused matrix pass and a code-reality audit.*
|
||||
|
||||
A Goal is a target. Its balance is the live balance of the savings accounts linked to it, minus what other goals have claimed from those same accounts. No "log a goal contribution." No parallel ledger.
|
||||
|
||||
@@ -10,22 +10,51 @@ Make a goal called House, target $50K. Link Ally savings ($13K). Goal shows $13K
|
||||
|
||||
## How "saving" still feels like an act
|
||||
|
||||
The action button reads **"I just transferred"** on goals backed by bank-connected accounts, **"I just saved"** on goals backed by manual accounts only. Tap it, enter $500, and the projection chart renders a translucent pending segment from today to seven days out, anchored to your pledged date. When your bank sync posts a matching `Transfer` (within ±5 days, amount within ±$0.50 or ±1%), the segment solidifies in place with a 400ms ease-out. Screen readers announce "Transfer matched." For manual accounts, the pledge resolves on your next manual balance edit and the segment solidifies immediately.
|
||||
The action button reads **"I just transferred"** on goals backed by bank-connected accounts, **"I just saved"** on goals backed by manual accounts only. Tap it, enter $500, and the projection chart renders a translucent pending segment from today to seven days out, anchored to your pledged date. When your bank sync posts a matching transfer (within ±5 days, amount within ±$0.50 or ±1%), the segment solidifies in place with a 400ms ease-out. Screen readers announce "Transfer matched." For manual accounts, the pledge resolves on your next manual balance edit and the segment solidifies immediately.
|
||||
|
||||
If the window expires without a match: "Still planning this transfer? Extend the window 7 days, or mark it done elsewhere."
|
||||
|
||||
A "Refresh sync" button forces an immediate bank pull. UI cooldown is per-goal (60 seconds). The Plaid quota is separate (1/min, 5/hour, 20/day). If the bank bucket is exhausted but the goal's local cooldown isn't, the button reads "Bank refresh limit reached — next slot at 2:14pm."
|
||||
|
||||
## When one account funds two goals
|
||||
## When a goal pulls from multiple accounts (1 × M)
|
||||
|
||||
The split prompt opens as a question: "Ally has $15K. It currently fully backs Emergency Fund. How much should House borrow?"
|
||||
A House goal can be backed by Ally, Chase Savings, and an HSA at the same time. Each linked account is shown in the funding widget as its own row with a 90-day sparkline of that account's contribution to the goal — the user grasps which account is actually doing the work, not just which one has the largest balance.
|
||||
|
||||
Sliders start at proportional-to-remaining-need; open-ended goals' current allocation is the floor. Time-to-target labels update live as you drag. A one-tap "Concentrate on the next deadline" routes everything to the soonest-dated goal.
|
||||
**Defaults at link time depend on subtype.** Checking, savings, and money-market accounts default to "fully count toward the goal." HSA, CD, and other restricted-use subtypes default to **excluded** with a one-tap "include this account" affordance. The misuse cost on those subtypes is high enough that the default flips to honest-by-default.
|
||||
|
||||
Three or more goals on one account: a list of stepper rows with the segment bar above as a read-only summary.
|
||||
**Rows in the funding widget are ordered by recency of net contribution**, not by largest balance. The account doing the saving sits on top; passive holders sink. A thin "last-30 days" overlay on the segment bar shows where the money is flowing now.
|
||||
|
||||
**One pending segment, not three.** When you mark a transfer, the projection chart shows one goal-level pending segment, not one per account. Tapping the dot lists which account the pledge is bound to.
|
||||
|
||||
**Pledge account selection.** The pledge sheet pre-selects the account with the largest matching-direction transfer in the last 14 days into this goal. If two accounts are within 15% of each other on that metric, no pre-selection — both are shown, the user picks.
|
||||
|
||||
**Catch-up copy attributes the slowdown.** When pace slips on a 1×M goal, the banner doesn't say "save $X/mo more." It says "Ally inflow halved last month. Move from checking, or adjust your target?"
|
||||
|
||||
## When one account funds multiple goals (N × 1)
|
||||
|
||||
The split prompt opens as a question, not a default to confirm: "Ally has $15K. It currently fully backs Emergency Fund. How much should House borrow?"
|
||||
|
||||
Above the sliders, a lead line: **"These sliders label your savings. They don't move anything."** Users early in the feature read sliders as "moving money" instead of "labeling intent" — the line resolves that.
|
||||
|
||||
Sliders start at **proportional-to-remaining-need**; open-ended goals' current allocation is the floor. Time-to-target labels update live on each affected dated goal. Two peer affordances live below the sliders:
|
||||
|
||||
- **Concentrate on the next deadline** — all flow to the soonest-dated goal.
|
||||
- **Distribute by deadline urgency** — weight allocations by `1 / months_remaining`. Bigger weight for closer deadlines.
|
||||
|
||||
At 3+ goals on one account: a list of stepper rows replaces the slider stack. The segment bar above the list stays as a read-only summary.
|
||||
|
||||
Joint accounts: splits are proposals the other partner accepts. Every accept, reject, or edit lands in a goal-level activity log with the diff. Selecting a joint account at goal-create surfaces a disclosure: "Goals on shared accounts are visible to everyone on the account."
|
||||
|
||||
## When goals share accounts (N × M, the partial-overlap case)
|
||||
|
||||
Three goals can share Ally while two also share Chase. Each goal's funding widget surfaces a quiet line under the heading: **"Ally also funds 2 other goals."** Overlap becomes explicit at the goal level where decisions are made, not buried at the account level.
|
||||
|
||||
**Editing one goal's accounts triggers a preview diff** before save. "Removing Marcus Invest from Vacation will leave Vacation $2.4K backed on Ally. Continue?" This is the cell where pro-rata under contention turns into a coaching emergency — silent sibling-goal allocation changes are the most-cited frustration with this design pattern.
|
||||
|
||||
**Reallocation flow.** A first-class action: source goal → destination goal → amount → optional reason. Writes two activity rows atomically and shows the segment-bar diff animation on both goals.
|
||||
|
||||
**"Why is this goal behind?" diagnostic.** Tap the chevron on the catch-up callout to expand: "Pace dropped from $1,200/mo to $480/mo in the last 30 days. Ally inflow halved." This is the moment a user either trusts the model or reaches for a spreadsheet.
|
||||
|
||||
## When the math doesn't work out
|
||||
|
||||
"Allocated $10K · Backed by $8.13K · Reserved beyond balance $2K." Pro-rata under contention. When a deposit clears the shortfall on the next sync, a transient toast: "Your paycheck covered House's shortfall."
|
||||
@@ -36,19 +65,21 @@ When you spend from a savings account holding multiple allocations, the post-spe
|
||||
|
||||
## Pace, projection, and windfalls
|
||||
|
||||
Pace is a 90-day rolling average of net `Transfer.exclude` inflow into linked accounts. Top-decile inflows show as annotated dots in the saved area: "counted toward total · tap to include in pace too." Tap the dot to apply a windfall to pace; the dot pulses on first appearance per session.
|
||||
Pace is a 90-day rolling average of net inflow into linked accounts, excluding inter-account transfers (filters on `Transaction#kind NOT IN TRANSFER_KINDS`). Top-decile inflows show as annotated dots in the saved area: "counted toward total · tap to include in pace too." Tap to apply a windfall to pace; the dot pulses on first appearance per session.
|
||||
|
||||
Passive growth on a single linked account — interest, employer match, a deposit that wasn't a real act of saving — gets the same dimmed annotation treatment as windfalls. The user sees the growth but the system doesn't pretend it was earned through goal-directed effort.
|
||||
|
||||
Accounts with less than 90 days of balance history use what's available, down to a 30-day minimum. Below 30: no projection.
|
||||
|
||||
## Unallocated cash and runway
|
||||
|
||||
The `/goals` index shows an "Unallocated" chip in the KPI strip: balance left in savings, HSA, CD, and money-market accounts after every allocation is counted. Checking is excluded because it's operational and would thrash.
|
||||
The `/goals` index shows an "Unallocated" chip in the KPI strip: balance left in savings, HSA, CD, and money-market accounts after every allocation is counted. Checking is excluded because it's operational and would thrash. **Tapping the chip opens a sheet** listing the unallocated amount per account, sorted by size, with each row clickable to "Allocate to a goal." The KPI is a prompt, not just a number.
|
||||
|
||||
Open-ended goals (no target date) show **months-of-runway** instead of progress-to-target — that goal's balance divided by the family's 90-day average monthly outflow, excluding transfers and income. Capped at "12+ months." Below 30 days of outflow history, the chip is hidden rather than guessed at.
|
||||
|
||||
## When an account is closed at the bank
|
||||
|
||||
The account is archived in place, not deleted. It disappears from the global sidebar, family totals, and the linkable-accounts list. It stays visible inside the goal's funding widget as a muted row so the goal's history doesn't break. Restoring it is one tap in settings.
|
||||
The account is moved into a goal-retention state, not deleted. It disappears from the global sidebar, family-level totals, and the linkable-accounts list. It stays visible inside the goal's funding widget as a muted row so the goal's history doesn't break. Restoring it is one tap in settings.
|
||||
|
||||
Auto-archive happens at 180 days no activity AND zero balance, only for goals without a future target date. Calendar-driven goals don't auto-archive. A heads-up appears at 150 days inside the funding widget. Archived accounts have a 30-day reversal grace.
|
||||
|
||||
@@ -58,6 +89,6 @@ Inside the funding widget, each linked account expands into a sparkline of its c
|
||||
|
||||
## What's not in v1
|
||||
|
||||
Priority ordering for the over-allocation split. Tag-based contribution annotation. Auto-fund from budget surplus. FX-aware allocation when the goal and account currencies differ. Family-member-private goals. A balance-derived weekly-savings indicator.
|
||||
Priority ordering for the over-allocation split. Tag-based contribution annotation. Auto-fund from budget surplus. FX-aware allocation when the goal and account currencies differ. Family-member-private goals. A balance-derived weekly-savings indicator. Sibling-goal allocation auto-rebalancing.
|
||||
|
||||
Engineering specifics in the [mechanics doc](goals-architecture-mechanics.md).
|
||||
Engineering specifics, schema deltas, and migration plan in the [mechanics doc](goals-architecture-mechanics.md).
|
||||
|
||||
Reference in New Issue
Block a user