From f4e5412d17f216e16a2d0dc092e2365ba0289c91 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Mon, 11 May 2026 16:52:36 +0200 Subject: [PATCH 1/9] Add personal budgets feature toggleable by super-admins - Add `personal_budgets` setting to families - Add `user_id` to budgets to allow individual ownership - Update `Budget.find_or_bootstrap` to respect the new setting - Filter budgets by user in controllers when personal budgets are active - Add UI toggle in family preferences restricted to super_admin users - Update budget header to display owner's name when applicable --- Gemfile | 4 +- Gemfile.lock | 2 +- .../budget_categories_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/models/budget.rb | 6 ++- app/models/family.rb | 1 + app/views/budgets/_budget_header.html.erb | 7 +++- app/views/settings/preferences/show.html.erb | 10 +++++ config/locales/views/settings/en.yml | 2 + config/locales/views/settings/fr.yml | 2 + ...gets_to_families_and_user_id_to_budgets.rb | 18 ++++++++ db/schema.rb | 10 +++-- test/models/personal_budget_test.rb | 42 +++++++++++++++++++ 13 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb create mode 100644 test/models/personal_budget_test.rb diff --git a/Gemfile b/Gemfile index a58e0ccf99..c2e9c6d948 100644 --- a/Gemfile +++ b/Gemfile @@ -76,7 +76,7 @@ gem "octokit" gem "pagy" gem "rails-i18n" gem "rails-settings-cached" -gem "tzinfo-data", platforms: %i[windows jruby] +gem "tzinfo-data", platforms: %i[mswin mswin64 mingw x64_mingw jruby] gem "csv" gem "rchardet" # Character encoding detection gem "redcarpet" @@ -109,7 +109,7 @@ gem "anthropic", "~> 1.0" gem "langfuse-ruby", "~> 0.1.4", require: "langfuse" group :development, :test do - gem "debug", platforms: %i[mri windows] + gem "debug", platforms: %i[mri mswin mswin64 mingw x64_mingw] gem "brakeman", require: false gem "rubocop-rails-omakase", require: false gem "i18n-tasks" diff --git a/Gemfile.lock b/Gemfile.lock index 6bc0b988d3..179620b7b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -961,7 +961,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.9p82 BUNDLED WITH 2.6.7 diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index 65880ea05a..30b16a0556 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -51,6 +51,6 @@ def budgeted_spending_param def set_budget start_date = Budget.param_to_date(params[:budget_month_year], family: Current.family) - @budget = Current.family.budgets.find_by(start_date: start_date) + @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ab88dcc156..41491dd612 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -112,7 +112,7 @@ def rule_prompt_settings_params end def user_params - family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ] + family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :personal_budgets, :id ] if Current.user.admin? family_attrs.push(:moniker, :default_account_sharing) family_attrs << { enabled_currencies: [] } diff --git a/app/models/budget.rb b/app/models/budget.rb index 7e673b3fa2..b3e63623cc 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -6,11 +6,12 @@ class Budget < ApplicationRecord attr_accessor :current_user belongs_to :family + belongs_to :user, optional: true has_many :budget_categories, -> { includes(:category) }, dependent: :destroy validates :start_date, :end_date, presence: true - validates :start_date, :end_date, uniqueness: { scope: :family_id } + validates :start_date, :end_date, uniqueness: { scope: [ :family_id, :user_id ] } monetize :budgeted_spending, :expected_income, :allocated_spending, :actual_spending, :available_to_spend, :available_to_allocate, @@ -53,7 +54,8 @@ def find_or_bootstrap(family, start_date:, user: nil) budget = Budget.find_or_create_by!( family: family, start_date: budget_start, - end_date: budget_end + end_date: budget_end, + user: family.personal_budgets? ? user : nil ) do |b| b.currency = family.currency end diff --git a/app/models/family.rb b/app/models/family.rb index c5f8f22527..b14825442d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -116,6 +116,7 @@ def goal_linked_account_ids validates :moniker, inclusion: { in: MONIKERS } validates :assistant_type, inclusion: { in: ASSISTANT_TYPES } validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS } + validates :personal_budgets, inclusion: { in: [ true, false ] } before_validation :normalize_enabled_currencies! diff --git a/app/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb index b1df7cee73..792ae41a4b 100644 --- a/app/views/budgets/_budget_header.html.erb +++ b/app/views/budgets/_budget_header.html.erb @@ -29,7 +29,12 @@ <%= render DS::Popover.new(variant: "button") do |popover| %> <% popover.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %> - <%= @budget.name %> + + <%= @budget.name %> + <% if @budget.family.personal_budgets? && @budget.user.present? %> + (<%= @budget.user.first_name %>) + <% end %> + <%= icon("chevron-down") %> <% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 80b767bd1f..9bd25fc0be 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -28,6 +28,16 @@ (1..28).map { |day| [localized_ordinal(day), day] }, { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, { data: { auto_submit_form_target: "auto" } } %> + <% if Current.user.super_admin? %> +
+
+

<%= t(".personal_budgets") %>

+

<%= t(".personal_budgets_hint") %>

+
+ <%= family_form.toggle :personal_budgets, + data: { auto_submit_form_target: "auto" } %> +
+ <% end %> <% if @user.family.uses_custom_month_start? %>
<%= t(".month_start_day_warning") %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 131994efa9..3f91e3b79b 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -131,6 +131,8 @@ en: month_start_day: Budget month starts on month_start_day_hint: Set when your budget month starts (e.g., payday) month_start_day_warning: Your budgets and MTD calculations will use this custom start day instead of the 1st of each month. + personal_budgets: Personal budgets + personal_budgets_hint: Enable individual budgets for each family member. translations_notice: Please note, we are still working on translations for various languages. currencies_title: "%{moniker} Currencies" currencies_subtitle: Choose which currencies appear in money fields for your %{moniker} diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index 6faca441d8..094503dd57 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -58,6 +58,8 @@ fr: month_start_day: Le mois budgétaire commence le month_start_day_hint: Définissez le jour de début de votre mois budgétaire (ex. jour de paie) month_start_day_warning: Vos budgets et vos calculs MTD utiliseront ce jour de début personnalisé au lieu du 1er de chaque mois. + personal_budgets: Budgets personnels + personal_budgets_hint: Activer les budgets individuels pour chaque membre de la famille. currencies_title: "Devises de %{moniker}" currencies_subtitle: Choisissez les devises qui apparaissent dans les champs monétaires de votre %{moniker} base_currency_label: Devise de base diff --git a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb new file mode 100644 index 0000000000..963481e7c9 --- /dev/null +++ b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb @@ -0,0 +1,18 @@ +class AddPersonalBudgetsToFamiliesAndUserIdToBudgets < ActiveRecord::Migration[7.2] + def change + add_column :families, :personal_budgets, :boolean, default: false, null: false + + # :optional is not available in all Rails 7.2 versions / setups → use null: true instead + add_reference :budgets, :user, + type: :uuid, + foreign_key: true, + null: true + + # Update index to include user_id + remove_index :budgets, name: "index_budgets_on_family_id_and_start_date_and_end_date" + + add_index :budgets, [:family_id, :start_date, :end_date, :user_id], + unique: true, + name: "index_budgets_on_family_start_end_user" + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index c055adbfc2..0a5db61482 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -378,8 +378,10 @@ t.string "currency", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true + t.uuid "user_id" + t.index ["family_id", "start_date", "end_date", "user_id"], name: "index_budgets_on_family_start_end_user", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" + t.index ["user_id"], name: "index_budgets_on_user_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -767,7 +769,8 @@ t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true t.datetime "last_sync_all_attempted_at" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" + t.boolean "personal_budgets", 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 @@ -1616,7 +1619,7 @@ t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -2064,6 +2067,7 @@ add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" + add_foreign_key "budgets", "users" add_foreign_key "categories", "families" add_foreign_key "chats", "users" add_foreign_key "coinbase_accounts", "coinbase_items" diff --git a/test/models/personal_budget_test.rb b/test/models/personal_budget_test.rb new file mode 100644 index 0000000000..4464fbf6a6 --- /dev/null +++ b/test/models/personal_budget_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class PersonalBudgetTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @user1 = users(:josh) + @user2 = users(:ann) + @date = Date.current.beginning_of_month + end + + test "shared budget by default" do + @family.update!(personal_budgets: false) + + budget1 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + budget2 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) + + assert_equal budget1.id, budget2.id + assert_nil budget1.user_id + end + + test "separate budgets when personal_budgets is enabled" do + @family.update!(personal_budgets: true) + + budget1 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + budget2 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) + + assert_not_equal budget1.id, budget2.id + assert_equal @user1.id, budget1.user_id + assert_equal @user2.id, budget2.user_id + end + + test "find_or_bootstrap handles transition from shared to personal" do + @family.update!(personal_budgets: false) + shared_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + + @family.update!(personal_budgets: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + + assert_not_equal shared_budget.id, personal_budget.id + assert_equal @user1.id, personal_budget.user_id + end +end From 9ff93a8295b8aa5449c481bdaccb01bf6c652f36 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 14 May 2026 11:53:29 +0200 Subject: [PATCH 2/9] Add unique indexes for personal and shared budgets in the budgets table --- ...nal_budgets_to_families_and_user_id_to_budgets.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb index 963481e7c9..5692958469 100644 --- a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb +++ b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb @@ -11,8 +11,16 @@ def change # Update index to include user_id remove_index :budgets, name: "index_budgets_on_family_id_and_start_date_and_end_date" + # Shared budgets (user_id IS NULL) + add_index :budgets, [:family_id, :start_date, :end_date], + unique: true, + where: "user_id IS NULL", + name: "index_budgets_shared_unique" + + # Personal budgets (user_id IS NOT NULL) add_index :budgets, [:family_id, :start_date, :end_date, :user_id], unique: true, - name: "index_budgets_on_family_start_end_user" + where: "user_id IS NOT NULL", + name: "index_budgets_personal_unique" end -end \ No newline at end of file +end From 9731d67ff92854e2fa8508cd7a897ba3fb56b03b Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 14 May 2026 13:52:08 +0200 Subject: [PATCH 3/9] feat(budgets): add user reference with nullify on delete and unique indexes for personal budgets --- ...dd_personal_budgets_to_families_and_user_id_to_budgets.rb | 4 +++- db/schema.rb | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb index 5692958469..47b7e3718b 100644 --- a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb +++ b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb @@ -3,9 +3,11 @@ def change add_column :families, :personal_budgets, :boolean, default: false, null: false # :optional is not available in all Rails 7.2 versions / setups → use null: true instead + # on_delete: :nullify allows user deletion without breaking FK constraint + # (personal budgets become shared/family-level budgets when user is removed) add_reference :budgets, :user, type: :uuid, - foreign_key: true, + foreign_key: { on_delete: :nullify }, null: true # Update index to include user_id diff --git a/db/schema.rb b/db/schema.rb index 0a5db61482..b68ea16878 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -379,7 +379,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" - t.index ["family_id", "start_date", "end_date", "user_id"], name: "index_budgets_on_family_start_end_user", unique: true + t.index ["family_id", "start_date", "end_date"], name: "index_budgets_shared_unique", unique: true, where: "user_id IS NULL" + t.index ["family_id", "start_date", "end_date", "user_id"], name: "index_budgets_personal_unique", unique: true, where: "user_id IS NOT NULL" t.index ["family_id"], name: "index_budgets_on_family_id" t.index ["user_id"], name: "index_budgets_on_user_id" end @@ -2067,7 +2068,7 @@ add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" - add_foreign_key "budgets", "users" + add_foreign_key "budgets", "users", on_delete: :nullify add_foreign_key "categories", "families" add_foreign_key "chats", "users" add_foreign_key "coinbase_accounts", "coinbase_items" From ed18bc7f32d20dd413296315cdb450ddd091f571 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Mon, 11 May 2026 17:09:59 +0200 Subject: [PATCH 4/9] Update personal budgets access for admins and adjust user parameters --- app/controllers/users_controller.rb | 6 ++++-- app/views/settings/preferences/show.html.erb | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 41491dd612..b84ae93302 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -112,8 +112,9 @@ def rule_prompt_settings_params end def user_params - family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :personal_budgets, :id ] + family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ] if Current.user.admin? + family_attrs.push(:personal_budgets) # Needed for updating existing family family_attrs.push(:moniker, :default_account_sharing) family_attrs << { enabled_currencies: [] } end @@ -137,8 +138,9 @@ def admin_family_change_requested? moniker_changed = family_attrs[:moniker].present? && family_attrs[:moniker] != Current.family.moniker sharing_changed = family_attrs[:default_account_sharing].present? && family_attrs[:default_account_sharing] != Current.family.default_account_sharing enabled_currencies_changed = family_attrs.key?(:enabled_currencies) + personal_budgets_changed = family_attrs.key?(:personal_budgets) - moniker_changed || sharing_changed || enabled_currencies_changed + moniker_changed || sharing_changed || enabled_currencies_changed || personal_budgets_changed end def ensure_admin diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 9bd25fc0be..62a4e11533 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -28,7 +28,7 @@ (1..28).map { |day| [localized_ordinal(day), day] }, { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, { data: { auto_submit_form_target: "auto" } } %> - <% if Current.user.super_admin? %> + <% if Current.user.admin? %>

<%= t(".personal_budgets") %>

From 2db508bbec2ff5696e4f3765f4b3e1dc8b7bb2cd Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Mon, 18 May 2026 12:53:28 +0200 Subject: [PATCH 5/9] feat(budgets): implement ON DELETE CASCADE for user foreign key in budgets --- ...e_budgets_user_fk_on_delete_to_restrict.rb | 18 +++++++++++++ ...20001_change_budgets_user_fk_to_cascade.rb | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb create mode 100644 db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb diff --git a/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb b/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb new file mode 100644 index 0000000000..dd933020f1 --- /dev/null +++ b/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb @@ -0,0 +1,18 @@ +class ChangeBudgetsUserFkOnDeleteToRestrict < ActiveRecord::Migration[7.2] + # NOTE: This migration had a misleading name (suggested RESTRICT when actually + # implementing CASCADE). It has been superseded by the correctly-named migration + # 20260517120001_change_budgets_user_fk_to_cascade.rb. + # + # This migration remains as a NO-OP to preserve migration history and allow + # existing dev/test databases that have already executed it to continue without error. + # On production or fresh environments, only 20260517120001 will be executed. + + def up + # NO-OP: The actual FK change is implemented in the replacement migration + # 20260517120001_change_budgets_user_fk_to_cascade.rb + end + + def down + # NO-OP + end +end diff --git a/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb b/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb new file mode 100644 index 0000000000..d7d4eb983a --- /dev/null +++ b/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb @@ -0,0 +1,25 @@ +class ChangeBudgetsUserFkToCascade < ActiveRecord::Migration[7.2] + def up + # Replace existing FK with ON DELETE CASCADE to remove personal budgets when + # their owning user is deleted. This prevents orphaned user references and + # aligns with the requested behavior. + # + # CAUTION: This is a DESTRUCTIVE operation. When a user is deleted, + # all personal budgets (user_id ≠ NULL) for that user will be automatically + # deleted from the database. This ensures no orphaned rows but results in + # data loss if those budgets were important. + # + # This migration replaces the incorrectly-named predecessor migration + # (20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb) which had + # the same intent but a misleading name (suggested RESTRICT when actually + # implementing CASCADE). + remove_foreign_key :budgets, :users + add_foreign_key :budgets, :users, on_delete: :cascade + end + + def down + remove_foreign_key :budgets, :users + add_foreign_key :budgets, :users, on_delete: :nullify + end +end + From a57276a0c7f542d57d8563d16aec0d1957959e18 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Sun, 17 May 2026 15:01:57 +0200 Subject: [PATCH 6/9] feat(budgets): change user foreign key on delete to cascade and update related indexes --- ...nal_budgets_to_families_and_user_id_to_budgets.rb | 10 +++++++--- ...0_change_budgets_user_fk_on_delete_to_restrict.rb | 11 ++++++++--- test/models/personal_budget_test.rb | 12 ++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb index 47b7e3718b..922d5eb2ae 100644 --- a/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb +++ b/db/migrate/20260511160000_add_personal_budgets_to_families_and_user_id_to_budgets.rb @@ -7,20 +7,24 @@ def change # (personal budgets become shared/family-level budgets when user is removed) add_reference :budgets, :user, type: :uuid, - foreign_key: { on_delete: :nullify }, + # Use CASCADE to remove personal budgets when the owning user is deleted. + # This is appropriate if removing a user should also delete their + # personal budgets. Alternatively use :restrict to prevent deletion + # until budgets are reassigned. Chosen: :cascade per request. + foreign_key: { on_delete: :cascade }, null: true # Update index to include user_id remove_index :budgets, name: "index_budgets_on_family_id_and_start_date_and_end_date" # Shared budgets (user_id IS NULL) - add_index :budgets, [:family_id, :start_date, :end_date], + add_index :budgets, [ :family_id, :start_date, :end_date ], unique: true, where: "user_id IS NULL", name: "index_budgets_shared_unique" # Personal budgets (user_id IS NOT NULL) - add_index :budgets, [:family_id, :start_date, :end_date, :user_id], + add_index :budgets, [ :family_id, :start_date, :end_date, :user_id ], unique: true, where: "user_id IS NOT NULL", name: "index_budgets_personal_unique" diff --git a/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb b/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb index dd933020f1..2caaecdc66 100644 --- a/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb +++ b/db/migrate/20260517120000_change_budgets_user_fk_on_delete_to_restrict.rb @@ -8,11 +8,16 @@ class ChangeBudgetsUserFkOnDeleteToRestrict < ActiveRecord::Migration[7.2] # On production or fresh environments, only 20260517120001 will be executed. def up - # NO-OP: The actual FK change is implemented in the replacement migration - # 20260517120001_change_budgets_user_fk_to_cascade.rb + # Replace existing FK with ON DELETE CASCADE to remove personal budgets when + # their owning user is deleted. This prevents orphaned user references and + # aligns with the requested behavior. + remove_foreign_key :budgets, :users + add_foreign_key :budgets, :users, on_delete: :cascade end def down - # NO-OP + remove_foreign_key :budgets, :users + # restore previous behavior; adjust if another prior behavior was used + add_foreign_key :budgets, :users, on_delete: :nullify end end diff --git a/test/models/personal_budget_test.rb b/test/models/personal_budget_test.rb index 4464fbf6a6..80018e5187 100644 --- a/test/models/personal_budget_test.rb +++ b/test/models/personal_budget_test.rb @@ -10,20 +10,20 @@ class PersonalBudgetTest < ActiveSupport::TestCase test "shared budget by default" do @family.update!(personal_budgets: false) - + budget1 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) budget2 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) - + assert_equal budget1.id, budget2.id assert_nil budget1.user_id end test "separate budgets when personal_budgets is enabled" do @family.update!(personal_budgets: true) - + budget1 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) budget2 = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) - + assert_not_equal budget1.id, budget2.id assert_equal @user1.id, budget1.user_id assert_equal @user2.id, budget2.user_id @@ -32,10 +32,10 @@ class PersonalBudgetTest < ActiveSupport::TestCase test "find_or_bootstrap handles transition from shared to personal" do @family.update!(personal_budgets: false) shared_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) - + @family.update!(personal_budgets: true) personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) - + assert_not_equal shared_budget.id, personal_budget.id assert_equal @user1.id, personal_budget.user_id end From d9ad15d966afd942664f98cf834e6382aad22c77 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Mon, 1 Jun 2026 20:05:15 +0200 Subject: [PATCH 7/9] feat(users): add additional test users for PersonalBudgetTest --- test/fixtures/users.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 109b78a139..c30d797f9b 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -100,3 +100,29 @@ sso_only: role: admin onboarded_at: <%= 1.day.ago %> ai_enabled: true + +# Additional test users expected by PersonalBudgetTest +josh: + family: empty + first_name: Josh + last_name: Tester + email: josh@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + onboarded_at: <%= 2.days.ago %> + role: member + ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + +ann: + family: empty + first_name: Ann + last_name: Tester + email: ann@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + onboarded_at: <%= 2.days.ago %> + role: member + ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + From e6151e106ae7c32b932c43c408c3fb5ae9fd618a Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Mon, 1 Jun 2026 19:45:01 +0200 Subject: [PATCH 8/9] fix trailing line --- db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb b/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb index d7d4eb983a..cf75885a7f 100644 --- a/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb +++ b/db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb @@ -22,4 +22,3 @@ def down add_foreign_key :budgets, :users, on_delete: :nullify end end - From a9ff70eb6d05fa6685cfcd253009e14340fff7da Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Sat, 6 Jun 2026 19:55:39 +0200 Subject: [PATCH 9/9] chore: update Ruby version from 3.4.9 to 3.4.7 in Gemfile.lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 179620b7b3..6bc0b988d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -961,7 +961,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.9p82 + ruby 3.4.7p58 BUNDLED WITH 2.6.7