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:
@@ -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
|
||||
|
||||
27
app/models/goal/retirement_adjustment.rb
Normal file
27
app/models/goal/retirement_adjustment.rb
Normal 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
|
||||
54
app/models/goal/retirement_statement.rb
Normal file
54
app/models/goal/retirement_statement.rb
Normal 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
|
||||
45
app/models/pension_source.rb
Normal file
45
app/models/pension_source.rb
Normal 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
|
||||
13
app/models/retirement_bucket_entry.rb
Normal file
13
app/models/retirement_bucket_entry.rb
Normal 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
|
||||
17
config/locales/models/retirement/en.yml
Normal file
17
config/locales/models/retirement/en.yml
Normal 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."
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRetirementParamsToGoals < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :goals, :retirement_params, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
27
db/migrate/20260529120110_create_pension_sources.rb
Normal file
27
db/migrate/20260529120110_create_pension_sources.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
92
db/schema.rb
generated
@@ -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"
|
||||
|
||||
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