mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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:
7
test/fixtures/goal_retirement_adjustments.yml
vendored
Normal file
7
test/fixtures/goal_retirement_adjustments.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
mortgage_paid_off:
|
||||
goal_retirement: retirement_bob
|
||||
from_age: 51
|
||||
amount_today: -680
|
||||
currency: USD
|
||||
label: Mortgage paid off
|
||||
ordinal: 0
|
||||
19
test/fixtures/goal_retirement_statements.yml
vendored
Normal file
19
test/fixtures/goal_retirement_statements.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
grv_2023:
|
||||
goal_retirement: retirement_bob
|
||||
pension_source: de_grv_bob
|
||||
received_on: 2023-01-15
|
||||
projected_monthly_amount: 1180
|
||||
projected_currency: EUR
|
||||
projected_at_age: 67
|
||||
current_points: 7.10
|
||||
raw_source_doc: Renteninformation 2023
|
||||
|
||||
grv_2025:
|
||||
goal_retirement: retirement_bob
|
||||
pension_source: de_grv_bob
|
||||
received_on: 2025-01-15
|
||||
projected_monthly_amount: 1510
|
||||
projected_currency: EUR
|
||||
projected_at_age: 67
|
||||
current_points: 9.60
|
||||
raw_source_doc: Renteninformation 2025
|
||||
11
test/fixtures/goals.yml
vendored
11
test/fixtures/goals.yml
vendored
@@ -23,3 +23,14 @@ car_paydown:
|
||||
target_date: <%= 12.months.from_now.to_date %>
|
||||
color: "#e99537"
|
||||
state: paused
|
||||
|
||||
# STI subtype — owner-scoped retirement plan. Excluded from the savings
|
||||
# goal routes (see Goal.savings); reachable only via RetirementController.
|
||||
retirement_bob:
|
||||
family: dylan_family
|
||||
user_id: <%= ActiveRecord::FixtureSet.identify(:family_admin, :uuid) %>
|
||||
type: Goal::Retirement
|
||||
name: Bob's Retirement
|
||||
target_amount: 1000000
|
||||
currency: USD
|
||||
state: active
|
||||
|
||||
23
test/fixtures/pension_sources.yml
vendored
Normal file
23
test/fixtures/pension_sources.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
de_grv_bob:
|
||||
goal_retirement: retirement_bob
|
||||
name: German Statutory Pension
|
||||
kind: state
|
||||
country: DE
|
||||
pension_system: de_grv
|
||||
tax_treatment: de_renten
|
||||
payout_shape: monthly_for_life
|
||||
start_age: 67
|
||||
amount: 1510
|
||||
currency: EUR
|
||||
|
||||
bav_bob:
|
||||
goal_retirement: retirement_bob
|
||||
name: bAV employer scheme
|
||||
kind: workplace
|
||||
country: DE
|
||||
pension_system: de_bav
|
||||
tax_treatment: de_bav
|
||||
payout_shape: lump_plus_annuity
|
||||
start_age: 65
|
||||
amount: 620
|
||||
currency: EUR
|
||||
3
test/fixtures/retirement_bucket_entries.yml
vendored
Normal file
3
test/fixtures/retirement_bucket_entries.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
bob_investment:
|
||||
goal_retirement: retirement_bob
|
||||
account: investment
|
||||
31
test/models/goal/retirement_adjustment_test.rb
Normal file
31
test/models/goal/retirement_adjustment_test.rb
Normal 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
|
||||
37
test/models/goal/retirement_statement_test.rb
Normal file
37
test/models/goal/retirement_statement_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
39
test/models/pension_source_test.rb
Normal file
39
test/models/pension_source_test.rb
Normal 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
|
||||
28
test/models/retirement_bucket_entry_test.rb
Normal file
28
test/models/retirement_bucket_entry_test.rb
Normal 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
|
||||
@@ -75,6 +75,13 @@ module ActiveSupport
|
||||
end
|
||||
end
|
||||
|
||||
# Namespaced models whose fixture-set name doesn't match the class by
|
||||
# Rails convention (goal_retirement_statements -> Goal::RetirementStatement,
|
||||
# not GoalRetirementStatement). Without this the loader can't resolve the
|
||||
# goal_retirement association and tries to insert a literal column.
|
||||
set_fixture_class goal_retirement_statements: "Goal::RetirementStatement",
|
||||
goal_retirement_adjustments: "Goal::RetirementAdjustment"
|
||||
|
||||
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
|
||||
fixtures :all
|
||||
|
||||
|
||||
Reference in New Issue
Block a user