mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
docs(goals): add llm-guide reference for the goals feature
Captures the architecture, key files, data model, status semantics, pledge match policy, connected-vs-manual account detection, color map convention, common tasks, and known gotchas. Matches the existing llm-guides pattern (architecture diagram + file inventory + task-oriented sections + reproducible commands). The doc is forward-looking: it covers how to add a new field to Goal, a new status branch, a new pledge kind, and how to safely touch the reconciler. The "Gotchas" section catalogues the known-incomplete-but-shipping items so a future audit doesn't re-derive them from scratch. Demo data regeneration command is included for anyone who needs to refresh the seed.
This commit is contained in:
336
docs/llm-guides/goals.md
Normal file
336
docs/llm-guides/goals.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Working with the Goals feature
|
||||
|
||||
Reference for changes to the savings-goals feature. Covers the data
|
||||
model, the surfaces that consume it, the load-bearing invariants, and
|
||||
the gotchas worth knowing before you touch the code.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```text
|
||||
GoalsController#index
|
||||
→ @active_goals = Family.goals.includes(:open_pledges, linked_accounts: :account_providers)
|
||||
→ KPI strip + per-goal cards (Goals::CardComponent)
|
||||
→ pending-pledges callout if any goal has an open pledge
|
||||
|
||||
GoalsController#show
|
||||
→ @goal.open_pledges.reverse_chronological → pending-pledge banners
|
||||
→ progress ring (Goals::ProgressRingComponent)
|
||||
→ projection chart (data-controller="goal-projection-chart")
|
||||
→ Goals::FundingAccountsBreakdownComponent (linked-account rows)
|
||||
→ Notes section if @goal.notes.present?
|
||||
|
||||
GoalPledgesController#create (turbo-frame: modal)
|
||||
→ goal.goal_pledges.new(amount:, account:, kind: kind_for_account(account))
|
||||
→ save! → matches?-loop runs once the next sync arrives
|
||||
|
||||
Account::ProviderImportAdapter#import_transaction
|
||||
→ GoalPledge::Reconciler.new(entry).run (transfer-kind path)
|
||||
Account::ReconciliationManager#reconcile
|
||||
→ GoalPledge::Reconciler.new(prepared_valuation).run (manual_save path)
|
||||
|
||||
SweepExpiredGoalPledgesJob (cron, every 15 minutes)
|
||||
→ GoalPledge.open_and_expired_now.find_each(&:expire!)
|
||||
```
|
||||
|
||||
## Key files
|
||||
|
||||
Model layer:
|
||||
|
||||
- `app/models/goal.rb` — balance, pace, status, projection, color map.
|
||||
- `app/models/goal_pledge.rb` — pledge, match policy, lifecycle.
|
||||
- `app/models/goal_pledge/reconciler.rb` — entry-to-pledge resolver, called from the import adapters.
|
||||
- `app/models/account.rb` — `#manual?` instance method (mirrors the `Account.manual` scope) drives pledge kind detection.
|
||||
- `app/models/family.rb` — `#savings_inflow_velocity` powers the KPI strip.
|
||||
|
||||
Controllers / routes:
|
||||
|
||||
- `app/controllers/goals_controller.rb` — index / show / new / create / edit / update / destroy / pause / resume / complete / archive / unarchive.
|
||||
- `app/controllers/goal_pledges_controller.rb` — new / create / renew / destroy.
|
||||
- `config/routes.rb` — `resources :goals do resources :pledges ... member { patch :renew } end`.
|
||||
|
||||
Views:
|
||||
|
||||
- `app/views/goals/index.html.erb`, `show.html.erb`, `new.html.erb`, `edit.html.erb`.
|
||||
- `app/views/goals/_form_stepper.html.erb`, `_form_edit.html.erb`, `_pending_pledge_banner.html.erb`, `_empty_state.html.erb`, `_color_picker.html.erb`.
|
||||
- `app/views/goal_pledges/new.html.erb`.
|
||||
|
||||
View components:
|
||||
|
||||
- `app/components/goals/card_component.{rb,html.erb}` — goal card on the index.
|
||||
- `app/components/goals/funding_accounts_breakdown_component.{rb,html.erb}` — per-account widget on show.
|
||||
- `app/components/goals/avatar_component.{rb,html.erb}` — colored letter/icon avatar.
|
||||
- `app/components/goals/account_stack_component.{rb,html.erb}` — overlapping account avatars on the card.
|
||||
- `app/components/goals/progress_ring_component.{rb,html.erb}` — show-page ring.
|
||||
- `app/components/goals/status_pill_component.{rb,html.erb}` — status chip.
|
||||
|
||||
Stimulus controllers:
|
||||
|
||||
- `app/javascript/controllers/goal_stepper_controller.js` — two-step create modal.
|
||||
- `app/javascript/controllers/goal_pledge_preview_controller.js` — live amount-impact preview + helper-text toggle.
|
||||
- `app/javascript/controllers/goal_projection_chart_controller.js` — D3 projection chart on show.
|
||||
- `app/javascript/controllers/goals_filter_controller.js` — index filter chips + search, with URL state.
|
||||
|
||||
Schema / migrations:
|
||||
|
||||
- `db/migrate/20260514120000_create_goal_pledges.rb` — table + enums + partial indexes + amount check.
|
||||
- `db/migrate/20260514120001_drop_goal_contributions.rb` — old ledger.
|
||||
- `db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb` — partial unique on `transactions.extra->'goal'->>'pledge_id'`.
|
||||
|
||||
Tests / fixtures:
|
||||
|
||||
- `test/models/goal_test.rb`, `goal_pledge_test.rb`, `goal_pledge/reconciler_test.rb`.
|
||||
- `test/controllers/goals_controller_test.rb`, `goal_pledges_controller_test.rb`.
|
||||
- `test/jobs/sweep_expired_goal_pledges_job_test.rb`.
|
||||
- `test/fixtures/goals.yml`, `goal_accounts.yml`, `goal_pledges.yml`.
|
||||
|
||||
Locales:
|
||||
|
||||
- `config/locales/views/goals/en.yml`, `goal_pledges/en.yml`.
|
||||
- `config/locales/models/goal/en.yml`, `goal_pledge/en.yml`.
|
||||
|
||||
## Data model
|
||||
|
||||
A goal records a name, target amount, optional target date, color, optional
|
||||
icon, optional notes, currency, and an AASM `state` (`active` / `paused` /
|
||||
`completed` / `archived`). It links to depository accounts via the join
|
||||
table `goal_accounts`.
|
||||
|
||||
The goal's *progress* is the live balance of every linked account. There
|
||||
is no ledger of contributions. `Goal#current_balance` reads
|
||||
`linked_accounts.sum(:balance)` at request time.
|
||||
|
||||
A `GoalPledge` is an intent: amount, account, kind, status, expires_at.
|
||||
The status enum is `open` / `matched` / `cancelled` / `expired`. The kind
|
||||
enum is `transfer` / `manual_save`; kind is decided at create time from
|
||||
the selected account's connection state.
|
||||
|
||||
## Status semantics
|
||||
|
||||
`Goal#status` is computed at render time:
|
||||
|
||||
- `:reached` when `progress_percent >= 100`.
|
||||
- `:no_target_date` when `target_date.nil?`.
|
||||
- `:on_track` when the goal has a deadline and `monthly_target_amount <= pace`.
|
||||
- `:behind` otherwise.
|
||||
|
||||
The AASM `state` is independent. Read `Goal#display_status` (not `#status`)
|
||||
to get the right pill label: it returns the AASM state when it's not
|
||||
`:active`, otherwise falls through to `#status`.
|
||||
|
||||
`Goal#pace` is the rolling 90-day net inflow into the linked accounts,
|
||||
divided by three. The query joins `entries` with `transactions`
|
||||
(valuations excluded by join shape), drops excluded entries, and drops
|
||||
pending provider transactions via `Transaction.excluding_pending`. This
|
||||
last filter matters: a pending Plaid deposit that later reverses would
|
||||
otherwise quietly reshape pace.
|
||||
|
||||
`Goal#monthly_target_amount` is `(remaining_amount / months_remaining).ceil(2)`.
|
||||
`months_remaining` uses day precision: `(target_date - Date.current) / 30.0`,
|
||||
clamped at zero. Calendar-month math is wrong here — it produces a cliff
|
||||
in the last 30 days where the required monthly rate spikes.
|
||||
|
||||
`Goal#catch_up_delta_money` returns `max(0, monthly_target - pace -
|
||||
sum_of_open_pledges)`. The show-page catch-up alert hides when this is
|
||||
zero; the pledge CTA inside the alert pre-fills with this delta, so
|
||||
accepting it once funds the gap rather than stacking the full required
|
||||
rate on top.
|
||||
|
||||
## Pledge match window
|
||||
|
||||
`GoalPledge#matches?` checks three things:
|
||||
|
||||
1. The pledge is open.
|
||||
2. The entry is on the pledge's `account_id`.
|
||||
3. The entry's `date` sits in `[created_at - 5d, max(created_at + 5d, expires_at)]`,
|
||||
and the entry's `|amount|` is within `$0.50` or `1%` of the pledge
|
||||
amount, whichever is larger.
|
||||
|
||||
The upper-bound date widens when `extend!` pushes `expires_at` forward.
|
||||
Without that widening, "Extend 7 days" would push the expiry forward but
|
||||
the actual match window would stay anchored at creation.
|
||||
|
||||
The reconciler picks pledges by `(account_id, status: "open", kind:
|
||||
expected_kind, expires_at >= NOW())`. `expected_kind` is `"manual_save"`
|
||||
for valuation entries and `"transfer"` for transactions.
|
||||
|
||||
When a pledge resolves on a transaction, the reconciler stamps
|
||||
`transaction.extra["goal"]["pledge_id"] = pledge.id` and sets
|
||||
`pledge.matched_transaction_id`. Two partial unique indexes enforce
|
||||
single-claim semantics:
|
||||
|
||||
- `goal_pledges (matched_transaction_id) WHERE matched_transaction_id IS NOT NULL`
|
||||
- `transactions ((extra -> 'goal' ->> 'pledge_id')) WHERE (extra -> 'goal' ->> 'pledge_id') IS NOT NULL`
|
||||
|
||||
`Goal#last_matched_pledge_at` joins through `matched_transaction_id` to
|
||||
the entry's `date`, so the show-page header reads the actual entry date,
|
||||
not `goal_pledges.updated_at`. The distinction matters: a sync resync
|
||||
would otherwise touch `updated_at` on every matched pledge and reset the
|
||||
"Last pledge matched N days ago" copy across every goal.
|
||||
|
||||
## Connected vs manual accounts
|
||||
|
||||
`Account#manual?` returns true when the account has no
|
||||
`account_providers` association rows, no `plaid_account_id`, and no
|
||||
`simplefin_account_id`. This mirrors the `Account.manual` query scope.
|
||||
|
||||
`Goal#any_connected_account?` returns true when *any* linked account is
|
||||
not manual. It drives the modal-title copy: connected accounts get
|
||||
"I just transferred…", manual-only goals get "I just saved…"
|
||||
|
||||
`GoalPledgesController#kind_for_account(account)` is per-account:
|
||||
manual → `manual_save`, connected → `transfer`. A goal with one manual
|
||||
and one connected linked account works correctly; the kind reflects the
|
||||
specific account the user picked, not the goal as a whole.
|
||||
|
||||
## Color map
|
||||
|
||||
`Goal#account_color_map` returns `{ account_id => palette_hex }` for the
|
||||
goal's linked accounts, sorted by id and assigned palette colors in
|
||||
order. Three surfaces consume the map: `AccountStackComponent` on the
|
||||
goal card, the distribution bar in the funding widget, and the avatars
|
||||
in the funding widget rows. A given account renders the same color on
|
||||
every surface within a goal.
|
||||
|
||||
Account avatars outside a goal context (the new-goal account checklist)
|
||||
still call `Goals::AvatarComponent.color_for(account.name)`. The
|
||||
mismatch is acceptable because the form is a one-shot picker, not a
|
||||
recurring view.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Adding a new field to `Goal`
|
||||
|
||||
1. Migration: `add_column :goals, :your_field, :type`. Add a partial
|
||||
index if the field is queried.
|
||||
2. Validation: add to `Goal` if presence/range rules apply.
|
||||
3. Strong params: update `goal_params` and `goal_update_params` in
|
||||
`GoalsController`.
|
||||
4. Form: surface in `app/views/goals/_form_stepper.html.erb` (create) and
|
||||
`_form_edit.html.erb` (edit).
|
||||
5. Locales: add labels under `goals.form_stepper.step1.fields.*` and
|
||||
`activerecord.attributes.goal.*`.
|
||||
6. Display: pick the right surface (header on show, secondary line on
|
||||
the card, etc).
|
||||
7. Tests: extend `test/models/goal_test.rb` for validation; controller
|
||||
tests for the form-param flow.
|
||||
|
||||
### Adding a new status to `Goal#status`
|
||||
|
||||
The enum is implicit in the method body (symbol returns); adding a
|
||||
state means touching:
|
||||
|
||||
1. `Goal#status` to return the new symbol from the right branch.
|
||||
2. `Goal#display_status` if the new status interacts with the AASM
|
||||
states.
|
||||
3. `Goals::StatusPillComponent::VARIANTS` to add the chip styling
|
||||
(classes + icon).
|
||||
4. `Goals::CardComponent#footer_line` if the footer copy depends.
|
||||
5. `GoalsController#kpi_payload` if the KPI strip counts it.
|
||||
6. `config/locales/views/goals/en.yml` under `goals.status.*` for the
|
||||
pill label, plus chip and subtitle keys if the new status filters
|
||||
on the index.
|
||||
7. `Goals::StatusPillComponent#status_key` and the goal-filter Stimulus
|
||||
controller (`data-status="..."` on chips) if the new status filters.
|
||||
|
||||
### Adding a new pledge kind
|
||||
|
||||
The kind is a Postgres enum (`goal_pledge_kind`) backing the
|
||||
`GoalPledge#kind` attribute. Adding a new value:
|
||||
|
||||
1. Migration: `ALTER TYPE goal_pledge_kind ADD VALUE 'your_kind'`.
|
||||
This is irreversible in Postgres; consider whether you really need a
|
||||
new kind versus a different match strategy on an existing one.
|
||||
2. `GoalPledge::KINDS` constant.
|
||||
3. `GoalPledgesController#kind_for_account` if the new kind has a
|
||||
per-account trigger.
|
||||
4. `GoalPledge::Reconciler#expected_kind` if the new kind matches a
|
||||
different entry shape.
|
||||
5. Locale + modal helper text in `goal_pledges.new.helper_*`.
|
||||
|
||||
### Touching the reconciler
|
||||
|
||||
The reconciler is hot — every imported transaction across every provider
|
||||
calls it. Things to watch:
|
||||
|
||||
- The outer `rescue StandardError` is protective: an unexpected raise
|
||||
here would break the importer for every account. Keep the rescue, but
|
||||
forward to Sentry so the underlying bug stays visible.
|
||||
- The inner rescue catches `NotOpenError`, `RecordInvalid`, and
|
||||
`RecordNotUnique`. These cover the known race conditions (another
|
||||
worker claimed the pledge first; another pledge claimed the
|
||||
transaction first). Adding new exception classes here should be a
|
||||
deliberate decision.
|
||||
- The `find_each` loop returns from the method on first successful
|
||||
resolve. On a rescued failure it falls through to the next candidate
|
||||
pledge.
|
||||
|
||||
## Gotchas
|
||||
|
||||
The same depository account can fund two goals. Both will read the
|
||||
full balance and double-count progress toward their targets. This is a
|
||||
known limitation; an allocation primitive that splits the balance
|
||||
proportionally (or by explicit user weights) would be the way out.
|
||||
|
||||
`Goal#pace` includes paychecks, rent, debit-card spend — anything on
|
||||
the linked account. For a goal linked to primary checking, the metric
|
||||
matches "net change in balance," not "intentional savings." A user
|
||||
living paycheck-to-paycheck shows near-zero pace even when they
|
||||
consciously transfer money in. Isolating intentional savings would need
|
||||
transfer-pair detection.
|
||||
|
||||
Status transitions on a single sub-pace month. The current behaviour is
|
||||
honest but jarring; a two-month moving condition or a recovery banner
|
||||
would soften the "great for five months, vacation in June, suddenly
|
||||
Behind" case.
|
||||
|
||||
Light-mode contrast on pale palette entries is weak against
|
||||
`bg-container`. The fix lives in the design system, not in the goal
|
||||
feature. The distribution bar segments and the goal-card ring are the
|
||||
visible surfaces.
|
||||
|
||||
`Goal#balance_series_values` rescues `StandardError` and logs to Sentry
|
||||
when `Balance::ChartSeriesBuilder` raises. The chart degrades to
|
||||
target-line-only rather than 500ing. If you're debugging "why is the
|
||||
projection saved-line empty," check Sentry first.
|
||||
|
||||
## Demo data
|
||||
|
||||
`Demo::Generator#generate_goals!` seeds nine goals chosen to surface
|
||||
every state on at least one card:
|
||||
|
||||
- Active + computed status: `:reached`, `:on_track`, `:behind`,
|
||||
`:no_target_date`, plus a past-due active goal that exercises the
|
||||
"was due" header copy.
|
||||
- AASM: paused, archived, completed.
|
||||
- Two open pledges (banner + index callout).
|
||||
- One matched pledge bound to a real recent inflow transaction
|
||||
(exercises the "Last pledge matched N days ago" header).
|
||||
|
||||
Routing goals to different account pools (primary checking holds
|
||||
the bulk of the balance; secondary checking holds a tenth) is what
|
||||
forces certain goals to land below their target instead of overshooting.
|
||||
If you change the demo's account balances, the goal targets need to
|
||||
move too.
|
||||
|
||||
To regenerate from scratch:
|
||||
|
||||
```sh
|
||||
bundle exec rails db:drop db:create db:schema:load
|
||||
SKIP_CLEAR=1 bundle exec rake demo_data:default
|
||||
```
|
||||
|
||||
`SKIP_CLEAR=0` clears existing data first; on a freshly-loaded schema
|
||||
the clear step has known issues with the `trades` constraint so the
|
||||
`SKIP_CLEAR=1` path is the reliable one.
|
||||
|
||||
## Background processes
|
||||
|
||||
`SweepExpiredGoalPledgesJob` runs every 15 minutes via sidekiq-cron
|
||||
(`config/schedule.yml`). It scans `GoalPledge.open_and_expired_now` and
|
||||
flips matching rows to `expired`.
|
||||
|
||||
`GoalPledge::Reconciler` runs synchronously inside the existing import
|
||||
pipeline; it is not a separate job. Any provider sync (Plaid,
|
||||
SimpleFIN, Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) and
|
||||
any manual balance reconciliation feeds through `Account::ProviderImportAdapter`
|
||||
or `Account::ReconciliationManager` and trips the reconciler hook.
|
||||
Reference in New Issue
Block a user