Files
sure/app/models/goal/retirement_statement.rb
Guillem Arias bf0f10c21f 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.
2026-05-29 10:36:18 +02:00

55 lines
1.7 KiB
Ruby

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