fix(retirement): allow null target_amount for retirement plans

for_owner bootstraps a Goal::Retirement before any target exists, but
goals.target_amount was NOT NULL at the DB level — the target_amount_required?
hook only dropped the AR validation. Creating a plan for a user with no
existing record (the demo user, caught in a live browser smoke) raised
PG::NotNullViolation. Tests missed it because for_owner(family_admin)
finds the retirement_bob fixture and never inserts.

Relaxes the column to nullable and re-asserts the guarantee for savings
goals via a type-aware check (type <> 'Goal' OR target_amount IS NOT NULL),
so base Goal rows still require a target at the DB level. Adds tests that
exercise the create path (a user with no fixture plan).
This commit is contained in:
Guillem Arias
2026-05-29 10:53:00 +02:00
parent 26bb333c34
commit 47f441afbc
3 changed files with 40 additions and 2 deletions

View File

@@ -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

5
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_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

View File

@@ -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)