Files
sure/docs/notes/goals-architecture.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

95 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
## What this looks like in practice
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.
## 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.
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 a goal pulls from multiple accounts (1 × M)
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.
**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.
**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."
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."
**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."
## Pace, projection, and windfalls
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. **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 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.
## Per-goal history
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.
## 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. Sibling-goal allocation auto-rebalancing.
Engineering specifics, schema deltas, and migration plan in the [mechanics doc](goals-architecture-mechanics.md).