Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/budget_categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def rule_prompt_settings_params
def user_params
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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/models/budget.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
7 changes: 6 additions & 1 deletion app/views/budgets/_budget_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
<span class="text-primary font-medium text-lg lg:text-base">
<%= @budget.name %>
<% if @budget.family.personal_budgets? && @budget.user.present? %>
<span class="text-secondary font-normal ml-1">(<%= @budget.user.first_name %>)</span>
<% end %>
</span>
<%= icon("chevron-down") %>
<% end %>

Expand Down
10 changes: 10 additions & 0 deletions app/views/settings/preferences/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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.admin? %>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm"><%= t(".personal_budgets") %></p>
<p class="text-secondary text-sm"><%= t(".personal_budgets_hint") %></p>
</div>
<%= family_form.toggle :personal_budgets,
data: { auto_submit_form_target: "auto" } %>
</div>
<% end %>
<% if @user.family.uses_custom_month_start? %>
<div class="text-sm text-warning bg-warning/10 p-3 rounded-lg">
<%= t(".month_start_day_warning") %>
Expand Down
2 changes: 2 additions & 0 deletions config/locales/views/settings/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions config/locales/views/settings/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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
# 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,
# 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 ],
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,
where: "user_id IS NOT NULL",
name: "index_budgets_personal_unique"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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
# 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
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
24 changes: 24 additions & 0 deletions db/migrate/20260517120001_change_budgets_user_fk_to_cascade.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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
2 changes: 2 additions & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

42 changes: 42 additions & 0 deletions test/models/personal_budget_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading