diff --git a/db/migrate/20260529120150_allow_null_target_amount_for_retirement_goals.rb b/db/migrate/20260529120150_allow_null_target_amount_for_retirement_goals.rb new file mode 100644 index 000000000..c6e56b5af --- /dev/null +++ b/db/migrate/20260529120150_allow_null_target_amount_for_retirement_goals.rb @@ -0,0 +1,17 @@ +class AllowNullTargetAmountForRetirementGoals < ActiveRecord::Migration[7.2] + # Retirement plans derive their target from the forecast (PR3), so they + # are created before any target exists. Relax the column NOT NULL but + # keep the guarantee for savings goals via a type-aware check, so base + # Goal rows still require a target at the DB level. + def up + change_column_null :goals, :target_amount, true + add_check_constraint :goals, + "type <> 'Goal' OR target_amount IS NOT NULL", + name: "chk_goals_savings_requires_target" + end + + def down + remove_check_constraint :goals, name: "chk_goals_savings_requires_target" + change_column_null :goals, :target_amount, false + end +end diff --git a/db/schema.rb b/db/schema.rb index d09a40aa4..ed52f7da1 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_29_120140) do +ActiveRecord::Schema[7.2].define(version: 2026_05_29_120150) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -822,7 +822,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120140) do create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "name", null: false - t.decimal "target_amount", precision: 19, scale: 4, null: false + t.decimal "target_amount", precision: 19, scale: 4 t.string "currency", null: false t.date "target_date" t.string "color" @@ -841,6 +841,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120140) do 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 "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" + t.check_constraint "type::text <> 'Goal'::text OR target_amount IS NOT NULL", name: "chk_goals_savings_requires_target" t.check_constraint "type::text <> 'Goal::Retirement'::text OR user_id IS NOT NULL", name: "chk_goals_retirement_requires_owner" end diff --git a/test/models/goal/retirement_test.rb b/test/models/goal/retirement_test.rb index b6c55d1fb..b88a09be8 100644 --- a/test/models/goal/retirement_test.rb +++ b/test/models/goal/retirement_test.rb @@ -74,6 +74,26 @@ class Goal::RetirementTest < ActiveSupport::TestCase assert retirement.valid?, retirement.errors.full_messages.to_sentence end + test "for_owner bootstraps a valid plan without a target_amount" do + # family_member has no retirement fixture, so this exercises the + # create path (family_admin would just find retirement_bob). + member = users(:family_member) + + plan = Goal::Retirement.for_owner(member) + + assert plan.persisted? + assert_nil plan.target_amount + assert_equal member.id, plan.user_id + assert_equal member.family_id, plan.family_id + end + + test "for_owner is idempotent (one plan per user)" do + member = users(:family_member) + first = Goal::Retirement.for_owner(member) + second = Goal::Retirement.for_owner(member) + assert_equal first.id, second.id + 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)