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

@@ -1,8 +1,19 @@
class Goal::Retirement < Goal
ADJUSTMENTS_LIMIT = 10
belongs_to :owner, class_name: "User", foreign_key: :user_id
has_many :pension_sources, foreign_key: :goal_retirement_id, dependent: :destroy
has_many :statements, class_name: "Goal::RetirementStatement",
foreign_key: :goal_retirement_id, dependent: :destroy
has_many :adjustments, class_name: "Goal::RetirementAdjustment",
foreign_key: :goal_retirement_id, dependent: :destroy
has_many :retirement_bucket_entries, foreign_key: :goal_retirement_id, dependent: :destroy
has_many :bucket_accounts, through: :retirement_bucket_entries, source: :account
validates :owner, presence: true
validate :owner_belongs_to_family
validate :adjustments_within_limit
def editable_by?(user)
return false if user.nil?
@@ -10,9 +21,9 @@ class Goal::Retirement < Goal
end
private
# Retirement uses RetirementBucketEntry (PR2) for asset selection, not
# the goal_accounts depository join. The parent validations operate on
# goal_accounts, so they would always fail for retirement subtypes.
# Retirement uses RetirementBucketEntry for asset selection, not the
# goal_accounts depository join, so the parent validations (which run
# against goal_accounts) would always fail. No-op them on the subtype.
def must_have_at_least_one_linked_account
end
@@ -23,4 +34,9 @@ class Goal::Retirement < Goal
return if owner.nil? || family_id.nil?
errors.add(:owner, :must_belong_to_family) unless owner.family_id == family_id
end
def adjustments_within_limit
return if adjustments.reject(&:marked_for_destruction?).size <= ADJUSTMENTS_LIMIT
errors.add(:adjustments, :too_many, count: ADJUSTMENTS_LIMIT)
end
end

View File

@@ -0,0 +1,27 @@
class Goal::RetirementAdjustment < ApplicationRecord
include Monetizable
self.table_name = "goal_retirement_adjustments"
belongs_to :goal_retirement, class_name: "Goal::Retirement", foreign_key: :goal_retirement_id
validates :from_age, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }
validates :to_age,
numericality: { only_integer: true, greater_than: :from_age, less_than_or_equal_to: 120 },
allow_nil: true
# Signed: negative reduces target spend in retirement, positive raises it.
validates :amount_today, presence: true, numericality: true
validates :currency, presence: true
validates :label, presence: true, length: { maximum: 255 }
validates :ordinal, presence: true, numericality: { only_integer: true }
scope :ordered, -> { order(:ordinal, :created_at) }
monetize :amount_today
def applicable_at?(age)
return false if age < from_age
to_age.nil? || age <= to_age
end
end

View File

@@ -0,0 +1,54 @@
class Goal::RetirementStatement < ApplicationRecord
include Monetizable
self.table_name = "goal_retirement_statements"
belongs_to :goal_retirement, class_name: "Goal::Retirement", foreign_key: :goal_retirement_id
belongs_to :pension_source
validates :received_on, presence: true
validates :projected_monthly_amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :projected_currency, presence: true
# Append-only audit: soft-deleted rows stay in the table for history but
# drop out of every normal read. Edits go through soft_replace!.
default_scope { where(deleted: false) }
scope :chronological, -> { order(:received_on) }
monetize :projected_monthly_amount
# Δ in pension points vs the prior active statement for the same source.
# nil for the earliest row (renders "—" in the journal).
def points_delta
return nil if current_points.nil?
prior = pension_source.statements
.where("received_on < ?", received_on)
.chronological
.last
return nil if prior.nil? || prior.current_points.nil?
current_points - prior.current_points
end
# Edit = soft-delete this row + insert a replacement, preserving the
# audit trail. Returns the new statement.
def soft_replace!(attrs)
new_statement = nil
self.class.transaction do
update_column(:deleted, true)
new_statement = pension_source.statements.create!(
attributes
.except("id", "deleted", "created_at", "updated_at")
.merge(attrs.stringify_keys)
)
end
new_statement
end
private
def monetizable_currency
projected_currency
end
end

View File

@@ -0,0 +1,45 @@
class PensionSource < ApplicationRecord
include Monetizable
KINDS = %w[state workplace other].freeze
COUNTRIES = %w[DE US UK].freeze
PENSION_SYSTEMS = %w[de_grv de_bav de_riester us_ss uk_state uk_workplace custom].freeze
TAX_TREATMENTS = %w[
de_renten de_bav de_riester de_private
uk_state uk_dc_drawdown uk_dc_25pct uk_isa
custom_post_tax
].freeze
PAYOUT_SHAPES = %w[monthly_for_life monthly_fixed_term lump_sum lump_plus_annuity].freeze
belongs_to :goal_retirement, class_name: "Goal::Retirement", foreign_key: :goal_retirement_id
has_many :statements, class_name: "Goal::RetirementStatement", dependent: :destroy
validates :name, presence: true, length: { maximum: 255 }
validates :kind, inclusion: { in: KINDS }
validates :country, inclusion: { in: COUNTRIES }
validates :pension_system, inclusion: { in: PENSION_SYSTEMS }
validates :tax_treatment, inclusion: { in: TAX_TREATMENTS }
validates :payout_shape, inclusion: { in: PAYOUT_SHAPES }
validates :start_age, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }
validates :end_age,
numericality: { only_integer: true, greater_than: :start_age, less_than_or_equal_to: 120 },
allow_nil: true
validates :amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :currency, presence: true
validates :effective_rate_override,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
validate :end_age_required_for_fixed_term
monetize :amount
def latest_statement
statements.order(:received_on).last
end
private
def end_age_required_for_fixed_term
return unless payout_shape == "monthly_fixed_term"
errors.add(:end_age, :required_for_fixed_term) if end_age.blank?
end
end

View File

@@ -0,0 +1,13 @@
class RetirementBucketEntry < ApplicationRecord
belongs_to :goal_retirement, class_name: "Goal::Retirement", foreign_key: :goal_retirement_id
belongs_to :account
validates :account_id, uniqueness: { scope: :goal_retirement_id }
validate :account_belongs_to_family
private
def account_belongs_to_family
return if account.nil? || goal_retirement.nil?
errors.add(:account, :must_belong_to_family) unless account.family_id == goal_retirement.family_id
end
end

View File

@@ -0,0 +1,17 @@
---
en:
activerecord:
errors:
models:
pension_source:
attributes:
end_age:
required_for_fixed_term: is required for a fixed-term payout.
retirement_bucket_entry:
attributes:
account:
must_belong_to_family: must belong to the same family as the plan.
"goal/retirement":
attributes:
adjustments:
too_many: "can't exceed %{count} per plan."

View File

@@ -0,0 +1,5 @@
class AddRetirementParamsToGoals < ActiveRecord::Migration[7.2]
def change
add_column :goals, :retirement_params, :jsonb, default: {}, null: false
end
end

View File

@@ -0,0 +1,27 @@
class CreatePensionSources < ActiveRecord::Migration[7.2]
def change
create_table :pension_sources, id: :uuid do |t|
t.references :goal_retirement, type: :uuid, null: false,
foreign_key: { to_table: :goals, on_delete: :cascade }, index: true
t.string :name, null: false
t.string :kind, null: false
t.string :country, null: false
t.string :pension_system, null: false
t.string :tax_treatment, null: false
t.string :payout_shape, null: false
t.integer :start_age, null: false
t.integer :end_age
t.decimal :amount, precision: 19, scale: 4, null: false
t.string :currency, null: false
t.decimal :effective_rate_override, precision: 5, scale: 4
t.jsonb :params, null: false, default: {}
t.timestamps
end
add_check_constraint :pension_sources, "amount >= 0",
name: "chk_pension_sources_amount_non_negative"
add_check_constraint :pension_sources, "start_age >= 0 AND start_age <= 120",
name: "chk_pension_sources_start_age_range"
end
end

View File

@@ -0,0 +1,24 @@
class CreateGoalRetirementStatements < ActiveRecord::Migration[7.2]
def change
create_table :goal_retirement_statements, id: :uuid do |t|
t.references :goal_retirement, type: :uuid, null: false,
foreign_key: { to_table: :goals, on_delete: :cascade }, index: true
t.references :pension_source, type: :uuid, null: false,
foreign_key: { on_delete: :cascade }, index: true
t.date :received_on, null: false
t.decimal :projected_monthly_amount, precision: 19, scale: 4, null: false
t.string :projected_currency, null: false
t.integer :projected_at_age
t.decimal :current_points, precision: 8, scale: 2
t.text :raw_source_doc
t.text :notes
t.boolean :deleted, null: false, default: false
t.timestamps
end
add_index :goal_retirement_statements, [ :pension_source_id, :received_on ],
where: "deleted = false",
name: "index_retirement_statements_active_by_received_on"
end
end

View File

@@ -0,0 +1,17 @@
class CreateGoalRetirementAdjustments < ActiveRecord::Migration[7.2]
def change
create_table :goal_retirement_adjustments, id: :uuid do |t|
t.references :goal_retirement, type: :uuid, null: false,
foreign_key: { to_table: :goals, on_delete: :cascade }, index: true
t.integer :from_age, null: false
t.integer :to_age
t.decimal :amount_today, precision: 19, scale: 4, null: false
t.string :currency, null: false
t.string :label, null: false
t.string :icon
t.integer :ordinal, null: false, default: 0
t.timestamps
end
end
end

View File

@@ -0,0 +1,15 @@
class CreateRetirementBucketEntries < ActiveRecord::Migration[7.2]
def change
create_table :retirement_bucket_entries, id: :uuid do |t|
t.references :goal_retirement, type: :uuid, null: false,
foreign_key: { to_table: :goals, on_delete: :cascade }
t.references :account, type: :uuid, null: false,
foreign_key: { on_delete: :cascade }
t.timestamps
end
add_index :retirement_bucket_entries, [ :goal_retirement_id, :account_id ],
unique: true, name: "index_retirement_bucket_entries_uniqueness"
end
end

92
db/schema.rb generated
View File

@@ -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_05_29_120001) do
ActiveRecord::Schema[7.2].define(version: 2026_05_29_120140) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -53,6 +53,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.string "content_type", limit: 100, null: false
t.bigint "byte_size", null: false
t.string "checksum", limit: 64, null: false
t.string "content_sha256"
t.string "source", default: "manual_upload", null: false
t.string "upload_status", default: "stored", null: false
t.string "institution_name_hint", limit: 200
@@ -69,7 +70,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.jsonb "sanitized_parser_output", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "content_sha256"
t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period"
t.index ["account_id"], name: "index_account_statements_on_account_id"
t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum"
@@ -254,9 +254,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.string "institution_domain"
t.string "institution_url"
t.string "institution_color"
t.string "status", default: "good", null: false
t.boolean "scheduled_for_deletion", default: false, null: false
t.boolean "pending_account_setup", default: false, null: false
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.text "api_key"
@@ -787,6 +787,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.check_constraint "amount > 0::numeric", name: "chk_goal_pledges_amount_positive"
end
create_table "goal_retirement_adjustments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "goal_retirement_id", null: false
t.integer "from_age", null: false
t.integer "to_age"
t.decimal "amount_today", precision: 19, scale: 4, null: false
t.string "currency", null: false
t.string "label", null: false
t.string "icon"
t.integer "ordinal", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["goal_retirement_id"], name: "index_goal_retirement_adjustments_on_goal_retirement_id"
end
create_table "goal_retirement_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "goal_retirement_id", null: false
t.uuid "pension_source_id", null: false
t.date "received_on", null: false
t.decimal "projected_monthly_amount", precision: 19, scale: 4, null: false
t.string "projected_currency", null: false
t.integer "projected_at_age"
t.decimal "current_points", precision: 8, scale: 2
t.text "raw_source_doc"
t.text "notes"
t.boolean "deleted", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["goal_retirement_id"], name: "index_goal_retirement_statements_on_goal_retirement_id"
t.index ["pension_source_id", "received_on"], name: "index_retirement_statements_active_by_received_on", where: "(deleted = false)"
t.index ["pension_source_id"], name: "index_goal_retirement_statements_on_pension_source_id"
end
create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name", null: false
@@ -801,12 +833,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.string "icon"
t.string "type", default: "Goal", null: false
t.uuid "user_id"
t.jsonb "retirement_params", default: {}, null: false
t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state"
t.index ["family_id", "type", "state"], name: "index_goals_on_family_type_state"
t.index ["family_id"], name: "index_goals_on_family_id"
t.index ["user_id", "type"], name: "index_goals_on_user_and_type_retirement", where: "((type)::text = 'Goal::Retirement'::text)"
t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length"
t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum"
t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum"
t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive"
t.check_constraint "type::text <> 'Goal::Retirement'::text OR user_id IS NOT NULL", name: "chk_goals_retirement_requires_owner"
end
@@ -844,9 +877,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.decimal "current_balance", precision: 19, scale: 4
t.decimal "cash_balance", precision: 19, scale: 4
t.jsonb "institution_metadata"
t.jsonb "raw_holdings_payload", default: []
t.jsonb "raw_activities_payload", default: {}
t.jsonb "raw_cash_report_payload", default: []
t.jsonb "raw_holdings_payload", default: [], null: false
t.jsonb "raw_activities_payload", default: {}, null: false
t.jsonb "raw_cash_report_payload", default: [], null: false
t.date "report_date"
t.datetime "last_holdings_sync"
t.datetime "last_activities_sync"
@@ -860,8 +893,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.string "status", default: "good", null: false
t.boolean "scheduled_for_deletion", default: false, null: false
t.boolean "pending_account_setup", default: false, null: false
t.jsonb "raw_payload"
t.string "query_id"
@@ -1339,6 +1372,27 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.string "subtype"
end
create_table "pension_sources", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "goal_retirement_id", null: false
t.string "name", null: false
t.string "kind", null: false
t.string "country", null: false
t.string "pension_system", null: false
t.string "tax_treatment", null: false
t.string "payout_shape", null: false
t.integer "start_age", null: false
t.integer "end_age"
t.decimal "amount", precision: 19, scale: 4, null: false
t.string "currency", null: false
t.decimal "effective_rate_override", precision: 5, scale: 4
t.jsonb "params", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["goal_retirement_id"], name: "index_pension_sources_on_goal_retirement_id"
t.check_constraint "amount >= 0::numeric", name: "chk_pension_sources_amount_non_negative"
t.check_constraint "start_age >= 0 AND start_age <= 120", name: "chk_pension_sources_start_age_range"
end
create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "plaid_item_id", null: false
t.string "plaid_id", null: false
@@ -1434,6 +1488,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id"
end
create_table "retirement_bucket_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "goal_retirement_id", null: false
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_retirement_bucket_entries_on_account_id"
t.index ["goal_retirement_id", "account_id"], name: "index_retirement_bucket_entries_uniqueness", unique: true
t.index ["goal_retirement_id"], name: "index_retirement_bucket_entries_on_goal_retirement_id"
end
create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "rule_id", null: false
t.string "action_type", null: false
@@ -1982,6 +2046,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
add_foreign_key "goal_pledges", "accounts", on_delete: :restrict
add_foreign_key "goal_pledges", "goals", on_delete: :cascade
add_foreign_key "goal_pledges", "transactions", column: "matched_transaction_id", on_delete: :nullify
add_foreign_key "goal_retirement_adjustments", "goals", column: "goal_retirement_id", on_delete: :cascade
add_foreign_key "goal_retirement_statements", "goals", column: "goal_retirement_id", on_delete: :cascade
add_foreign_key "goal_retirement_statements", "pension_sources", on_delete: :cascade
add_foreign_key "goals", "families", on_delete: :cascade
add_foreign_key "goals", "users", on_delete: :restrict
add_foreign_key "holdings", "account_providers"
@@ -2012,6 +2079,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
add_foreign_key "oidc_identities", "users"
add_foreign_key "pension_sources", "goals", column: "goal_retirement_id", on_delete: :cascade
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "recurring_transactions", "accounts", column: "destination_account_id", on_delete: :cascade
@@ -2020,6 +2088,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
add_foreign_key "recurring_transactions", "merchants"
add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id"
add_foreign_key "rejected_transfers", "transactions", column: "outflow_transaction_id"
add_foreign_key "retirement_bucket_entries", "accounts", on_delete: :cascade
add_foreign_key "retirement_bucket_entries", "goals", column: "goal_retirement_id", on_delete: :cascade
add_foreign_key "rule_actions", "rules"
add_foreign_key "rule_conditions", "rule_conditions", column: "parent_id"
add_foreign_key "rule_conditions", "rules"

View 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

View 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

View File

@@ -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
View 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

View File

@@ -0,0 +1,3 @@
bob_investment:
goal_retirement: retirement_bob
account: investment

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

View File

@@ -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