Polish retirement feature: Custom calculator, grouped select, auto-detect, UK progress

- Extract Custom pension calculator class (was mapped to Base, which raises
  NotImplementedError — now explicit and safe)
- Remove if/custom short-circuit from estimated_monthly_pension — all systems
  go through pension_calculator uniformly
- Add PENSION_SYSTEM_GROUPS constant for grouped UI select and
  COUNTRY_TO_PENSION_SYSTEM + suggest_pension_system(country) for auto-detection
- Setup action pre-selects pension system and country from family profile
- Form dropdown now uses grouped_options_for_select grouped by region
- Show page displays UK State Pension qualifying-years progress bar for uk_sp
- Add pension_system_groups i18n keys in EN/DE/ES/FR
- Add UK progress i18n keys (uk_progress_title, uk_qualifying_years,
  uk_years_remaining) in all four locales
- Add 5 calculator unit tests (DE, US, UK, FR, ES) and update schema.rb
  to reflect GeneralizePensionSystems migration (pension_params JSONB,
  data JSONB, current_points nullable, old DE-specific columns removed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ChakibMoMi
2026-05-24 03:41:18 +02:00
parent 4f3230c904
commit d57bdd02e1
15 changed files with 315 additions and 13 deletions

View File

@@ -9,7 +9,9 @@ class RetirementController < ApplicationController
def setup
@retirement_config = Current.family.retirement_config || Current.family.build_retirement_config(
birth_year: 1990,
currency: Current.family.currency
currency: Current.family.currency,
country: Current.family.country,
pension_system: RetirementConfig.suggest_pension_system(Current.family.country)
)
end

View File

@@ -1,6 +1,6 @@
class RetirementConfig < ApplicationRecord
PENSION_SYSTEMS = {
"custom" => { calculator: "RetirementConfig::PensionCalculator::Base" },
"custom" => { calculator: "RetirementConfig::PensionCalculator::Custom" },
"de_grv" => { calculator: "RetirementConfig::PensionCalculator::DeGrv" },
"us_ss" => { calculator: "RetirementConfig::PensionCalculator::UsSocialSecurity" },
"uk_sp" => { calculator: "RetirementConfig::PensionCalculator::UkStatePension" },
@@ -8,8 +8,27 @@ class RetirementConfig < ApplicationRecord
"es_ss" => { calculator: "RetirementConfig::PensionCalculator::EsSocialSecurity" }
}.freeze
PENSION_SYSTEM_GROUPS = {
"europe" => %w[de_grv fr_regime es_ss],
"united_kingdom" => %w[uk_sp],
"north_america" => %w[us_ss],
"other" => %w[custom]
}.freeze
COUNTRY_TO_PENSION_SYSTEM = {
"DE" => "de_grv", "AT" => "de_grv",
"US" => "us_ss", "CA" => "us_ss",
"GB" => "uk_sp",
"FR" => "fr_regime",
"ES" => "es_ss"
}.freeze
SAFE_WITHDRAWAL_RATE = 0.04
def self.suggest_pension_system(country)
COUNTRY_TO_PENSION_SYSTEM.fetch(country.to_s.upcase, "custom")
end
belongs_to :family
has_many :pension_entries, dependent: :destroy
@@ -36,12 +55,7 @@ class RetirementConfig < ApplicationRecord
current_age >= retirement_age
end
# Delegates pension estimation to the active calculator
def estimated_monthly_pension
if pension_system == "custom"
return latest_pension_entry&.projected_monthly_pension || 0
end
pension_calculator.estimated_monthly_pension
end

View File

@@ -0,0 +1,9 @@
class RetirementConfig
module PensionCalculator
class Custom < Base
def estimated_monthly_pension
latest_entry&.projected_monthly_pension || 0
end
end
end
end

View File

@@ -45,7 +45,15 @@
<div>
<label for="retirement_config_pension_system" class="block text-sm font-medium text-secondary mb-1"><%= t(".pension_system") %></label>
<%= f.select :pension_system,
RetirementConfig::PENSION_SYSTEMS.keys.map { |k| [t("retirement.pension_systems.#{k}", default: k.humanize), k] },
grouped_options_for_select(
RetirementConfig::PENSION_SYSTEM_GROUPS.map do |group_key, systems|
[
t("retirement.pension_system_groups.#{group_key}"),
systems.map { |k| [ t("retirement.pension_systems.#{k}", default: k.humanize), k ] }
]
end,
f.object&.pension_system
),
{},
class: "form-select w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2",
data: { pension_system_target: "select", action: "change->pension-system#toggle" } %>

View File

@@ -135,6 +135,27 @@
</div>
</div>
<%# System-specific progress (UK State Pension) %>
<% if @retirement_config.pension_system == "uk_sp" %>
<% qualifying_years = @retirement_config.pension_param("qualifying_years").to_i %>
<% target_years = 35 %>
<div class="bg-container shadow-border-xs rounded-lg p-6 mb-8">
<h2 class="text-lg font-medium text-primary mb-4"><%= t(".uk_progress_title") %></h2>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t(".uk_qualifying_years") %></span>
<span class="text-primary font-medium"><%= qualifying_years %> / <%= target_years %></span>
</div>
<div class="w-full bg-surface-inset rounded-full h-3">
<div class="bg-primary h-3 rounded-full transition-all" style="width: <%= [ (qualifying_years.to_f / target_years * 100), 100 ].min.round %>%"></div>
</div>
<p class="text-xs text-secondary">
<%= t(".uk_years_remaining", count: [ target_years - qualifying_years, 0 ].max) %>
</p>
</div>
</div>
<% end %>
<%# Pension History %>
<div class="bg-container shadow-border-xs rounded-lg p-6">
<h2 class="text-lg font-medium text-primary mb-4"><%= t(".pension_history") %></h2>

View File

@@ -41,6 +41,12 @@ de:
no_entries: Noch keine Renteneinträge vorhanden
no_entries_hint: Füge Einträge aus deiner Renteninformation hinzu, um deinen Rentenfortschritt zu verfolgen
setup_required: Bitte richte zuerst die Ruhestandsplanung ein
uk_progress_title: Rentenanspruch Fortschritt
uk_qualifying_years: Anspruchsjahre (NI)
uk_years_remaining:
zero: Alle Anspruchsjahre erreicht — volle State Pension!
one: "Noch %{count} Jahr für die volle State Pension"
other: "Noch %{count} Jahre für die volle State Pension"
form_fields: &retirement_form_fields_de
personal_section: Persönliche Angaben
birth_year: Geburtsjahr
@@ -98,6 +104,11 @@ de:
pension_entry_added: Renteneintrag erfolgreich hinzugefügt
destroy_pension_entry:
pension_entry_removed: Renteneintrag entfernt
pension_system_groups:
europe: Europa
united_kingdom: Vereinigtes Königreich
north_america: Nordamerika
other: Sonstiges
pension_systems:
de_grv: Deutsche Gesetzliche Rentenversicherung (GRV)
us_ss: US-Sozialversicherung

View File

@@ -41,6 +41,12 @@ en:
no_entries: No pension entries yet
no_entries_hint: Add entries from your pension statements to track your progress
setup_required: Please set up retirement planning first
uk_progress_title: State Pension Progress
uk_qualifying_years: Qualifying Years (NI)
uk_years_remaining:
zero: All qualifying years reached — full State Pension!
one: "%{count} more year needed for full State Pension"
other: "%{count} more years needed for full State Pension"
form_fields: &retirement_form_fields_en
personal_section: Personal Information
birth_year: Birth Year
@@ -98,6 +104,11 @@ en:
pension_entry_added: Pension entry added successfully
destroy_pension_entry:
pension_entry_removed: Pension entry removed
pension_system_groups:
europe: Europe
united_kingdom: United Kingdom
north_america: North America
other: Other
pension_systems:
de_grv: German Statutory Pension (GRV)
us_ss: US Social Security

View File

@@ -41,6 +41,12 @@ es:
no_entries: Aún no hay entradas de pensión
no_entries_hint: Añade entradas de tus informes de pensión para seguir tu progreso
setup_required: Por favor configura primero la planificación de jubilación
uk_progress_title: Progreso de Pensión Estatal
uk_qualifying_years: Años Calificados (NI)
uk_years_remaining:
zero: "¡Todos los años calificados alcanzados — State Pension completa!"
one: "%{count} año más para la State Pension completa"
other: "%{count} años más para la State Pension completa"
form_fields: &retirement_form_fields_es
personal_section: Información Personal
birth_year: Año de Nacimiento
@@ -92,6 +98,11 @@ es:
pension_entry_added: Entrada de pensión añadida correctamente
destroy_pension_entry:
pension_entry_removed: Entrada de pensión eliminada
pension_system_groups:
europe: Europa
united_kingdom: Reino Unido
north_america: Norteamérica
other: Otros
pension_systems:
de_grv: Pensión Estatal Alemana (GRV)
us_ss: Seguridad Social de EE.UU.

View File

@@ -41,6 +41,12 @@ fr:
no_entries: Aucune entrée de pension
no_entries_hint: Ajoutez des entrées de vos relevés de pension pour suivre votre progression
setup_required: Veuillez d'abord configurer la planification de retraite
uk_progress_title: Progression de la Pension d'État
uk_qualifying_years: Années de Qualification (NI)
uk_years_remaining:
zero: "Toutes les années atteintes — Pension d'État complète !"
one: "%{count} an de plus pour la Pension d'État complète"
other: "%{count} ans de plus pour la Pension d'État complète"
form_fields: &retirement_form_fields_fr
personal_section: Informations Personnelles
birth_year: Année de Naissance
@@ -92,6 +98,11 @@ fr:
pension_entry_added: Entrée de pension ajoutée avec succès
destroy_pension_entry:
pension_entry_removed: Entrée de pension supprimée
pension_system_groups:
europe: Europe
united_kingdom: Royaume-Uni
north_america: Amérique du Nord
other: Autre
pension_systems:
de_grv: Pension Légale Allemande (GRV)
us_ss: Sécurité Sociale Américaine

9
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do
ActiveRecord::Schema[7.2].define(version: 2026_04_09_100000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -1063,10 +1063,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do
create_table "pension_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "retirement_config_id", null: false
t.date "recorded_at", null: false
t.decimal "current_points", precision: 8, scale: 4, null: false
t.decimal "current_points", precision: 8, scale: 4
t.decimal "current_monthly_pension", precision: 19, scale: 4
t.decimal "projected_monthly_pension", precision: 19, scale: 4
t.text "notes"
t.jsonb "data", null: false, default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["retirement_config_id", "recorded_at"], name: "index_pension_entries_on_retirement_config_id_and_recorded_at", unique: true
@@ -1173,9 +1174,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do
t.decimal "inflation_pct", precision: 5, scale: 2, null: false, default: "2.0"
t.decimal "tax_rate_pct", precision: 5, scale: 2, null: false, default: "26.38"
t.decimal "current_monthly_savings", precision: 19, scale: 4, null: false, default: "0.0"
t.integer "contribution_start_year"
t.decimal "expected_annual_points", precision: 5, scale: 2
t.decimal "rentenwert", precision: 8, scale: 2
t.jsonb "pension_params", null: false, default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_retirement_configs_on_family_id", unique: true

View File

@@ -0,0 +1,44 @@
require "test_helper"
class RetirementConfig::PensionCalculator::DeGrvTest < ActiveSupport::TestCase
setup do
@config = retirement_configs(:dylan_retirement)
@calculator = RetirementConfig::PensionCalculator::DeGrv.new(@config)
end
test "uses projected_monthly_pension from latest entry when available" do
result = @calculator.estimated_monthly_pension
assert_equal 1850.0, result
end
test "calculates from points and rentenwert when no entry present" do
@config.pension_entries.delete_all
@config.instance_variable_set(:@latest_pension_entry, nil)
@config.pension_params = { "expected_annual_points" => 1.0, "rentenwert" => 39.32 }
result = @calculator.estimated_monthly_pension
expected = (0 + 1.0 * @config.years_to_retirement) * 39.32
assert_in_delta expected, result, 0.01
end
test "falls back to default rentenwert when not in pension_params" do
@config.pension_entries.delete_all
@config.instance_variable_set(:@latest_pension_entry, nil)
@config.pension_params = { "expected_annual_points" => 1.0 }
result = @calculator.estimated_monthly_pension
expected = (0 + 1.0 * @config.years_to_retirement) * RetirementConfig::PensionCalculator::DeGrv::DEFAULT_RENTENWERT
assert_in_delta expected, result, 0.01
end
test "is points_based" do
assert @calculator.points_based?
end
test "param_definitions returns expected keys" do
keys = RetirementConfig::PensionCalculator::DeGrv.param_definitions.map { |d| d[:key] }
assert_includes keys, "expected_annual_points"
assert_includes keys, "rentenwert"
assert_includes keys, "contribution_start_year"
end
end

View File

@@ -0,0 +1,39 @@
require "test_helper"
class RetirementConfig::PensionCalculator::EsSocialSecurityTest < ActiveSupport::TestCase
setup do
@config = retirement_configs(:us_retirement)
@config.pension_system = "es_ss"
@config.pension_params = { "estimated_monthly_pension" => 1400.0, "contribution_years" => 30 }
@calculator = RetirementConfig::PensionCalculator::EsSocialSecurity.new(@config)
end
test "returns estimated_monthly_pension from pension_params" do
assert_equal 1400.0, @calculator.estimated_monthly_pension
end
test "returns 0 when no pension configured and no entry" do
@config.pension_params = {}
assert_equal 0, @calculator.estimated_monthly_pension
end
test "uses projected_monthly_pension from entry when available" do
entry = @config.pension_entries.build(
recorded_at: Date.current,
projected_monthly_pension: 1500.0
)
entry.save!
calc = RetirementConfig::PensionCalculator::EsSocialSecurity.new(@config)
assert_equal 1500.0, calc.estimated_monthly_pension
end
test "is not points_based" do
assert_not @calculator.points_based?
end
test "param_definitions includes contribution_years and estimated_monthly_pension" do
keys = RetirementConfig::PensionCalculator::EsSocialSecurity.param_definitions.map { |d| d[:key] }
assert_includes keys, "contribution_years"
assert_includes keys, "estimated_monthly_pension"
end
end

View File

@@ -0,0 +1,39 @@
require "test_helper"
class RetirementConfig::PensionCalculator::FrRegimeGeneralTest < ActiveSupport::TestCase
setup do
@config = retirement_configs(:us_retirement)
@config.pension_system = "fr_regime"
@config.pension_params = { "estimated_monthly_pension" => 1600.0, "trimestres" => 120 }
@calculator = RetirementConfig::PensionCalculator::FrRegimeGeneral.new(@config)
end
test "returns estimated_monthly_pension from pension_params" do
assert_equal 1600.0, @calculator.estimated_monthly_pension
end
test "returns 0 when no pension configured and no entry" do
@config.pension_params = {}
assert_equal 0, @calculator.estimated_monthly_pension
end
test "uses projected_monthly_pension from entry when available" do
entry = @config.pension_entries.build(
recorded_at: Date.current,
projected_monthly_pension: 1700.0
)
entry.save!
calc = RetirementConfig::PensionCalculator::FrRegimeGeneral.new(@config)
assert_equal 1700.0, calc.estimated_monthly_pension
end
test "is not points_based" do
assert_not @calculator.points_based?
end
test "param_definitions includes trimestres and estimated_monthly_pension" do
keys = RetirementConfig::PensionCalculator::FrRegimeGeneral.param_definitions.map { |d| d[:key] }
assert_includes keys, "trimestres"
assert_includes keys, "estimated_monthly_pension"
end
end

View File

@@ -0,0 +1,45 @@
require "test_helper"
class RetirementConfig::PensionCalculator::UkStatePensionTest < ActiveSupport::TestCase
setup do
@config = retirement_configs(:us_retirement)
@config.pension_system = "uk_sp"
@config.pension_params = { "qualifying_years" => 35, "full_weekly_rate" => 221.20 }
@calculator = RetirementConfig::PensionCalculator::UkStatePension.new(@config)
end
test "calculates full pension for 35 qualifying years" do
result = @calculator.estimated_monthly_pension
expected = (35.0 / 35.0) * 221.20 * 52 / 12
assert_in_delta expected, result, 0.01
end
test "calculates partial pension for fewer qualifying years" do
@config.pension_params = { "qualifying_years" => 20, "full_weekly_rate" => 221.20 }
result = @calculator.estimated_monthly_pension
expected = (20.0 / 35.0) * 221.20 * 52 / 12
assert_in_delta expected, result, 0.01
end
test "returns 0 when no qualifying years" do
@config.pension_params = { "qualifying_years" => 0 }
assert_equal 0, @calculator.estimated_monthly_pension
end
test "falls back to default weekly rate" do
@config.pension_params = { "qualifying_years" => 35 }
result = @calculator.estimated_monthly_pension
expected = 1.0 * RetirementConfig::PensionCalculator::UkStatePension::FULL_WEEKLY_RATE * 52 / 12
assert_in_delta expected, result, 0.01
end
test "is not points_based" do
assert_not @calculator.points_based?
end
test "param_definitions includes qualifying_years and full_weekly_rate" do
keys = RetirementConfig::PensionCalculator::UkStatePension.param_definitions.map { |d| d[:key] }
assert_includes keys, "qualifying_years"
assert_includes keys, "full_weekly_rate"
end
end

View File

@@ -0,0 +1,38 @@
require "test_helper"
class RetirementConfig::PensionCalculator::UsSocialSecurityTest < ActiveSupport::TestCase
setup do
@config = retirement_configs(:us_retirement)
@calculator = RetirementConfig::PensionCalculator::UsSocialSecurity.new(@config)
end
test "returns estimated_monthly_benefit from pension_params" do
result = @calculator.estimated_monthly_pension
assert_equal 2800.0, result
end
test "returns 0 when no benefit configured and no entry" do
@config.pension_params = {}
result = @calculator.estimated_monthly_pension
assert_equal 0, result
end
test "uses projected_monthly_pension from entry when available" do
entry = @config.pension_entries.build(
recorded_at: Date.current,
projected_monthly_pension: 3000.0
)
entry.save!
calc = RetirementConfig::PensionCalculator::UsSocialSecurity.new(@config)
assert_equal 3000.0, calc.estimated_monthly_pension
end
test "is not points_based" do
assert_not @calculator.points_based?
end
test "param_definitions includes estimated_monthly_benefit" do
keys = RetirementConfig::PensionCalculator::UsSocialSecurity.param_definitions.map { |d| d[:key] }
assert_includes keys, "estimated_monthly_benefit"
end
end