diff --git a/app/models/goal/retirement.rb b/app/models/goal/retirement.rb index f88832e91..6753d5f87 100644 --- a/app/models/goal/retirement.rb +++ b/app/models/goal/retirement.rb @@ -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 diff --git a/app/models/goal/retirement_adjustment.rb b/app/models/goal/retirement_adjustment.rb new file mode 100644 index 000000000..caf2da561 --- /dev/null +++ b/app/models/goal/retirement_adjustment.rb @@ -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 diff --git a/app/models/goal/retirement_statement.rb b/app/models/goal/retirement_statement.rb new file mode 100644 index 000000000..1babd1a8c --- /dev/null +++ b/app/models/goal/retirement_statement.rb @@ -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 diff --git a/app/models/pension_source.rb b/app/models/pension_source.rb new file mode 100644 index 000000000..4ff93a725 --- /dev/null +++ b/app/models/pension_source.rb @@ -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 diff --git a/app/models/retirement_bucket_entry.rb b/app/models/retirement_bucket_entry.rb new file mode 100644 index 000000000..11babe0c0 --- /dev/null +++ b/app/models/retirement_bucket_entry.rb @@ -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 diff --git a/config/locales/models/retirement/en.yml b/config/locales/models/retirement/en.yml new file mode 100644 index 000000000..ec9f13d51 --- /dev/null +++ b/config/locales/models/retirement/en.yml @@ -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." diff --git a/db/migrate/20260529120100_add_retirement_params_to_goals.rb b/db/migrate/20260529120100_add_retirement_params_to_goals.rb new file mode 100644 index 000000000..8465fef26 --- /dev/null +++ b/db/migrate/20260529120100_add_retirement_params_to_goals.rb @@ -0,0 +1,5 @@ +class AddRetirementParamsToGoals < ActiveRecord::Migration[7.2] + def change + add_column :goals, :retirement_params, :jsonb, default: {}, null: false + end +end diff --git a/db/migrate/20260529120110_create_pension_sources.rb b/db/migrate/20260529120110_create_pension_sources.rb new file mode 100644 index 000000000..173976ad1 --- /dev/null +++ b/db/migrate/20260529120110_create_pension_sources.rb @@ -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 diff --git a/db/migrate/20260529120120_create_goal_retirement_statements.rb b/db/migrate/20260529120120_create_goal_retirement_statements.rb new file mode 100644 index 000000000..85bfa6329 --- /dev/null +++ b/db/migrate/20260529120120_create_goal_retirement_statements.rb @@ -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 diff --git a/db/migrate/20260529120130_create_goal_retirement_adjustments.rb b/db/migrate/20260529120130_create_goal_retirement_adjustments.rb new file mode 100644 index 000000000..2cf42e184 --- /dev/null +++ b/db/migrate/20260529120130_create_goal_retirement_adjustments.rb @@ -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 diff --git a/db/migrate/20260529120140_create_retirement_bucket_entries.rb b/db/migrate/20260529120140_create_retirement_bucket_entries.rb new file mode 100644 index 000000000..d94e03304 --- /dev/null +++ b/db/migrate/20260529120140_create_retirement_bucket_entries.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b0963531c..d09a40aa4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/test/fixtures/goal_retirement_adjustments.yml b/test/fixtures/goal_retirement_adjustments.yml new file mode 100644 index 000000000..3fbacbcc6 --- /dev/null +++ b/test/fixtures/goal_retirement_adjustments.yml @@ -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 diff --git a/test/fixtures/goal_retirement_statements.yml b/test/fixtures/goal_retirement_statements.yml new file mode 100644 index 000000000..680570172 --- /dev/null +++ b/test/fixtures/goal_retirement_statements.yml @@ -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 diff --git a/test/fixtures/goals.yml b/test/fixtures/goals.yml index 6c0faafaa..4f009de27 100644 --- a/test/fixtures/goals.yml +++ b/test/fixtures/goals.yml @@ -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 diff --git a/test/fixtures/pension_sources.yml b/test/fixtures/pension_sources.yml new file mode 100644 index 000000000..19d91960f --- /dev/null +++ b/test/fixtures/pension_sources.yml @@ -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 diff --git a/test/fixtures/retirement_bucket_entries.yml b/test/fixtures/retirement_bucket_entries.yml new file mode 100644 index 000000000..822145143 --- /dev/null +++ b/test/fixtures/retirement_bucket_entries.yml @@ -0,0 +1,3 @@ +bob_investment: + goal_retirement: retirement_bob + account: investment diff --git a/test/models/goal/retirement_adjustment_test.rb b/test/models/goal/retirement_adjustment_test.rb new file mode 100644 index 000000000..4436c2e40 --- /dev/null +++ b/test/models/goal/retirement_adjustment_test.rb @@ -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 diff --git a/test/models/goal/retirement_statement_test.rb b/test/models/goal/retirement_statement_test.rb new file mode 100644 index 000000000..77717c0fd --- /dev/null +++ b/test/models/goal/retirement_statement_test.rb @@ -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 diff --git a/test/models/goal/retirement_test.rb b/test/models/goal/retirement_test.rb index f1ca0e206..b6c55d1fb 100644 --- a/test/models/goal/retirement_test.rb +++ b/test/models/goal/retirement_test.rb @@ -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 diff --git a/test/models/pension_source_test.rb b/test/models/pension_source_test.rb new file mode 100644 index 000000000..d67e954ee --- /dev/null +++ b/test/models/pension_source_test.rb @@ -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 diff --git a/test/models/retirement_bucket_entry_test.rb b/test/models/retirement_bucket_entry_test.rb new file mode 100644 index 000000000..4584a21a3 --- /dev/null +++ b/test/models/retirement_bucket_entry_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 461ebe3a6..332208663 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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