Generalize pension system: multi-country strategy pattern

- Add JSONB pension_params column to retirement_configs
- Add data JSONB column to pension_entries
- Create pension calculator strategy classes (Base, DeGrv, UsSocialSecurity, UkStatePension, FrRegimeGeneral, EsSocialSecurity)
- Update RetirementConfig model to delegate to calculators
- Make PensionEntry.current_points optional for non-points systems
- Update controller strong params (pension_params: {})
- Add Stimulus pension_system_controller for dynamic form fields
- Update views with per-country field groups and conditional points columns
- Expand i18n (EN, DE) and add ES, FR locale files
- Update fixtures and tests for new schema

Addresses review feedback from jjmata on PR #1057
This commit is contained in:
ChakibMoMi
2026-04-09 00:37:11 +02:00
parent 0fed438215
commit 4f3230c904
20 changed files with 675 additions and 83 deletions

View File

@@ -80,17 +80,15 @@ class RetirementController < ApplicationController
:country, :pension_system, :birth_year, :retirement_age,
:target_monthly_income, :currency, :expected_return_pct,
:inflation_pct, :tax_rate_pct, :current_monthly_savings,
:contribution_start_year, :expected_annual_points, :rentenwert
pension_params: {}
)
end
# Params are scoped under :pension_entry because the form uses
# form_with url: ..., scope: :pension_entry. Switching to form_with model:
# would change the nesting — update this permit list accordingly.
def pension_entry_params
params.require(:pension_entry).permit(
:recorded_at, :current_points, :current_monthly_pension,
:projected_monthly_pension, :notes
:projected_monthly_pension, :notes,
data: {}
)
end
end

View File

@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus";
// Toggles visibility of country-specific pension field groups
// based on the selected pension system.
//
// Usage:
// <div data-controller="pension-system">
// <select data-pension-system-target="select" data-action="change->pension-system#toggle">
// <div data-pension-system-target="fields" data-pension-system-key="de_grv"> ... </div>
// <div data-pension-system-target="fields" data-pension-system-key="us_ss"> ... </div>
// </div>
export default class extends Controller {
static targets = ["select", "fields"];
connect() {
this.toggle();
}
toggle() {
const selected = this.selectTarget.value;
this.fieldsTargets.forEach((el) => {
if (el.dataset.pensionSystemKey === selected) {
el.classList.remove("hidden");
el.querySelectorAll("input, select").forEach((i) => (i.disabled = false));
} else {
el.classList.add("hidden");
el.querySelectorAll("input, select").forEach((i) => (i.disabled = true));
}
});
}
}

View File

@@ -2,23 +2,22 @@ class PensionEntry < ApplicationRecord
belongs_to :retirement_config
validates :recorded_at, presence: true, uniqueness: { scope: :retirement_config_id }
validates :current_points, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :current_points, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :current_monthly_pension, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :projected_monthly_pension, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
scope :chronological, -> { order(recorded_at: :asc) }
scope :reverse_chronological, -> { order(recorded_at: :desc) }
# Calculate the points gained since the previous entry.
# For bulk rendering, prefer RetirementConfig#pension_entries_with_gains
# to avoid N+1 queries.
def points_gained
return nil unless current_points
previous = retirement_config.pension_entries
.where("recorded_at < ?", recorded_at)
.order(recorded_at: :desc)
.first
return current_points unless previous
return current_points unless previous&.current_points
current_points - previous.current_points
end
end

View File

@@ -1,13 +1,20 @@
class RetirementConfig < ApplicationRecord
PENSION_SYSTEMS = %w[de_grv custom].freeze
DEFAULT_RENTENWERT = 39.32 # Updated annually by German government (as of 2025)
SAFE_WITHDRAWAL_RATE = 0.04 # 4% rule for safe withdrawal
PENSION_SYSTEMS = {
"custom" => { calculator: "RetirementConfig::PensionCalculator::Base" },
"de_grv" => { calculator: "RetirementConfig::PensionCalculator::DeGrv" },
"us_ss" => { calculator: "RetirementConfig::PensionCalculator::UsSocialSecurity" },
"uk_sp" => { calculator: "RetirementConfig::PensionCalculator::UkStatePension" },
"fr_regime" => { calculator: "RetirementConfig::PensionCalculator::FrRegimeGeneral" },
"es_ss" => { calculator: "RetirementConfig::PensionCalculator::EsSocialSecurity" }
}.freeze
SAFE_WITHDRAWAL_RATE = 0.04
belongs_to :family
has_many :pension_entries, dependent: :destroy
validates :country, presence: true
validates :pension_system, inclusion: { in: PENSION_SYSTEMS }
validates :pension_system, inclusion: { in: PENSION_SYSTEMS.keys }
validates :birth_year, presence: true,
numericality: { greater_than: 1900, less_than_or_equal_to: -> { Date.current.year } }
validates :retirement_age, presence: true,
@@ -17,50 +24,49 @@ class RetirementConfig < ApplicationRecord
validates :inflation_pct, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 20 }
validates :tax_rate_pct, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
# Current age based on birth year
def current_age
Date.current.year - birth_year
end
# Years until retirement
def years_to_retirement
[ retirement_age - current_age, 0 ].max
end
# Whether the user has already reached retirement age
def retired?
current_age >= retirement_age
end
# Estimated monthly pension.
# GRV: calculated from Entgeltpunkte × Rentenwert, with latest statement override.
# Custom: uses latest pension entry's projected value if available, otherwise 0.
# Delegates pension estimation to the active calculator
def estimated_monthly_pension
if latest_pension_entry&.projected_monthly_pension
return latest_pension_entry.projected_monthly_pension
if pension_system == "custom"
return latest_pension_entry&.projected_monthly_pension || 0
end
return 0 unless pension_system == "de_grv"
points = total_projected_points
rw = rentenwert || DEFAULT_RENTENWERT
points * rw
pension_calculator.estimated_monthly_pension
end
# Total projected pension points at retirement
def total_projected_points
current = latest_pension_entry&.current_points || 0
annual = expected_annual_points || 1.0
current + (annual * years_to_retirement)
def pension_calculator
@pension_calculator ||= begin
klass_name = PENSION_SYSTEMS.dig(pension_system, :calculator)
klass_name.constantize.new(self)
end
end
# Whether the current system uses pension points (e.g. DE Entgeltpunkte)
def points_based?
pension_calculator.points_based?
end
# Read a system-specific parameter from JSONB
def pension_param(key)
pension_params&.dig(key.to_s)
end
# Monthly pension gap: how much more you need beyond GRV pension
def monthly_pension_gap
gap = target_monthly_income - estimated_monthly_pension_after_tax
[ gap, 0 ].max
end
# Estimated pension after taxes
def estimated_monthly_pension_after_tax
estimated_monthly_pension * (1 - (tax_rate_pct / 100.0))
end
@@ -158,7 +164,11 @@ class RetirementConfig < ApplicationRecord
sorted = pension_entries.chronological.to_a
sorted.each_with_index do |entry, idx|
prev = idx > 0 ? sorted[idx - 1] : nil
delta = prev ? entry.current_points - prev.current_points : entry.current_points
if points_based? && entry.current_points
delta = prev&.current_points ? entry.current_points - prev.current_points : entry.current_points
else
delta = nil
end
entry.define_singleton_method(:points_gained) { delta }
end
sorted.reverse

View File

@@ -0,0 +1,36 @@
class RetirementConfig
module PensionCalculator
class Base
attr_reader :config
def initialize(config)
@config = config
end
def estimated_monthly_pension
raise NotImplementedError, "#{self.class} must implement #estimated_monthly_pension"
end
# Override in subclasses to define system-specific parameters for forms
# Returns array of hashes: [{ key:, type:, label_i18n:, default:, step:, min:, max: }]
def self.param_definitions
[]
end
# Override in subclasses that use a points-based system
def points_based?
false
end
private
def pension_param(key)
config.pension_params&.dig(key.to_s)
end
def latest_entry
config.send(:latest_pension_entry)
end
end
end
end

View File

@@ -0,0 +1,37 @@
class RetirementConfig
module PensionCalculator
class DeGrv < Base
DEFAULT_RENTENWERT = 39.32
def estimated_monthly_pension
if latest_entry&.projected_monthly_pension
return latest_entry.projected_monthly_pension
end
points = total_projected_points
rw = pension_param("rentenwert")&.to_f || DEFAULT_RENTENWERT
points * rw
end
def points_based?
true
end
def self.param_definitions
[
{ key: "expected_annual_points", type: :number, step: 0.01, min: 0, default: 1.0 },
{ key: "rentenwert", type: :number, step: 0.01, min: 0, default: DEFAULT_RENTENWERT },
{ key: "contribution_start_year", type: :integer, min: 1960, max: Date.current.year }
]
end
private
def total_projected_points
current = latest_entry&.current_points || 0
annual = pension_param("expected_annual_points")&.to_f || 1.0
current + (annual * config.years_to_retirement)
end
end
end
end

View File

@@ -0,0 +1,21 @@
class RetirementConfig
module PensionCalculator
class EsSocialSecurity < Base
def estimated_monthly_pension
if latest_entry&.projected_monthly_pension
return latest_entry.projected_monthly_pension
end
# Users enter projected pension from their informe de vida laboral
pension_param("estimated_monthly_pension")&.to_f || 0
end
def self.param_definitions
[
{ key: "contribution_years", type: :integer, min: 0, max: 50, default: 0 },
{ key: "estimated_monthly_pension", type: :number, step: 1, min: 0, default: 0 }
]
end
end
end
end

View File

@@ -0,0 +1,21 @@
class RetirementConfig
module PensionCalculator
class FrRegimeGeneral < Base
def estimated_monthly_pension
if latest_entry&.projected_monthly_pension
return latest_entry.projected_monthly_pension
end
# Users can enter projected pension from their relevé de carrière
pension_param("estimated_monthly_pension")&.to_f || 0
end
def self.param_definitions
[
{ key: "trimestres", type: :integer, min: 0, max: 200, default: 0 },
{ key: "estimated_monthly_pension", type: :number, step: 1, min: 0, default: 0 }
]
end
end
end
end

View File

@@ -0,0 +1,26 @@
class RetirementConfig
module PensionCalculator
class UkStatePension < Base
FULL_WEEKLY_RATE = 221.20 # £/week as of 2025/26
def estimated_monthly_pension
if latest_entry&.projected_monthly_pension
return latest_entry.projected_monthly_pension
end
qualifying_years = pension_param("qualifying_years")&.to_f || 0
rate = pension_param("full_weekly_rate")&.to_f || FULL_WEEKLY_RATE
return 0 if qualifying_years <= 0
(qualifying_years / 35.0) * rate * 52 / 12
end
def self.param_definitions
[
{ key: "qualifying_years", type: :integer, min: 0, max: 50, default: 0 },
{ key: "full_weekly_rate", type: :number, step: 0.01, min: 0, default: FULL_WEEKLY_RATE }
]
end
end
end
end

View File

@@ -0,0 +1,20 @@
class RetirementConfig
module PensionCalculator
class UsSocialSecurity < Base
def estimated_monthly_pension
if latest_entry&.projected_monthly_pension
return latest_entry.projected_monthly_pension
end
# Users enter their estimated benefit from their SSA statement
pension_param("estimated_monthly_benefit")&.to_f || 0
end
def self.param_definitions
[
{ key: "estimated_monthly_benefit", type: :number, step: 1, min: 0, default: 0 }
]
end
end
end
end

View File

@@ -38,13 +38,17 @@
</fieldset>
<%# Pension System %>
<fieldset class="space-y-4">
<fieldset class="space-y-4" data-controller="pension-system">
<legend class="text-lg font-medium text-primary"><%= t(".pension_section") %></legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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.map { |s| [t("retirement.pension_systems.#{s}", default: s.humanize), s] }, {}, class: "form-select w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<%= f.select :pension_system,
RetirementConfig::PENSION_SYSTEMS.keys.map { |k| [t("retirement.pension_systems.#{k}", default: k.humanize), k] },
{},
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" } %>
</div>
<div>
<label for="retirement_config_country" class="block text-sm font-medium text-secondary mb-1"><%= t(".country") %></label>
@@ -52,22 +56,97 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%# German GRV fields %>
<div data-pension-system-target="fields" data-pension-system-key="de_grv" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="retirement_config_expected_annual_points" class="block text-sm font-medium text-secondary mb-1"><%= t(".expected_annual_points") %></label>
<%= f.number_field :expected_annual_points, step: 0.01, min: 0, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".expected_annual_points_hint") %></p>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".de_expected_annual_points") %></label>
<%= f.number_field "pension_params[expected_annual_points]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("expected_annual_points"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".de_expected_annual_points_hint") %></p>
</div>
<div>
<label for="retirement_config_rentenwert" class="block text-sm font-medium text-secondary mb-1"><%= t(".rentenwert") %></label>
<%= f.number_field :rentenwert, step: 0.01, min: 0, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".rentenwert_hint") %></p>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".de_rentenwert") %></label>
<%= f.number_field "pension_params[rentenwert]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("rentenwert"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".de_rentenwert_hint") %></p>
</div>
<div>
<label for="retirement_config_contribution_start_year" class="block text-sm font-medium text-secondary mb-1"><%= t(".contribution_start_year") %></label>
<%= f.number_field :contribution_start_year, min: 1960, max: Date.current.year, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".de_contribution_start_year") %></label>
<%= f.number_field "pension_params[contribution_start_year]", min: 1960, max: Date.current.year,
value: f.object&.pension_params&.dig("contribution_start_year"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
</div>
</div>
<%# US Social Security fields %>
<div data-pension-system-target="fields" data-pension-system-key="us_ss" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".us_estimated_monthly_benefit") %></label>
<%= f.number_field "pension_params[estimated_monthly_benefit]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("estimated_monthly_benefit"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".us_estimated_monthly_benefit_hint") %></p>
</div>
</div>
<%# UK State Pension fields %>
<div data-pension-system-target="fields" data-pension-system-key="uk_sp" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".uk_qualifying_years") %></label>
<%= f.number_field "pension_params[qualifying_years]", step: 1, min: 0, max: 35,
value: f.object&.pension_params&.dig("qualifying_years"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".uk_qualifying_years_hint") %></p>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".uk_full_weekly_rate") %></label>
<%= f.number_field "pension_params[full_weekly_rate]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("full_weekly_rate"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".uk_full_weekly_rate_hint") %></p>
</div>
</div>
<%# French Régime Général fields %>
<div data-pension-system-target="fields" data-pension-system-key="fr_regime" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".fr_estimated_monthly_pension") %></label>
<%= f.number_field "pension_params[estimated_monthly_pension]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("estimated_monthly_pension"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".fr_estimated_monthly_pension_hint") %></p>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".fr_trimestres") %></label>
<%= f.number_field "pension_params[trimestres]", step: 1, min: 0,
value: f.object&.pension_params&.dig("trimestres"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
</div>
</div>
<%# Spanish Social Security fields %>
<div data-pension-system-target="fields" data-pension-system-key="es_ss" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".es_estimated_monthly_pension") %></label>
<%= f.number_field "pension_params[estimated_monthly_pension]", step: 0.01, min: 0,
value: f.object&.pension_params&.dig("estimated_monthly_pension"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
<p class="text-xs text-secondary mt-1"><%= t(".es_estimated_monthly_pension_hint") %></p>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1"><%= t(".es_contribution_years") %></label>
<%= f.number_field "pension_params[contribution_years]", step: 1, min: 0,
value: f.object&.pension_params&.dig("contribution_years"),
class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
</div>
</div>
<%# Custom / Other — just use estimated pension from entries %>
<div data-pension-system-target="fields" data-pension-system-key="custom" class="text-sm text-secondary">
<p><%= t(".custom_hint") %></p>
</div>
</fieldset>
<%# Assumptions %>

View File

@@ -24,9 +24,7 @@
<%# Estimated Pension %>
<div class="bg-container shadow-border-xs rounded-lg p-4">
<p class="text-sm text-secondary mb-1">
<%= @retirement_config.pension_system == "de_grv" ? t(".estimated_pension_grv") : t(".estimated_pension") %>
</p>
<p class="text-sm text-secondary mb-1"><%= t(".estimated_pension") %></p>
<p class="text-2xl font-semibold text-primary">
<%= number_to_currency(@retirement_config.estimated_monthly_pension, unit: retirement_currency_unit(@retirement_config)) %>
</p>
@@ -154,10 +152,12 @@
<%= f.label :recorded_at, t(".date"), class: "block text-sm font-medium text-secondary mb-1" %>
<%= f.date_field :recorded_at, value: Date.current, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2", required: true %>
</div>
<div>
<%= f.label :current_points, t(".pension_points"), class: "block text-sm font-medium text-secondary mb-1" %>
<%= f.number_field :current_points, step: 0.0001, min: 0, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2", required: true %>
</div>
<% if @retirement_config.points_based? %>
<div>
<%= f.label :current_points, t(".pension_points"), class: "block text-sm font-medium text-secondary mb-1" %>
<%= f.number_field :current_points, step: 0.0001, min: 0, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
</div>
<% end %>
<div>
<%= f.label :current_monthly_pension, t(".current_pension"), class: "block text-sm font-medium text-secondary mb-1" %>
<%= f.number_field :current_monthly_pension, step: 0.01, min: 0, class: "form-input w-full rounded-lg border border-secondary bg-container text-sm text-primary px-3 py-2" %>
@@ -185,8 +185,10 @@
<thead>
<tr class="border-b border-secondary">
<th class="text-left text-secondary font-medium py-2 px-3"><%= t(".date") %></th>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".pension_points") %></th>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".points_gained") %></th>
<% if @retirement_config.points_based? %>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".pension_points") %></th>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".points_gained") %></th>
<% end %>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".current_pension") %></th>
<th class="text-right text-secondary font-medium py-2 px-3"><%= t(".projected_pension") %></th>
<th class="text-left text-secondary font-medium py-2 px-3"><%= t(".notes") %></th>
@@ -197,12 +199,24 @@
<% @pension_entries.each do |entry| %>
<tr class="border-b border-secondary/50 hover:bg-surface-hover">
<td class="py-3 px-3 text-primary"><%= l(entry.recorded_at) %></td>
<td class="py-3 px-3 text-right text-primary"><%= number_with_precision(entry.current_points, precision: 4) %></td>
<td class="py-3 px-3 text-right">
<span class="<%= entry.points_gained > 0 ? 'text-success' : entry.points_gained < 0 ? 'text-destructive' : 'text-secondary' %>">
<%= entry.points_gained >= 0 ? '+' : '' %><%= number_with_precision(entry.points_gained, precision: 4) %>
</span>
</td>
<% if @retirement_config.points_based? %>
<td class="py-3 px-3 text-right text-primary">
<% if entry.current_points %>
<%= number_with_precision(entry.current_points, precision: 4) %>
<% else %>
<span class="text-secondary">—</span>
<% end %>
</td>
<td class="py-3 px-3 text-right">
<% if entry.points_gained %>
<span class="<%= entry.points_gained > 0 ? 'text-success' : entry.points_gained < 0 ? 'text-destructive' : 'text-secondary' %>">
<%= entry.points_gained >= 0 ? '+' : '' %><%= number_with_precision(entry.points_gained, precision: 4) %>
</span>
<% else %>
<span class="text-secondary">—</span>
<% end %>
</td>
<% end %>
<td class="py-3 px-3 text-right text-primary">
<% if entry.current_monthly_pension %>
<%= number_to_currency(entry.current_monthly_pension, unit: retirement_currency_unit(@retirement_config)) %>

View File

@@ -9,7 +9,6 @@ de:
one: "%{count} Jahr bis zur Rente"
other: "%{count} Jahre bis zur Rente"
estimated_pension: Geschätzte monatliche Rente
estimated_pension_grv: Geschätzte GRV-Rente
after_tax: Nach Steuern
pension_gap: Monatliche Rentenlücke
target: "Ziel"
@@ -28,7 +27,7 @@ de:
inflation: Inflationsrate
tax_rate: Steuersatz
monthly_savings: Monatliche Sparrate
pension_history: Rentenhistorie (Renteninformation)
pension_history: Rentenhistorie
add_entry: Neuen Eintrag hinzufügen
date: Datum
pension_points: Entgeltpunkte
@@ -53,11 +52,30 @@ de:
pension_section: Rentensystem
pension_system: Rentensystem
country: Land
expected_annual_points: Erwartete jährliche Entgeltpunkte
expected_annual_points_hint: Durchschnittsgehalt = ~1,0 Punkte/Jahr
rentenwert: Aktueller Rentenwert
rentenwert_hint: Aktueller Wert in EUR (2025 ~ 39,32)
contribution_start_year: Beitragsbeginn (Jahr)
# German GRV
de_expected_annual_points: Erwartete jährliche Entgeltpunkte
de_expected_annual_points_hint: Durchschnittsgehalt = ~1,0 Punkte/Jahr
de_rentenwert: Aktueller Rentenwert
de_rentenwert_hint: Aktueller Wert in EUR (2025 ~ 39,32)
de_contribution_start_year: Beitragsbeginn (Jahr)
# US Social Security
us_estimated_monthly_benefit: Geschätzte monatliche Leistung
us_estimated_monthly_benefit_hint: Aus deiner SSA-Bescheinigung (ssa.gov)
# UK State Pension
uk_qualifying_years: Anspruchsjahre (NI)
uk_qualifying_years_hint: "35 Jahre für volle State Pension"
uk_full_weekly_rate: Voller Wochensatz (£)
uk_full_weekly_rate_hint: "2024/25: £221,20/Woche"
# France Régime Général
fr_estimated_monthly_pension: Geschätzte monatliche Rente
fr_estimated_monthly_pension_hint: Aus deinem relevé de situation individuelle
fr_trimestres: Validierte Trimester
# Spain Social Security
es_estimated_monthly_pension: Geschätzte monatliche Rente
es_estimated_monthly_pension_hint: Aus deinem informe de vida laboral
es_contribution_years: Beitragsjahre
# Custom
custom_hint: Füge Renteneinträge manuell hinzu, um deine geschätzte Rente zu verfolgen.
assumptions_section: Annahmen
expected_return: Erwartete jährliche Rendite (%)
inflation: Erwartete Inflation (%)
@@ -82,4 +100,8 @@ de:
pension_entry_removed: Renteneintrag entfernt
pension_systems:
de_grv: Deutsche Gesetzliche Rentenversicherung (GRV)
us_ss: US-Sozialversicherung
uk_sp: UK State Pension
fr_regime: Französisches Régime Général
es_ss: Spanische Sozialversicherung
custom: Benutzerdefiniert / Andere

View File

@@ -9,7 +9,6 @@ en:
one: "%{count} year to retirement"
other: "%{count} years to retirement"
estimated_pension: Estimated Monthly Pension
estimated_pension_grv: Estimated GRV Pension
after_tax: After tax
pension_gap: Monthly Pension Gap
target: "Target"
@@ -28,7 +27,7 @@ en:
inflation: Inflation Rate
tax_rate: Tax Rate
monthly_savings: Monthly Savings
pension_history: Pension History (Renteninformation)
pension_history: Pension History
add_entry: Add new pension entry
date: Date
pension_points: Pension Points
@@ -36,11 +35,11 @@ en:
current_pension: Current Pension
projected_pension: Projected Pension
notes: Notes
notes_placeholder: "e.g. Annual Renteninformation 2024"
notes_placeholder: "e.g. Annual pension statement 2024"
save_entry: Save Entry
confirm_delete: Are you sure you want to delete this entry?
no_entries: No pension entries yet
no_entries_hint: Add entries from your Renteninformation to track your pension progress
no_entries_hint: Add entries from your pension statements to track your progress
setup_required: Please set up retirement planning first
form_fields: &retirement_form_fields_en
personal_section: Personal Information
@@ -53,11 +52,30 @@ en:
pension_section: Pension System
pension_system: Pension System
country: Country
expected_annual_points: Expected Annual Points
expected_annual_points_hint: Average salary = ~1.0 points/year
rentenwert: Current Pension Value (Rentenwert)
rentenwert_hint: Current value in EUR (2025 ~ 39.32)
contribution_start_year: Contribution Start Year
# German GRV
de_expected_annual_points: Expected Annual Points
de_expected_annual_points_hint: Average salary = ~1.0 points/year
de_rentenwert: Current Pension Value (Rentenwert)
de_rentenwert_hint: Current value in EUR (2025 ~ 39.32)
de_contribution_start_year: Contribution Start Year
# US Social Security
us_estimated_monthly_benefit: Estimated Monthly Benefit
us_estimated_monthly_benefit_hint: From your SSA statement (ssa.gov)
# UK State Pension
uk_qualifying_years: Qualifying Years (NI)
uk_qualifying_years_hint: "35 years for full new State Pension"
uk_full_weekly_rate: Full Weekly Rate (£)
uk_full_weekly_rate_hint: "2024/25: £221.20/week"
# France Régime Général
fr_estimated_monthly_pension: Estimated Monthly Pension
fr_estimated_monthly_pension_hint: From your relevé de situation individuelle
fr_trimestres: Trimestres Validated
# Spain Social Security
es_estimated_monthly_pension: Estimated Monthly Pension
es_estimated_monthly_pension_hint: From your informe de vida laboral
es_contribution_years: Contribution Years
# Custom
custom_hint: Add pension entries manually to track your estimated pension.
assumptions_section: Assumptions
expected_return: Expected Annual Return (%)
inflation: Expected Inflation (%)
@@ -82,4 +100,8 @@ en:
pension_entry_removed: Pension entry removed
pension_systems:
de_grv: German Statutory Pension (GRV)
us_ss: US Social Security
uk_sp: UK State Pension
fr_regime: French Régime Général
es_ss: Spanish Social Security
custom: Custom / Other

View File

@@ -0,0 +1,101 @@
---
es:
retirement:
show:
page_title: Planificación de Jubilación
subtitle: Sigue tu camino hacia la independencia financiera y la preparación para la jubilación
current_age: Edad Actual
years_to_retirement:
one: "%{count} año para la jubilación"
other: "%{count} años para la jubilación"
estimated_pension: Pensión Mensual Estimada
after_tax: Después de impuestos
pension_gap: Brecha Mensual de Pensión
target: "Objetivo"
required_savings: Ahorro Mensual Necesario
per_month: al mes para cerrar la brecha de pensión
fire_progress: Progreso FIRE
current_portfolio: Cartera Actual
fire_number: Número FIRE
progress: Progreso
estimated_fire_date: Fecha FIRE Estimada
assumptions: Supuestos
edit: Editar
birth_year: Año de Nacimiento
retirement_age: Edad de Jubilación
expected_return: Rendimiento Esperado
inflation: Tasa de Inflación
tax_rate: Tasa Impositiva
monthly_savings: Ahorro Mensual
pension_history: Historial de Pensión
add_entry: Añadir nueva entrada de pensión
date: Fecha
pension_points: Puntos de Pensión
points_gained: Puntos Ganados
current_pension: Pensión Actual
projected_pension: Pensión Proyectada
notes: Notas
notes_placeholder: "ej. Informe de vida laboral 2024"
save_entry: Guardar Entrada
confirm_delete: "¿Estás seguro de querer eliminar esta entrada?"
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
form_fields: &retirement_form_fields_es
personal_section: Información Personal
birth_year: Año de Nacimiento
retirement_age: Edad de Jubilación Deseada
financial_section: Objetivos Financieros
target_monthly_income: Ingreso Mensual Objetivo (Jubilación)
current_monthly_savings: Ahorro Mensual Actual
currency: Moneda
pension_section: Sistema de Pensiones
pension_system: Sistema de Pensiones
country: País
de_expected_annual_points: Puntos Anuales Esperados
de_expected_annual_points_hint: Salario promedio = ~1,0 puntos/año
de_rentenwert: Valor Actual de Pensión (Rentenwert)
de_rentenwert_hint: Valor actual en EUR (2025 ~ 39,32)
de_contribution_start_year: Año de Inicio de Contribuciones
us_estimated_monthly_benefit: Beneficio Mensual Estimado
us_estimated_monthly_benefit_hint: De tu declaración SSA (ssa.gov)
uk_qualifying_years: Años Calificados (NI)
uk_qualifying_years_hint: "35 años para la State Pension completa"
uk_full_weekly_rate: Tarifa Semanal Completa (£)
uk_full_weekly_rate_hint: "2024/25: £221,20/semana"
fr_estimated_monthly_pension: Pensión Mensual Estimada
fr_estimated_monthly_pension_hint: De tu relevé de situation individuelle
fr_trimestres: Trimestres Validados
es_estimated_monthly_pension: Pensión Mensual Estimada
es_estimated_monthly_pension_hint: De tu informe de vida laboral
es_contribution_years: Años de Cotización
custom_hint: Añade entradas de pensión manualmente para seguir tu pensión estimada.
assumptions_section: Supuestos
expected_return: Rendimiento Anual Esperado (%)
inflation: Inflación Esperada (%)
tax_rate: Tasa Impositiva en Jubilación (%)
setup:
page_title: Configurar Planificación de Jubilación
subtitle: Configura tus parámetros de planificación de jubilación y FIRE
cancel: Cancelar
save: Guardar y Continuar
edit:
page_title: Editar Configuración de Jubilación
subtitle: Actualiza tus parámetros de planificación de jubilación
cancel: Cancelar
save: Guardar Cambios
create:
created: La planificación de jubilación se ha configurado correctamente
update:
updated: Configuración de jubilación actualizada correctamente
add_pension_entry:
pension_entry_added: Entrada de pensión añadida correctamente
destroy_pension_entry:
pension_entry_removed: Entrada de pensión eliminada
pension_systems:
de_grv: Pensión Estatal Alemana (GRV)
us_ss: Seguridad Social de EE.UU.
uk_sp: Pensión Estatal del Reino Unido
fr_regime: Régimen General Francés
es_ss: Seguridad Social Española
custom: Personalizado / Otro

View File

@@ -0,0 +1,101 @@
---
fr:
retirement:
show:
page_title: Planification de la Retraite
subtitle: Suivez votre chemin vers l'indépendance financière et la préparation à la retraite
current_age: Âge Actuel
years_to_retirement:
one: "%{count} an avant la retraite"
other: "%{count} ans avant la retraite"
estimated_pension: Pension Mensuelle Estimée
after_tax: Après impôts
pension_gap: Écart Mensuel de Pension
target: "Objectif"
required_savings: Épargne Mensuelle Nécessaire
per_month: par mois pour combler l'écart de pension
fire_progress: Progression FIRE
current_portfolio: Portefeuille Actuel
fire_number: Nombre FIRE
progress: Progression
estimated_fire_date: Date FIRE Estimée
assumptions: Hypothèses
edit: Modifier
birth_year: Année de Naissance
retirement_age: Âge de Retraite
expected_return: Rendement Attendu
inflation: Taux d'Inflation
tax_rate: Taux d'Imposition
monthly_savings: Épargne Mensuelle
pension_history: Historique de Pension
add_entry: Ajouter une entrée de pension
date: Date
pension_points: Points de Pension
points_gained: Points Gagnés
current_pension: Pension Actuelle
projected_pension: Pension Projetée
notes: Notes
notes_placeholder: "ex. Relevé de situation individuelle 2024"
save_entry: Enregistrer
confirm_delete: "Êtes-vous sûr de vouloir supprimer cette entrée ?"
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
form_fields: &retirement_form_fields_fr
personal_section: Informations Personnelles
birth_year: Année de Naissance
retirement_age: Âge de Retraite Souhaité
financial_section: Objectifs Financiers
target_monthly_income: Revenu Mensuel Cible (Retraite)
current_monthly_savings: Épargne Mensuelle Actuelle
currency: Devise
pension_section: Système de Retraite
pension_system: Système de Retraite
country: Pays
de_expected_annual_points: Points Annuels Attendus
de_expected_annual_points_hint: Salaire moyen = ~1,0 point/an
de_rentenwert: Valeur Actuelle de la Pension (Rentenwert)
de_rentenwert_hint: Valeur actuelle en EUR (2025 ~ 39,32)
de_contribution_start_year: Année de Début des Cotisations
us_estimated_monthly_benefit: Prestation Mensuelle Estimée
us_estimated_monthly_benefit_hint: De votre relevé SSA (ssa.gov)
uk_qualifying_years: Années de Qualification (NI)
uk_qualifying_years_hint: "35 ans pour la State Pension complète"
uk_full_weekly_rate: Taux Hebdomadaire Complet (£)
uk_full_weekly_rate_hint: "2024/25 : £221,20/semaine"
fr_estimated_monthly_pension: Pension Mensuelle Estimée
fr_estimated_monthly_pension_hint: De votre relevé de situation individuelle
fr_trimestres: Trimestres Validés
es_estimated_monthly_pension: Pension Mensuelle Estimée
es_estimated_monthly_pension_hint: De votre informe de vida laboral
es_contribution_years: Années de Cotisation
custom_hint: Ajoutez des entrées de pension manuellement pour suivre votre pension estimée.
assumptions_section: Hypothèses
expected_return: Rendement Annuel Attendu (%)
inflation: Inflation Attendue (%)
tax_rate: Taux d'Imposition à la Retraite (%)
setup:
page_title: Configurer la Planification de Retraite
subtitle: Configurez vos paramètres de planification de retraite et FIRE
cancel: Annuler
save: Enregistrer et Continuer
edit:
page_title: Modifier les Paramètres de Retraite
subtitle: Mettez à jour vos paramètres de planification de retraite
cancel: Annuler
save: Enregistrer les Modifications
create:
created: La planification de la retraite a été configurée avec succès
update:
updated: Paramètres de retraite mis à jour avec succès
add_pension_entry:
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_systems:
de_grv: Pension Légale Allemande (GRV)
us_ss: Sécurité Sociale Américaine
uk_sp: Pension d'État Britannique
fr_regime: Régime Général Français
es_ss: Sécurité Sociale Espagnole
custom: Personnalisé / Autre

View File

@@ -0,0 +1,32 @@
class GeneralizePensionSystems < ActiveRecord::Migration[7.2]
def change
# Add JSONB column for system-specific parameters (replaces rentenwert, expected_annual_points)
add_column :retirement_configs, :pension_params, :jsonb, null: false, default: {}
# Add JSONB column for system-specific entry data
add_column :pension_entries, :data, :jsonb, null: false, default: {}
# Make current_points nullable (only used by points-based systems like DE)
change_column_null :pension_entries, :current_points, true
# Migrate existing data: move rentenwert and expected_annual_points into pension_params
reversible do |dir|
dir.up do
execute <<-SQL.squish
UPDATE retirement_configs
SET pension_params = jsonb_build_object(
'rentenwert', COALESCE(rentenwert, 39.32),
'expected_annual_points', COALESCE(expected_annual_points, 1.0),
'contribution_start_year', contribution_start_year
)
WHERE pension_system = 'de_grv'
SQL
end
end
# Remove old German-specific columns
remove_column :retirement_configs, :rentenwert, :decimal, precision: 8, scale: 2
remove_column :retirement_configs, :expected_annual_points, :decimal, precision: 5, scale: 2
remove_column :retirement_configs, :contribution_start_year, :integer
end
end

View File

@@ -44,7 +44,12 @@ class RetirementControllerTest < ActionDispatch::IntegrationTest
country: "DE",
expected_return_pct: 7.0,
inflation_pct: 2.0,
tax_rate_pct: 26.38
tax_rate_pct: 26.38,
pension_params: {
expected_annual_points: 1.0,
rentenwert: 39.32,
contribution_start_year: 2015
}
}
}
end

View File

@@ -10,9 +10,10 @@ dylan_retirement:
inflation_pct: 2.0
tax_rate_pct: 26.38
current_monthly_savings: 500.0
contribution_start_year: 2015
expected_annual_points: 1.0
rentenwert: 39.32
pension_params:
expected_annual_points: 1.0
rentenwert: 39.32
contribution_start_year: 2015
custom_retirement:
family: empty
@@ -26,3 +27,18 @@ custom_retirement:
inflation_pct: 2.5
tax_rate_pct: 22.0
current_monthly_savings: 800.0
us_retirement:
family: inactive_trial
country: US
pension_system: us_ss
birth_year: 1980
retirement_age: 67
target_monthly_income: 5000.0
currency: USD
expected_return_pct: 7.0
inflation_pct: 2.5
tax_rate_pct: 24.0
current_monthly_savings: 1000.0
pension_params:
estimated_monthly_benefit: 2800.0

View File

@@ -15,9 +15,9 @@ class PensionEntryTest < ActiveSupport::TestCase
assert_not @entry_2024.valid?
end
test "requires current_points" do
test "current_points is optional" do
@entry_2024.current_points = nil
assert_not @entry_2024.valid?
assert @entry_2024.valid?
end
test "current_points must be non-negative" do