FIRE Planning and Retirement Calculations#
The retirement/FIRE planning feature lets users project their path to Financial Independence using a deterministic, real-money forecast engine. All projections run in today's-money terms (real return, no inflation parameter needed), producing reproducible, easily testable output. The feature is currently preview-gated and ships in two generations:
- v1 (PR #1952) — standalone
RetirementConfigmodel, country-specific pension calculators, dashboard UI - v2 (PRs #2044–#2047) — redesigned on the Goals v2 architecture via
Goal::RetirementSTI, a full pension data plane, and a deterministic FIRE forecast engine
Feature Gating & Entry Point#
Access is controlled by a two-tier gate :
- Global preview flag —
PreviewGateable#require_preview_features!redirects to dashboard if preview is disabled - Family killswitch —
families.retirement_disabledcolumn (defaultfalse); raises 404 whentrue
The sidebar nav entry (sun icon, violet preview dot) is hidden unless Current.family.retirement_enabled?(Current.user) returns true. The shared RetirementScoped concern enforces both tiers across all retirement sub-controllers .
Data Model#
Goal::Retirement (app/models/goal/retirement.rb) is an STI subclass of Goal, scoped to a single user (not family-wide). It uses store_accessor for forecast params: birth_year, retire_age, real_return_pct, monthly_savings, target_spend, terminal_age. It overrides target_amount_required? to false — the forecast engine owns the target computation .
Supporting models added in PR #2046 :
| Model | File | Purpose |
|---|---|---|
PensionSource | app/models/pension_source.rb | A pension income stream; kind (state/workplace/other), country, pension_system, tax_treatment, payout_shape — all string enums (not PG types) to allow future countries without ALTER TYPE migrations |
Goal::RetirementStatement | app/models/goal/retirement_statement.rb | Append-only audit journal of pension statement data; soft-delete via soft_replace! |
Goal::RetirementAdjustment | app/models/goal/retirement_adjustment.rb | Signed today's-money spending adjustments, age-bounded; max 10 per plan (ADJUSTMENTS_LIMIT) |
RetirementBucketEntry | app/models/retirement_bucket_entry.rb | Account-selection join for the investment portfolio "bucket" |
FIRE Forecast Engine#
The engine lives in app/models/retirement/fire/ and is entirely deterministic pure Ruby (no Monte Carlo in v1) .
Key files:
app/models/retirement/fire/forecast.rb— main annual stepper (164 lines)app/models/retirement/fire/inputs.rb— inputs value objectapp/models/retirement/fire/forecast_result.rb— result struct (glide series, KPIs, warnings)app/models/retirement/fire/payout.rb— normalizesPensionSourceto annual income / lump sumapp/models/retirement/fire/adjustment.rb— age-bounded spending deltaapp/models/retirement/fire/cohort_access.rb— minimum pension access ages by countryapp/models/retirement/tax/static_rate.rb— static fraction-kept by treatment, boot-validated viaconfig/initializers/retirement_tax_static.rb
Stepper math :
- Accumulation phase (
age < retire_age):portfolio = portfolio × (1 + real_return) + annual_savings - Drawdown phase (
age ≥ retire_age):portfolio = portfolio × (1 + real_return) + lumps − max(target_spend − net_pension_income, 0)
Outputs: portfolio glide series, money-lasts-to age (when/if portfolio depletes), terminal value at terminal_age, Coast FIRE age, feasibility flag, and depletion warnings.
4% Rule: Capital required to fill a pension gap is annual_gap / 0.04. In v1 this is applied directly; in v2 the drawdown stepper replaces a fixed SWR with portfolio simulation, but the 4% rule remains the underlying conceptual anchor .
Spending baseline: Family#retirement_spending_baseline anchors the default target_spend to the trailing-12-month 10%-trimmed mean monthly expense .
Coast FIRE & Pension Gap Analysis#
Coast FIRE age is computed via bisection on the minimum portfolio balance at retire_age that still sustains drawdown through terminal_age. The result — surfaced as Goal::Retirement#coast_fire_date — is the earliest age at which a user can stop making contributions and still reach their FIRE target through investment growth alone .
Pension gap = target_income − (estimated_pension × (1 − tax_rate)). The capital required to fill that gap uses the 4% rule:
future_annual_gap = (monthly_gap × 12) × (1 + inflation)^years_to_retirement
capital_needed = future_annual_gap / 0.04
In v2, the gap feeds directly into the drawdown leg of the stepper rather than being calculated separately .
Multi-Country Pension Support#
Fire::Payout normalizes any PensionSource into gross annual income plus optional one-time lump sums per age, across four payout_shape variants: monthly_for_life, monthly_fixed_term, lump_sum, lump_plus_annuity .
Fire::CohortAccess encodes minimum pension access ages by country/system :
| Country / System | Min Access Age |
|---|---|
| UK (NMPA) | 55 → 57 from 2028 (pre-2021 cohort protection) |
| US 401k / IRA | 59.5 |
| US Social Security | 62 |
| DE GRV (early) | 63 |
| DE (other) | 55 |
Tax::StaticRate applies a static fraction-kept by tax_treatment (e.g., de_renten varies with cohort year), boot-validated at startup .
String-backed enums for pension_system, tax_treatment, and payout_shape (not PG enum types) mean adding a new country requires no ALTER TYPE migrations .
V1 also shipped dedicated calculators (RetirementConfig::PensionCalculator::*) for DE GRV, US Social Security, UK State Pension, FR Régime Général, ES Social Security, and a Custom fallback .
Live What-If#
The Stimulus controller retirement_what_if_controller.js debounces user input and POSTs a PATCH /retirement/forecast request. The server merges incoming params into @plan.retirement_params via merged_plan_params (filtering blanks to avoid clobbering stored values), runs the forecast engine against the transient inputs, and responds with a Turbo Stream that replaces the retirement_kpis partial — nothing is persisted .
KPI cards displayed: Freedom date, Coast FIRE age, Money-lasts-to age, Terminal portfolio value.
Key Files Reference#
| File | Role |
|---|---|
app/models/goal/retirement.rb | Core plan model (STI subclass of Goal) |
app/models/pension_source.rb | Pension income stream |
app/models/goal/retirement_statement.rb | Append-only pension statement journal |
app/models/goal/retirement_adjustment.rb | Age-bounded spending adjustments |
app/models/retirement_bucket_entry.rb | Portfolio account selection |
app/models/retirement/fire/forecast.rb | Annual stepper — main forecast engine |
app/models/retirement/fire/payout.rb | PensionSource → annual income normalizer |
app/models/retirement/fire/cohort_access.rb | Country min access ages |
app/models/retirement/tax/static_rate.rb | Tax treatment rates |
app/controllers/concerns/retirement_scoped.rb | Shared preview gate + plan loader |
app/javascript/controllers/retirement_what_if_controller.js | Debounced what-if UI |
test/models/retirement/fire/forecast_test.rb | 119-line stepper test suite |
PRs: #1952 (v1) · #2044 (scaffold) · #2046 (data plane) · #2047 (forecast engine)