From d57bdd02e1d1c37aa583c345901a288cf2d567c5 Mon Sep 17 00:00:00 2001 From: ChakibMoMi Date: Sun, 24 May 2026 03:41:18 +0200 Subject: [PATCH] Polish retirement feature: Custom calculator, grouped select, auto-detect, UK progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/controllers/retirement_controller.rb | 4 +- app/models/retirement_config.rb | 26 ++++++++--- .../pension_calculator/custom.rb | 9 ++++ app/views/retirement/_form_fields.html.erb | 10 ++++- app/views/retirement/show.html.erb | 21 +++++++++ config/locales/views/retirement/de.yml | 11 +++++ config/locales/views/retirement/en.yml | 11 +++++ config/locales/views/retirement/es.yml | 11 +++++ config/locales/views/retirement/fr.yml | 11 +++++ db/schema.rb | 9 ++-- .../pension_calculator/de_grv_test.rb | 44 ++++++++++++++++++ .../es_social_security_test.rb | 39 ++++++++++++++++ .../fr_regime_general_test.rb | 39 ++++++++++++++++ .../uk_state_pension_test.rb | 45 +++++++++++++++++++ .../us_social_security_test.rb | 38 ++++++++++++++++ 15 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 app/models/retirement_config/pension_calculator/custom.rb create mode 100644 test/models/retirement_config/pension_calculator/de_grv_test.rb create mode 100644 test/models/retirement_config/pension_calculator/es_social_security_test.rb create mode 100644 test/models/retirement_config/pension_calculator/fr_regime_general_test.rb create mode 100644 test/models/retirement_config/pension_calculator/uk_state_pension_test.rb create mode 100644 test/models/retirement_config/pension_calculator/us_social_security_test.rb diff --git a/app/controllers/retirement_controller.rb b/app/controllers/retirement_controller.rb index 856c9a9da..4d3b8656c 100644 --- a/app/controllers/retirement_controller.rb +++ b/app/controllers/retirement_controller.rb @@ -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 diff --git a/app/models/retirement_config.rb b/app/models/retirement_config.rb index a5407a9c3..11ef2b09d 100644 --- a/app/models/retirement_config.rb +++ b/app/models/retirement_config.rb @@ -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 diff --git a/app/models/retirement_config/pension_calculator/custom.rb b/app/models/retirement_config/pension_calculator/custom.rb new file mode 100644 index 000000000..eeb87e13b --- /dev/null +++ b/app/models/retirement_config/pension_calculator/custom.rb @@ -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 diff --git a/app/views/retirement/_form_fields.html.erb b/app/views/retirement/_form_fields.html.erb index 05d2f09f5..9650f20e9 100644 --- a/app/views/retirement/_form_fields.html.erb +++ b/app/views/retirement/_form_fields.html.erb @@ -45,7 +45,15 @@
<%= 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" } %> diff --git a/app/views/retirement/show.html.erb b/app/views/retirement/show.html.erb index 089c6dab1..1af923223 100644 --- a/app/views/retirement/show.html.erb +++ b/app/views/retirement/show.html.erb @@ -135,6 +135,27 @@
+<%# 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 %> +
+

<%= t(".uk_progress_title") %>

+
+
+ <%= t(".uk_qualifying_years") %> + <%= qualifying_years %> / <%= target_years %> +
+
+
+
+

+ <%= t(".uk_years_remaining", count: [ target_years - qualifying_years, 0 ].max) %> +

+
+
+<% end %> + <%# Pension History %>

<%= t(".pension_history") %>

diff --git a/config/locales/views/retirement/de.yml b/config/locales/views/retirement/de.yml index 272b69662..1036dc427 100644 --- a/config/locales/views/retirement/de.yml +++ b/config/locales/views/retirement/de.yml @@ -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 diff --git a/config/locales/views/retirement/en.yml b/config/locales/views/retirement/en.yml index e83aa4aa7..0acab7825 100644 --- a/config/locales/views/retirement/en.yml +++ b/config/locales/views/retirement/en.yml @@ -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 diff --git a/config/locales/views/retirement/es.yml b/config/locales/views/retirement/es.yml index 04a434ba3..1ff6fefd8 100644 --- a/config/locales/views/retirement/es.yml +++ b/config/locales/views/retirement/es.yml @@ -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. diff --git a/config/locales/views/retirement/fr.yml b/config/locales/views/retirement/fr.yml index 42b2e29ea..44d769db3 100644 --- a/config/locales/views/retirement/fr.yml +++ b/config/locales/views/retirement/fr.yml @@ -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 diff --git a/db/schema.rb b/db/schema.rb index dfbb6be1a..4550680c6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/test/models/retirement_config/pension_calculator/de_grv_test.rb b/test/models/retirement_config/pension_calculator/de_grv_test.rb new file mode 100644 index 000000000..7cee01ade --- /dev/null +++ b/test/models/retirement_config/pension_calculator/de_grv_test.rb @@ -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 diff --git a/test/models/retirement_config/pension_calculator/es_social_security_test.rb b/test/models/retirement_config/pension_calculator/es_social_security_test.rb new file mode 100644 index 000000000..395aef912 --- /dev/null +++ b/test/models/retirement_config/pension_calculator/es_social_security_test.rb @@ -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 diff --git a/test/models/retirement_config/pension_calculator/fr_regime_general_test.rb b/test/models/retirement_config/pension_calculator/fr_regime_general_test.rb new file mode 100644 index 000000000..3191ede7f --- /dev/null +++ b/test/models/retirement_config/pension_calculator/fr_regime_general_test.rb @@ -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 diff --git a/test/models/retirement_config/pension_calculator/uk_state_pension_test.rb b/test/models/retirement_config/pension_calculator/uk_state_pension_test.rb new file mode 100644 index 000000000..b2bd8f5ec --- /dev/null +++ b/test/models/retirement_config/pension_calculator/uk_state_pension_test.rb @@ -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 diff --git a/test/models/retirement_config/pension_calculator/us_social_security_test.rb b/test/models/retirement_config/pension_calculator/us_social_security_test.rb new file mode 100644 index 000000000..6768caaa6 --- /dev/null +++ b/test/models/retirement_config/pension_calculator/us_social_security_test.rb @@ -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