diff --git a/app/controllers/retirement_controller.rb b/app/controllers/retirement_controller.rb
new file mode 100644
index 000000000..cab74eaac
--- /dev/null
+++ b/app/controllers/retirement_controller.rb
@@ -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
diff --git a/app/models/family.rb b/app/models/family.rb
index 9cd33c853..37dd03722 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -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?
diff --git a/app/models/goal.rb b/app/models/goal.rb
index da4f597af..6531a7b79 100644
--- a/app/models/goal.rb
+++ b/app/models/goal.rb
@@ -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
diff --git a/app/models/goal/retirement.rb b/app/models/goal/retirement.rb
new file mode 100644
index 000000000..f88832e91
--- /dev/null
+++ b/app/models/goal/retirement.rb
@@ -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
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 39077cc6a..0b03cd32d 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -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 %>
diff --git a/app/views/retirement/show.html.erb b/app/views/retirement/show.html.erb
new file mode 100644
index 000000000..1479cfa8b
--- /dev/null
+++ b/app/views/retirement/show.html.erb
@@ -0,0 +1,26 @@
+<% content_for :breadcrumbs do %>
+ <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
+<% end %>
+
+
+
+
+
+
+ <%= icon("sun", size: "lg") %>
+
+
+ <%= t("retirement.show.placeholder_heading") %>
+
+
+ <%= t("retirement.show.placeholder_body") %>
+
+
+
diff --git a/config/locales/breadcrumbs/en.yml b/config/locales/breadcrumbs/en.yml
index c6f1ba1d8..d46371a41 100644
--- a/config/locales/breadcrumbs/en.yml
+++ b/config/locales/breadcrumbs/en.yml
@@ -65,6 +65,7 @@ en:
recurring_transactions: Recurring
registrations: Sign up
reports: Reports
+ retirement: Retirement
rules: Rules
securities: Security
security: Security
diff --git a/config/locales/models/goal/en.yml b/config/locales/models/goal/en.yml
index 1df923657..4e733b591 100644
--- a/config/locales/models/goal/en.yml
+++ b/config/locales/models/goal/en.yml
@@ -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.
diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml
index e38487900..c61d4001d 100644
--- a/config/locales/views/layout/en.yml
+++ b/config/locales/views/layout/en.yml
@@ -10,6 +10,7 @@ en:
home: Home
reports: Reports
goals: Goals
+ retirement: Retirement
transactions: Transactions
auth:
existing_account: Already have an account?
diff --git a/config/locales/views/retirement/en.yml b/config/locales/views/retirement/en.yml
new file mode 100644
index 000000000..de966903e
--- /dev/null
+++ b/config/locales/views/retirement/en.yml
@@ -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.
diff --git a/config/routes.rb b/config/routes.rb
index 7d5ec7e2c..7383c63f9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20260529120000_add_sti_and_owner_to_goals.rb b/db/migrate/20260529120000_add_sti_and_owner_to_goals.rb
new file mode 100644
index 000000000..94b584e2c
--- /dev/null
+++ b/db/migrate/20260529120000_add_sti_and_owner_to_goals.rb
@@ -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
diff --git a/db/migrate/20260529120001_add_retirement_disabled_to_families.rb b/db/migrate/20260529120001_add_retirement_disabled_to_families.rb
new file mode 100644
index 000000000..7cd3296da
--- /dev/null
+++ b/db/migrate/20260529120001_add_retirement_disabled_to_families.rb
@@ -0,0 +1,5 @@
+class AddRetirementDisabledToFamilies < ActiveRecord::Migration[7.2]
+ def change
+ add_column :families, :retirement_disabled, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7cbbf8663..b0963531c 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_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"
diff --git a/test/controllers/retirement_controller_test.rb b/test/controllers/retirement_controller_test.rb
new file mode 100644
index 000000000..0f2f0e449
--- /dev/null
+++ b/test/controllers/retirement_controller_test.rb
@@ -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
diff --git a/test/models/goal/retirement_test.rb b/test/models/goal/retirement_test.rb
new file mode 100644
index 000000000..f1ca0e206
--- /dev/null
+++ b/test/models/goal/retirement_test.rb
@@ -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
diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb
index 70529f149..afd88f7a1 100644
--- a/test/models/goal_test.rb
+++ b/test/models/goal_test.rb
@@ -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