feat(retirement): PR2 data models — pension sources, statements, bucket

Data plane for Retirement v2 (no FIRE math yet — that is PR3). Five
migrations + four AR models, wired to Goal::Retirement.

Models:
- PensionSource — state/workplace/other source with country, pension
  system, tax treatment, payout shape (string-backed + inclusion
  validations rather than PG enums, so v2 can add countries without
  ALTER TYPE). monetize :amount; end_age required for fixed-term.
- Goal::RetirementStatement — append-only audit journal. default_scope
  excludes soft-deleted rows; soft_replace! does soft-delete + insert;
  points_delta drives the "—"/signed Δ column; monetize against
  projected_currency.
- Goal::RetirementAdjustment — signed today's-money deltas to the
  spending target, ordered, applicable_at?(age).
- RetirementBucketEntry — account selection join, unique per plan,
  same-family guard.

Goal::Retirement gains the four associations + bucket_accounts and an
ADJUSTMENTS_LIMIT (10) cap. retirement_params jsonb added to goals for
PR3 plan settings.

Namespaced fixture classes mapped via set_fixture_class so the
goal_retirement association resolves. Minimal fixtures + model tests
(112 runs green, incl. goal/family/controller regression sweep). No
new gems.
This commit is contained in:
Guillem Arias
2026-05-29 10:36:18 +02:00
parent 839d6b36ad
commit bf0f10c21f
23 changed files with 570 additions and 14 deletions

View File

@@ -0,0 +1,31 @@
require "test_helper"
class Goal::RetirementAdjustmentTest < ActiveSupport::TestCase
setup do
@adj = goal_retirement_adjustments(:mortgage_paid_off)
end
test "fixture is valid" do
assert @adj.valid?, @adj.errors.full_messages.to_sentence
end
test "amount can be negative (a reduction)" do
assert @adj.amount_today.negative?
assert @adj.valid?
end
test "to_age must exceed from_age when present" do
@adj.to_age = @adj.from_age - 1
assert_not @adj.valid?
end
test "applicable_at? respects the from/to range" do
assert_not @adj.applicable_at?(@adj.from_age - 1)
assert @adj.applicable_at?(@adj.from_age)
assert @adj.applicable_at?(@adj.from_age + 5) # to_age nil => forever
end
test "amount_today_money uses the adjustment currency" do
assert_equal Money.new(-680, "USD"), @adj.amount_today_money
end
end

View File

@@ -0,0 +1,37 @@
require "test_helper"
class Goal::RetirementStatementTest < ActiveSupport::TestCase
setup do
@statement = goal_retirement_statements(:grv_2025)
end
test "fixture is valid" do
assert @statement.valid?, @statement.errors.full_messages.to_sentence
end
test "default scope excludes soft-deleted rows" do
@statement.update_column(:deleted, true)
assert_not Goal::RetirementStatement.exists?(@statement.id)
assert Goal::RetirementStatement.unscoped.exists?(@statement.id)
end
test "points_delta is nil for earliest, signed for later" do
assert_nil goal_retirement_statements(:grv_2023).points_delta
assert_in_delta 2.50, goal_retirement_statements(:grv_2025).points_delta, 0.001
end
test "soft_replace! soft-deletes self and inserts replacement" do
new_stmt = nil
assert_difference -> { Goal::RetirementStatement.unscoped.count }, 1 do
new_stmt = @statement.soft_replace!(projected_monthly_amount: 1600)
end
assert @statement.reload.deleted
assert_equal 1600, new_stmt.projected_monthly_amount.to_i
assert_not new_stmt.deleted
end
test "money uses projected_currency" do
assert_equal Money.new(1510, "EUR"), @statement.projected_monthly_amount_money
end
end

View File

@@ -73,4 +73,25 @@ class Goal::RetirementTest < ActiveSupport::TestCase
assert retirement.valid?, retirement.errors.full_messages.to_sentence
end
test "has pension sources, statements, adjustments, and bucket accounts" do
plan = goals(:retirement_bob)
assert_includes plan.pension_sources, pension_sources(:de_grv_bob)
assert_includes plan.statements, goal_retirement_statements(:grv_2025)
assert_includes plan.adjustments, goal_retirement_adjustments(:mortgage_paid_off)
assert_includes plan.bucket_accounts, accounts(:investment)
end
test "adjustments are capped at ADJUSTMENTS_LIMIT" do
plan = goals(:retirement_bob)
(plan.adjustments.size...Goal::Retirement::ADJUSTMENTS_LIMIT).each do |i|
plan.adjustments.build(from_age: 60, amount_today: -1, currency: "USD", label: "adj #{i}", ordinal: i + 1)
end
assert plan.valid?, plan.errors.full_messages.to_sentence
plan.adjustments.build(from_age: 61, amount_today: -1, currency: "USD", label: "over", ordinal: 99)
assert_not plan.valid?
assert_includes plan.errors.attribute_names, :adjustments
end
end

View File

@@ -0,0 +1,39 @@
require "test_helper"
class PensionSourceTest < ActiveSupport::TestCase
setup do
@source = pension_sources(:de_grv_bob)
end
test "fixture is valid" do
assert @source.valid?, @source.errors.full_messages.to_sentence
end
test "validates enum-like inclusions" do
@source.kind = "bogus"
assert_not @source.valid?
assert_includes @source.errors.attribute_names, :kind
end
test "end_age required for fixed-term payout" do
@source.payout_shape = "monthly_fixed_term"
@source.end_age = nil
assert_not @source.valid?
assert_includes @source.errors[:end_age],
I18n.t("activerecord.errors.models.pension_source.attributes.end_age.required_for_fixed_term")
end
test "end_age must exceed start_age" do
@source.end_age = @source.start_age - 1
assert_not @source.valid?
end
test "amount_money uses the source currency" do
assert_equal Money.new(1510, "EUR"), @source.amount_money
end
test "effective_rate_override bounded between 0 and 1" do
@source.effective_rate_override = 1.5
assert_not @source.valid?
end
end

View File

@@ -0,0 +1,28 @@
require "test_helper"
class RetirementBucketEntryTest < ActiveSupport::TestCase
setup do
@entry = retirement_bucket_entries(:bob_investment)
@goal = goals(:retirement_bob)
end
test "fixture is valid" do
assert @entry.valid?, @entry.errors.full_messages.to_sentence
end
test "account is unique within a plan" do
dup = RetirementBucketEntry.new(goal_retirement: @goal, account: @entry.account)
assert_not dup.valid?
assert_includes dup.errors.attribute_names, :account_id
end
test "account must belong to the plan's family" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
foreign = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 1)
entry = RetirementBucketEntry.new(goal_retirement: @goal, account: foreign)
assert_not entry.valid?
assert_includes entry.errors[:account],
I18n.t("activerecord.errors.models.retirement_bucket_entry.attributes.account.must_belong_to_family")
end
end