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 %> + +
+
+

+ <%= t("retirement.show.title") %> +

+

+ <%= t("retirement.show.subtitle") %> +

+
+ +
+
+ <%= 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