mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +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:
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
|
||||
Reference in New Issue
Block a user