Commit Graph

6 Commits

Author SHA1 Message Date
Guillem Arias
ec6fc1d685 feat(retirement): PR4b "Why this target?" card + trimmed-mean anchor
- IncomeStatement#trimmed_mean_expense(months:, trim_pct:) — trailing-N-
  month mean monthly expense with the top/bottom trim_pct% of months
  dropped, so one-off spikes don't skew the anchor. Family#retirement_
  spending_baseline now uses it (was median).
- Goal::Retirement#fi_number — 25× the annual target (4% rule).
- "Why this target?" card on the show page: Last-12-months anchor →
  Target → FI number (25×), with a "Use my average" button that sets
  target_spend to the trimmed-mean baseline. Money is privacy-sensitive.
- Header gains a green-dot "Active plan" DS::Pill badge when projectable.

Tests: trimmed_mean returns non-negative; fi_number = 25× annual target;
baseline returns Money. Rubocop + erb_lint clean.
2026-05-29 12:34:40 +02:00
Guillem Arias
ee9f5d8b63 feat(retirement): PR4a glide-path chart (D3)
The dashboard centerpiece. Goal::Retirement#glide_payload derives, from
the forecast, the active-plan series + a zero-savings (Walletburst)
shadow + a ±1pp real-return band + the per-age income breakdown for the
hover tooltip + lump markers + the retire/Coast crossover points (three
extra deterministic Forecast runs; cheap).

retirement_glide_chart_controller (D3, mirrors goal_projection_chart's
import / ResizeObserver / theme-observer idiom): portfolio-by-age line +
area, accumulation/drawdown phase shading, the ±1pp band cone, the
dashed Walletburst shadow, a "Retire · age N / $X" chip on the retire
line, a blue Coast crossover ring, purple lump bars, and a hover tooltip
(PR #2029 bg-container/rounded-xl/shadow style) showing the monthly
State / Workplace / Drawdown breakdown + Total-vs-target with a Covered
badge. Wired into the show page above the what-if; container is
privacy-sensitive.

Browser-verified: renders the band, shading, retire chip ($571K), Coast
dot, and shadow against the demo plan. glide_payload + lump_markers
unit-tested. Rubocop + erb_lint + biome clean.

Remaining for PR4: DS::SelectableCard bucket, "Why this target?" anchor
card, skinned DS::Dialog deletes, DE locale, demo seed, system test.
2026-05-29 11:58:56 +02:00
Guillem Arias
36a43f3a35 feat(retirement): PR3a FIRE forecast engine (deterministic, real-terms)
Pure-Ruby projection engine for a single plan. Models in real
(today's-money) terms: portfolio grows at the real return, spending and
pension incomes are held in today's money, so no inflation parameter is
needed and output is fully deterministic. v2 swaps in a Sidekiq Monte
Carlo behind the same call interface.

POROs (app/models/retirement/):
- Fire::Forecast — annual stepper. Accumulate (×(1+r)+savings) to
  retire_age, then draw down max(target − net pension income, 0) with
  lump payouts as portfolio deltas. Computes the glide series,
  money-lasts-to age, terminal value, Coast FIRE age (bisection on the
  minimum survivable portfolio at retirement), feasibility, warnings.
- Fire::Payout — normalises a PensionSource to gross annual income +
  one-time lump per age, across the four payout shapes.
- Fire::Adjustment — age-bounded signed change to the spending target.
- Fire::CohortAccess — min access age (UK NMPA 55→57 from 2028, US
  59.5/62, DE 63/55).
- Tax::StaticRate (+ initializer) — v1 fraction-kept by treatment;
  de_renten falls with the cohort year. Boot-validated against the
  PensionSource enum.

Wiring: Goal::Retirement gains retirement_params store_accessor
(birth_year, retire_age, real_return_pct, monthly_savings, target_spend,
terminal_age), bucket_value, payouts, forecast_inputs, memoised
#forecast (nil until birth_year set), freedom_date, coast_fire_date.
Family#retirement_spending_baseline anchors the default target on the
median monthly expense (the precise trailing-12m 10%-trimmed mean +
its label ship with PR4's "Why this target?" card).

Tests: 28 — exact zero-return stepper checks (accumulation + depletion
with shortfall), pension-covered no-drawdown, tax widening the
drawdown, adjustment lowering the target, Coast extremes, infeasible
warnings, plus tax/payout/cohort units and the model wiring. No new
gems.
2026-05-29 11:31:06 +02:00
Guillem Arias
47f441afbc fix(retirement): allow null target_amount for retirement plans
for_owner bootstraps a Goal::Retirement before any target exists, but
goals.target_amount was NOT NULL at the DB level — the target_amount_required?
hook only dropped the AR validation. Creating a plan for a user with no
existing record (the demo user, caught in a live browser smoke) raised
PG::NotNullViolation. Tests missed it because for_owner(family_admin)
finds the retirement_bob fixture and never inserts.

Relaxes the column to nullable and re-asserts the guarantee for savings
goals via a type-aware check (type <> 'Goal' OR target_amount IS NOT NULL),
so base Goal rows still require a target at the DB level. Adds tests that
exercise the create path (a user with no fixture plan).
2026-05-29 10:53:00 +02:00
Guillem Arias
bf0f10c21f feat(retirement): PR2 data models — pension sources, statements, bucket
Data plane for Retirement v2 (no FIRE math yet — that is PR3). Five
migrations + four AR models, wired to Goal::Retirement.

Models:
- PensionSource — state/workplace/other source with country, pension
  system, tax treatment, payout shape (string-backed + inclusion
  validations rather than PG enums, so v2 can add countries without
  ALTER TYPE). monetize :amount; end_age required for fixed-term.
- Goal::RetirementStatement — append-only audit journal. default_scope
  excludes soft-deleted rows; soft_replace! does soft-delete + insert;
  points_delta drives the "—"/signed Δ column; monetize against
  projected_currency.
- Goal::RetirementAdjustment — signed today's-money deltas to the
  spending target, ordered, applicable_at?(age).
- RetirementBucketEntry — account selection join, unique per plan,
  same-family guard.

Goal::Retirement gains the four associations + bucket_accounts and an
ADJUSTMENTS_LIMIT (10) cap. retirement_params jsonb added to goals for
PR3 plan settings.

Namespaced fixture classes mapped via set_fixture_class so the
goal_retirement association resolves. Minimal fixtures + model tests
(112 runs green, incl. goal/family/controller regression sweep). No
new gems.
2026-05-29 10:36:18 +02:00
Guillem Arias
ca73a2f389 feat(retirement): PR1 scaffold + preview-gated /retirement page
Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.

- STI on goals: add `type` (default "Goal") + `user_id` columns;
  partial index for `Goal::Retirement` rows; check constraint
  requiring an owner on retirement rows. Existing goals backfill to
  `type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
  `editable_by?` narrowed to owner-only. Parent depository-only
  linked-account validations no-op'd; PR2 introduces
  `RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
  `Family#retirement_enabled?(user)` helper as tier 2 of the gate.
  Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
  `ensure_module_enabled!` then a placeholder body. Unknown to users
  without preview features; 404 when the family killswitch is on (the
  feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
  preview features AND the family has retirement enabled, so the
  killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
  and the new `owner.must_belong_to_family` validation message under
  the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
  validations, `editable_by?` on both Goal and Goal::Retirement, gate
  matrix on the controller, nav-item visibility under both preview and
  family flags, base-row STI backfill.

Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
2026-05-29 09:24:47 +02:00