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