feat(retirement): PR1 scaffold + preview-gated /retirement page

Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.

- STI on goals: add `type` (default "Goal") + `user_id` columns;
  partial index for `Goal::Retirement` rows; check constraint
  requiring an owner on retirement rows. Existing goals backfill to
  `type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
  `editable_by?` narrowed to owner-only. Parent depository-only
  linked-account validations no-op'd; PR2 introduces
  `RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
  `Family#retirement_enabled?(user)` helper as tier 2 of the gate.
  Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
  `ensure_module_enabled!` then a placeholder body. Unknown to users
  without preview features; 404 when the family killswitch is on (the
  feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
  preview features AND the family has retirement enabled, so the
  killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
  and the new `owner.must_belong_to_family` validation message under
  the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
  validations, `editable_by?` on both Goal and Goal::Retirement, gate
  matrix on the controller, nav-item visibility under both preview and
  family flags, base-row STI backfill.

Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
This commit is contained in:
Guillem Arias
2026-05-29 09:18:06 +02:00
parent e401f43fe1
commit ca73a2f389
17 changed files with 313 additions and 12 deletions

View File

@@ -0,0 +1,26 @@
class RetirementController < ApplicationController
include PreviewGateable
before_action :require_preview_features!
before_action :ensure_module_enabled!
before_action :load_goal_retirement
def show
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("breadcrumbs.retirement"), nil ]
]
end
private
def ensure_module_enabled!
return if Current.family.retirement_enabled?(Current.user)
raise ActionController::RoutingError, "Not Found"
end
def load_goal_retirement
@goal = Current.family.goals
.where(type: "Goal::Retirement", user_id: Current.user.id)
.first
end
end

View File

@@ -405,6 +405,16 @@ class Family < ApplicationRecord
Rails.application.config.app_mode.self_hosted?
end
# Tier 2 of the Retirement preview gate. Tier 1 is
# User#preview_features_enabled? (read via PreviewGateable). The
# retirement_disabled column defaults to false; admins flip it to hide
# the feature household-wide without revoking individual preview
# access. Per-user param futureproofs the v1.1 non-owner viewer split.
def retirement_enabled?(user)
return false if user.nil?
!retirement_disabled?
end
private
def normalize_enabled_currencies!
if enabled_currencies.blank?

View File

@@ -1,6 +1,8 @@
class Goal < ApplicationRecord
include AASM, Monetizable
self.inheritance_column = :type
COLORS = Category::COLORS
ICONS = Category.icon_codes
@@ -399,6 +401,13 @@ class Goal < ApplicationRecord
Money.new(delta, currency)
end
# Family-scoped default for base Goal records. Subclasses like
# Goal::Retirement narrow this to owner-only by overriding.
def editable_by?(user)
return false if user.nil?
family_id == user.family_id
end
private
# Cleared after every AASM transition. The state column drives the
# display_status / projection_summary memos; without this the same

View File

@@ -0,0 +1,26 @@
class Goal::Retirement < Goal
belongs_to :owner, class_name: "User", foreign_key: :user_id
validates :owner, presence: true
validate :owner_belongs_to_family
def editable_by?(user)
return false if user.nil?
user_id == user.id
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.
def must_have_at_least_one_linked_account
end
def linked_accounts_must_be_depository
end
def owner_belongs_to_family
return if owner.nil? || family_id.nil?
errors.add(:owner, :must_belong_to_family) unless owner.family_id == family_id
end
end

View File

@@ -12,6 +12,7 @@ else
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
(preview_gated_nav_item({ name: t(".nav.retirement"), path: retirement_path, icon: "sun", icon_custom: false, active: page_active?(retirement_path) }) if Current.family.retirement_enabled?(Current.user)),
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact
end %>

View File

@@ -0,0 +1,26 @@
<% content_for :breadcrumbs do %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
<% end %>
<div class="space-y-6">
<header class="space-y-2">
<h1 class="text-primary text-2xl font-semibold">
<%= t("retirement.show.title") %>
</h1>
<p class="text-secondary text-sm">
<%= t("retirement.show.subtitle") %>
</p>
</header>
<section class="bg-container shadow-border-xs rounded-xl p-8 text-center">
<div class="mx-auto mb-4 w-12 h-12 flex items-center justify-center rounded-full bg-surface-inset">
<%= icon("sun", size: "lg") %>
</div>
<p class="text-primary text-base font-medium mb-2">
<%= t("retirement.show.placeholder_heading") %>
</p>
<p class="text-secondary text-sm max-w-prose mx-auto">
<%= t("retirement.show.placeholder_body") %>
</p>
</section>
</div>

View File

@@ -65,6 +65,7 @@ en:
recurring_transactions: Recurring
registrations: Sign up
reports: Reports
retirement: Retirement
rules: Rules
securities: Security
security: Security

View File

@@ -11,6 +11,7 @@ en:
notes: Notes
state: State
linked_accounts: Linked accounts
owner: Owner
errors:
models:
goal:
@@ -23,3 +24,5 @@ en:
must_belong_to_family: Linked accounts must belong to the same family as the goal.
currency:
locked_after_linked: Can't change the currency after the goal is linked to accounts.
owner:
must_belong_to_family: must belong to the same family as the goal.

View File

@@ -10,6 +10,7 @@ en:
home: Home
reports: Reports
goals: Goals
retirement: Retirement
transactions: Transactions
auth:
existing_account: Already have an account?

View File

@@ -0,0 +1,8 @@
---
en:
retirement:
show:
title: Retirement
subtitle: Plan when you can stop working — and what you'll need to get there.
placeholder_heading: Retirement preview — coming soon
placeholder_body: We're building a unified retirement planning surface. You'll be able to set a target spending number, log pension statements, and see your projected freedom date here.

View File

@@ -317,6 +317,8 @@ Rails.application.routes.draw do
end
end
resource :retirement, only: %i[show], controller: "retirement"
resources :family_merchants, only: %i[index new create edit update destroy] do
collection do
get :merge

View File

@@ -0,0 +1,29 @@
class AddStiAndOwnerToGoals < ActiveRecord::Migration[7.2]
def up
add_column :goals, :type, :string, default: "Goal"
add_column :goals, :user_id, :uuid
execute "UPDATE goals SET type = 'Goal' WHERE type IS NULL"
change_column_null :goals, :type, false
add_foreign_key :goals, :users, column: :user_id, on_delete: :restrict
add_index :goals, [ :user_id, :type ],
where: "type = 'Goal::Retirement'",
name: "index_goals_on_user_and_type_retirement"
add_index :goals, [ :family_id, :type, :state ],
name: "index_goals_on_family_type_state"
add_check_constraint :goals,
"type <> 'Goal::Retirement' OR user_id IS NOT NULL",
name: "chk_goals_retirement_requires_owner"
end
def down
remove_check_constraint :goals, name: "chk_goals_retirement_requires_owner"
remove_index :goals, name: "index_goals_on_family_type_state"
remove_index :goals, name: "index_goals_on_user_and_type_retirement"
remove_foreign_key :goals, column: :user_id
remove_column :goals, :user_id
remove_column :goals, :type
end
end

View File

@@ -0,0 +1,5 @@
class AddRetirementDisabledToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :retirement_disabled, :boolean, default: false, null: false
end
end

31
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_19_100000) do
ActiveRecord::Schema[7.2].define(version: 2026_05_29_120001) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -53,7 +53,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) 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
@@ -70,6 +69,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) 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_19_100000) do
t.string "institution_domain"
t.string "institution_url"
t.string "institution_color"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", 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.datetime "sync_start_date"
t.jsonb "raw_payload"
t.text "api_key"
@@ -500,7 +500,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key"
t.index ["source"], name: "index_debug_log_entries_on_source"
t.index ["user_id"], name: "index_debug_log_entries_on_user_id"
t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level"
t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level"
end
create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -719,6 +719,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
t.string "default_account_sharing", default: "shared", null: false
t.string "enabled_currencies", array: true
t.datetime "last_sync_all_attempted_at"
t.boolean "retirement_disabled", default: false, null: false
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end
@@ -798,11 +799,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "icon"
t.string "type", default: "Goal", null: false
t.uuid "user_id"
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::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum"
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 "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
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -838,9 +844,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) 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: [], null: false
t.jsonb "raw_activities_payload", default: {}, null: false
t.jsonb "raw_cash_report_payload", default: [], null: false
t.jsonb "raw_holdings_payload", default: []
t.jsonb "raw_activities_payload", default: {}
t.jsonb "raw_cash_report_payload", default: []
t.date "report_date"
t.datetime "last_holdings_sync"
t.datetime "last_activities_sync"
@@ -854,8 +860,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) 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", null: false
t.boolean "scheduled_for_deletion", default: false, null: false
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false, null: false
t.jsonb "raw_payload"
t.string "query_id"
@@ -1977,6 +1983,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
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 "goals", "families", on_delete: :cascade
add_foreign_key "goals", "users", on_delete: :restrict
add_foreign_key "holdings", "account_providers"
add_foreign_key "holdings", "accounts", on_delete: :cascade
add_foreign_key "holdings", "securities"

View File

@@ -0,0 +1,58 @@
require "test_helper"
class RetirementControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
@family.update!(retirement_disabled: false)
sign_in @user
ensure_tailwind_build
end
test "redirects when preview features disabled" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get retirement_url
assert_redirected_to root_path
assert_match(/preview/i, flash[:alert])
end
test "404 when family retirement_disabled is true" do
@family.update!(retirement_disabled: true)
get retirement_url
assert_response :not_found
end
test "200 when preview features and family flag both allow" do
get retirement_url
assert_response :success
assert_match(/Retirement/i, response.body)
end
test "nav item rendered when preview enabled" do
get root_url
assert_select "a[href=?]", retirement_path
end
test "nav item hidden when preview disabled" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get root_url
assert_select "a[href=?]", retirement_path, count: 0
end
test "nav item hidden when family retirement disabled" do
@family.update!(retirement_disabled: true)
get root_url
assert_select "a[href=?]", retirement_path, count: 0
end
end

View File

@@ -0,0 +1,76 @@
require "test_helper"
class Goal::RetirementTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@owner = users(:family_admin)
@sibling = users(:family_member)
@stranger = users(:empty)
end
test "STI: Goal.find returns Goal::Retirement instance" do
retirement = Goal::Retirement.create!(
family: @family,
owner: @owner,
name: "Retirement",
target_amount: 1_000_000,
currency: "USD"
)
fetched = Goal.find(retirement.id)
assert_instance_of Goal::Retirement, fetched
assert_equal "Goal::Retirement", fetched.type
end
test "requires owner" do
retirement = Goal::Retirement.new(
family: @family,
name: "Retirement",
target_amount: 1_000_000,
currency: "USD"
)
assert_not retirement.valid?
assert_includes retirement.errors[:owner], "can't be blank"
end
test "owner must belong to family" do
retirement = Goal::Retirement.new(
family: @family,
owner: @stranger,
name: "Retirement",
target_amount: 1_000_000,
currency: "USD"
)
assert_not retirement.valid?
assert_match(/belong to the same family/i, retirement.errors[:owner].join(" "))
end
test "editable_by? owner true, sibling false, stranger false, nil false" do
retirement = Goal::Retirement.create!(
family: @family,
owner: @owner,
name: "Retirement",
target_amount: 1_000_000,
currency: "USD"
)
assert retirement.editable_by?(@owner)
assert_not retirement.editable_by?(@sibling)
assert_not retirement.editable_by?(@stranger)
assert_not retirement.editable_by?(nil)
end
test "no linked_accounts required (parent validation bypassed)" do
retirement = Goal::Retirement.new(
family: @family,
owner: @owner,
name: "Retirement",
target_amount: 1_000_000,
currency: "USD"
)
assert retirement.valid?, retirement.errors.full_messages.to_sentence
end
end

View File

@@ -248,4 +248,17 @@ class GoalTest < ActiveSupport::TestCase
reloaded = Goal.find(@goal.id)
assert_equal "goals.show.pledge_just_saved", reloaded.pledge_action_label_key
end
test "STI: no Goal rows have NULL type after migration" do
assert_equal 0, Goal.where(type: nil).count
end
test "editable_by? base Goal: same family user true, foreign family false, nil false" do
same_family_user = users(:family_admin)
foreign_user = users(:empty)
assert @goal.editable_by?(same_family_user)
assert_not @goal.editable_by?(foreign_user)
assert_not @goal.editable_by?(nil)
end
end