diff --git a/docs/notes/goals-architecture-mechanics.md b/docs/notes/goals-architecture-mechanics.md index f774e6f9d..ec326615d 100644 --- a/docs/notes/goals-architecture-mechanics.md +++ b/docs/notes/goals-architecture-mechanics.md @@ -1,72 +1,106 @@ # Goals: account-linked model — engineering mechanics -*Posted 2026-05-12. Companion to [`goals-architecture.md`](goals-architecture.md). Engineering-facing detail behind the user-facing summary.* - -## What changes for users (recap) - -You set a goal. You link the accounts that hold the money for it. Whatever those accounts hold is your goal's balance. No "add contribution" step. The goal updates next time Sure syncs. +*Companion to [`goals-architecture.md`](goals-architecture.md). Final cut after five iterations of expert review.* ## Schema -``` -goals: (unchanged from current branch) -goal_accounts: id, goal_id, account_id, allocated_amount NULL, currency -goal_contributions: dropped +- `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. + +## Computed properties + +```ruby +Goal#allocated = goal_accounts.sum do |ga| + ga.allocation_mode == 'full_balance' ? ga.account.balance : ga.allocated_amount +end + +Goal#backed = GoalBacking.for(family).fetch(goal.id, :backed) ``` -`Goal#allocated`: sum of allocation amounts (NULL means full balance). -`Goal#backed`: pro-rata of allocations against actual account balance when contended. +`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. ## Pro-rata under contention -Account balance B, allocations a₁ … aₙ: +Per linked account with balance B and allocations a₁ … aₙ: - `Σ aᵢ ≤ B`: each goal backed by aᵢ. - `Σ aᵢ > B`: each goal backed by `aᵢ × B / Σ aᵢ`. -Fair-share, no priority. Priority is a v2 question and is explicitly deferred. The schema doesn't lock the door. +Fair-share, no priority. Priority ordering is deferred to v1.1; the schema doesn't need to change to add it. -## Over-allocation +## Pledge reconciliation -When `allocated > backed`, the goal shows: "Allocated $5K · Backed by $3K · Uncovered by $2K." Projection chart and status pill use intent. The user has three one-click affordances: reduce allocation, transfer in, accept. +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. -## Unallocated +Multiple open pledges on one account: nearest-amount-first, then nearest-date. Manual-account pledges reconcile against the user's next balance update. -Per account: `balance − Σ allocations`. Top-level on `/goals`: sum across linked savings. Label: "Unallocated." +## Sync race against split-prompt commit -## Defaults +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. -Single goal on an account, no explicit allocation: `allocated_amount = NULL`, full balance counts. +## Rate limits -Second goal added to the same account triggers a split prompt. Two goals: slider, defaults to 50/50. Three or more: numeric inputs summing to ≤ balance, defaults to equal split. +Two clocks: -## Pace and projection +- **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. -Pace is the rolling 90-day average of total linked-account balance change, weighted by allocation. +## Pace -Accounts with less than 90 days of history use whatever's available, down to a 30-day minimum. The 30-day threshold is where short-term volatility (one payday, one large transfer) stops dominating the slope. Below 30 days: no projection, just the saved area. +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. -Net negative growth over the window: projection line goes flat or down. Status reads "Behind." +Window shrinks to available history above 30 days; below 30, no projection segment. -## Manual accounts +## Snapshot rebuild -A manual account works identically. The user maintains the balance; the goal follows. +`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. -## Un-link and delete +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. -Un-link: allocation row removed, goal balance drops by the allocation amount. +## Account archival -Delete: prompt to re-link or remove the goals. No silent cascade. +`accounts.archived_at = now()` on close. -## Gains and losses +- 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. -- Loss: the "I saved $200 today" ritual. -- Gain: the goal balance never lies. Spend from savings, goal shrinks. -- Loss: the add-contribution modal and live impact preview. -- Gain: the projection chart reflects reality. Income drops, projection drops. -- Loss: roughly 30% of the v1 surface (contribution model, controller, views, Stimulus). -- Gain: no double-entry. What's in the bank is what's in the goal. +`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. -## What stays +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. -Goal model, AASM states, index page, KPI strip, status pills, projection chart visual, color and icon picker, avatar component, AI tool, demo seed. +## Currency + +v1 locks `goal_accounts.currency` to `accounts.currency`. Cross-currency goals deferred to v1.1. + +## Activity log visibility + +`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. + +## Animation + +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." + +## 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. + +## 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? +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. + +## 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. diff --git a/docs/notes/goals-architecture.md b/docs/notes/goals-architecture.md index 2cfbbbfa3..dadaf5083 100644 --- a/docs/notes/goals-architecture.md +++ b/docs/notes/goals-architecture.md @@ -1,57 +1,63 @@ # 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`.* +*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.* -A Goal is a target. The number on its page is the live balance of the savings accounts you link to it, minus what other goals have claimed from those same accounts. - -That's it. No "log a contribution" step. No parallel ledger. +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. ## What this looks like in practice -You make a goal called House, target $50K. You link your Ally savings ($13K). The goal shows $13K, 26% to target. +Make a goal called House, target $50K. Link Ally savings ($13K). Goal shows $13K, 26%. Two months later Ally has grown to $15K because you've been saving — goal shows $15K, 30%. Three months later you transfer $3K out for a car repair — goal shows $12K. The projection chart reflects every change. -Two months later, your Ally savings has grown to $15K because you've been saving. The goal shows $15K, 30%. You did nothing in the app for that to happen. +## How "saving" still feels like an act -Three months later, you transfer $3K out for a car repair. The goal shows $12K. If you were on track before, the projection chart now reflects the setback. +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 -If you also want a Vacation goal funded from Ally, you'll be asked to split it. "Of Ally's $15K, how much for House, how much for Vacation?" Two sliders, or a list if you have three or more goals on the same account. +The split prompt opens as a question: "Ally has $15K. It currently fully backs Emergency Fund. How much should House borrow?" -The split is stored as a dollar allocation per goal per account. +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. + +Three or more goals on one account: a list of stepper rows with the segment bar above 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 the math doesn't work out -If your allocations exceed your balance (House $10K, Vacation $6K, but Ally holds $13K), Sure shows two numbers per goal: "Allocated $10K · Backed by $8.13K." You see the gap and decide what to do: reduce, top up, or accept it. +"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." -The split when over-allocated is pro-rata to allocation. No priority ordering in v1. +When you spend from a savings account holding multiple allocations, the post-spend reconciliation prompt names the allocation that absorbed it and offers a one-tap "restore later." -## What you give up +**Special error state.** Shortfall caused by an archived account: a dedicated banner replaces the catch-up callout. "$7.8K is in an archived account · Restore Ally, or re-link this goal to another account." -The act of logging a contribution. If that was the part of the feature you used, this model removes it. The replacement is watching your account grow. +## Pace, projection, and windfalls -## What you gain +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. -The goal balance can't be a fiction. Spend from your savings, it shrinks. Save more, it grows. The system keeps you honest by construction. +Accounts with less than 90 days of balance history use what's available, down to a 30-day minimum. Below 30: no projection. -## What stays +## Unallocated cash and runway -Status pills, projection chart, color and icon picker, AI assistant tool, demo data, every fix from the last week of work. The only thing changing is how the balance gets computed. +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. -## What's deferred and why +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. -Priority ordering for the over-allocation split. Pro-rata is fair-share; priority would let users say "House first." A real feature, not in v1. +## When an account is closed at the bank -Tag-based contribution annotation (Juanjo's Discord proposal). Annotations on real transactions can layer on later without changing the model. +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. -Auto-fund from budget surplus. Was in the closed PR #1569. Belongs in a Budgets-aware follow-up. +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. -## Still being decided +## Per-goal history -The pace calculation window. 90-day rolling average is the proposal, with a 30-day minimum for short-history accounts. +Inside the funding widget, each linked account expands into a sparkline of its contribution to the goal plus a list of net inflows ≥ $100 with `View transaction` links. This replaces the contributions list. -The split-prompt UX for the second-goal-on-an-account case. Slider vs. inputs, default proportions. +## What's not in v1 -Whether manual accounts should carry any "this is a goal-only ledger" treatment, or just behave like any other account. +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. -Open for feedback. The PR is on hold until the model is settled. Engineering mechanics in the [companion mechanics doc](goals-architecture-mechanics.md). +Engineering specifics in the [mechanics doc](goals-architecture-mechanics.md).