From e5750a6c096697b68669c878640dcaef119cecb2 Mon Sep 17 00:00:00 2001 From: JulienGourmet <69808509+jubbakka@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:36:46 +0200 Subject: [PATCH] feat(budgets): add per-user personal budgets with strict isolation (#2891) * feat(budgets): add per-user personal budgets with strict isolation Families can now opt into personal budgets (toggleable via family settings): each family member gets their own budget for a given period instead of sharing a single family-wide budget. - Add families.personal_budgets flag and budgets.user_id, with partial unique indexes so shared budgets (user_id IS NULL) and personal budgets (user_id IS NOT NULL) can't collide. - Budget.find_or_bootstrap scopes lookup/creation by user when the family has personal_budgets enabled. - Scope most_recent_initialized_budget (used to seed a new budget from the prior period) by user_id so one user's copy-forward never bleeds into another user's budget. - budgets.user_id cascades on user deletion so personal budgets don't outlive their owner. * feat(budgets): enforce user-specific budget ownership and cascade deletion * feat(budgets): display user name for personal budgets in budget card on the plan section * feat(budgets): enhance personal budgets display for admins with preview feature indication * feat(budgets): enforce user-specific budget and category visibility for personal budgets * feat(budgets): create budget section titles and add translations notice in preferences * feat(budgets): let household and personal budgets coexist with sharing Previously enabling personal_budgets made the shared household budget unreachable. Budget.find_or_bootstrap now takes an explicit household: flag so both can be resolved independently for the same period, with a new household_budget_enabled family setting to opt out of the household side and keep personal budgets only. Adds a BudgetShare model (read_only/read_write) so a member can grant another family member access to their personal budget, enforced via Budget#viewable_by?/editable_by? across BudgetsController, BudgetCategoriesController, PlansController, and the read-only API. Preferences gains a Budget sharing card (gated on preview access like the rest of the personal budgets UI) and an owner switcher pill ( Household / mine / shared-with-me) appears on the budget page and the Plan hub card. Also fixes personal budgets showing the same "actual spending" as the household budget: actual spending/income now scope to the budget owner's own accounts instead of the viewer's full accessible set, via a new accounts: override on IncomeStatement. Co-Authored-By: Claude Sonnet 5 * feat(budgets): enhance budget switcher with icons and improved styling * feat(budgets): remove user name display from budget card and header * feat(budgets): remove unique index on taggable_type and taggable_id in taggings * feat(budgets): enhance budget sharing functionality and improve UI elements * Collapse personal budget migrations --------- Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: sure-admin --- Gemfile | 4 +- .../api/v1/budget_categories_controller.rb | 7 +- app/controllers/api/v1/budgets_controller.rb | 12 +- .../budget_categories_controller.rb | 12 +- app/controllers/budgets_controller.rb | 19 +- app/controllers/concerns/budget_ownership.rb | 107 ++++++++++ app/controllers/plans_controller.rb | 6 +- .../settings/budget_shares_controller.rb | 40 ++++ .../settings/preferences_controller.rb | 2 + app/controllers/users_controller.rb | 5 +- app/helpers/budgets_helper.rb | 11 ++ app/models/assistant/function/get_budget.rb | 6 +- app/models/budget.rb | 66 ++++++- app/models/budget_share.rb | 30 +++ app/models/family.rb | 6 +- app/models/income_statement.rb | 13 +- app/models/user.rb | 8 + .../_budget_category.html.erb | 2 +- .../_budget_category_form.html.erb | 2 +- .../_confirm_button.html.erb | 2 +- app/views/budget_categories/index.html.erb | 4 +- app/views/budgets/_budget_donut.html.erb | 56 +++--- app/views/budgets/_budget_header.html.erb | 16 +- app/views/budgets/_budget_nav.html.erb | 6 +- .../budgets/_copy_previous_prompt.html.erb | 4 +- .../budgets/_over_allocation_warning.html.erb | 20 +- app/views/budgets/_owner_switcher.html.erb | 22 +++ app/views/budgets/_picker.html.erb | 6 +- app/views/budgets/edit.html.erb | 6 +- app/views/budgets/show.html.erb | 10 +- app/views/plans/_budget_card.html.erb | 58 +++--- app/views/plans/show.html.erb | 2 +- app/views/settings/preferences/show.html.erb | 69 ++++++- config/locales/views/budgets/en.yml | 3 + config/locales/views/budgets/fr.yml | 3 + config/locales/views/settings/en.yml | 19 +- config/locales/views/settings/fr.yml | 17 ++ config/routes.rb | 1 + .../20260818060353_add_personal_budgets.rb | 58 ++++++ db/schema.rb | 25 ++- .../v1/budget_categories_controller_test.rb | 25 +++ .../api/v1/budgets_controller_test.rb | 41 ++++ .../budget_categories_controller_test.rb | 52 +++++ test/controllers/budgets_controller_test.rb | 71 +++++++ test/controllers/plans_controller_test.rb | 31 +++ .../settings/budget_shares_controller_test.rb | 50 +++++ .../settings/preferences_controller_test.rb | 29 +++ test/fixtures/users.yml | 26 +++ .../assistant/function/get_budget_test.rb | 14 ++ test/models/budget_share_test.rb | 47 +++++ test/models/personal_budget_test.rb | 183 ++++++++++++++++++ test/views/budgets/budget_donut_view_test.rb | 2 +- 52 files changed, 1221 insertions(+), 115 deletions(-) create mode 100644 app/controllers/concerns/budget_ownership.rb create mode 100644 app/controllers/settings/budget_shares_controller.rb create mode 100644 app/models/budget_share.rb create mode 100644 app/views/budgets/_owner_switcher.html.erb create mode 100644 db/migrate/20260818060353_add_personal_budgets.rb create mode 100644 test/controllers/settings/budget_shares_controller_test.rb create mode 100644 test/models/budget_share_test.rb create mode 100644 test/models/personal_budget_test.rb diff --git a/Gemfile b/Gemfile index f38a1a3a3..4f6053ec5 100644 --- a/Gemfile +++ b/Gemfile @@ -75,7 +75,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" @@ -107,7 +107,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/app/controllers/api/v1/budget_categories_controller.rb b/app/controllers/api/v1/budget_categories_controller.rb index 5628e749e..3ab776b74 100644 --- a/app/controllers/api/v1/budget_categories_controller.rb +++ b/app/controllers/api/v1/budget_categories_controller.rb @@ -39,10 +39,15 @@ class Api::V1::BudgetCategoriesController < Api::V1::BaseController def budget_categories_scope BudgetCategory .joins(:budget, :category) - .where(budgets: { family_id: current_resource_owner.family_id }) + .where(budgets: { family_id: current_resource_owner.family_id, user_id: visible_owner_ids }) .includes({ budget: { budget_categories: { category: :parent } } }, category: :parent) end + def visible_owner_ids + shared_with_me = BudgetShare.where(viewer_id: current_resource_owner.id).pluck(:owner_id) + [ nil, current_resource_owner.id, *shared_with_me ] + end + def apply_filters(query) if params[:budget_id].present? raise InvalidFilterError, "budget_id must be a valid UUID" unless valid_uuid?(params[:budget_id]) diff --git a/app/controllers/api/v1/budgets_controller.rb b/app/controllers/api/v1/budgets_controller.rb index d9203faaa..4375497c5 100644 --- a/app/controllers/api/v1/budgets_controller.rb +++ b/app/controllers/api/v1/budgets_controller.rb @@ -42,6 +42,16 @@ class Api::V1::BudgetsController < Api::V1::BaseController end def budgets_scope - current_resource_owner.family.budgets.includes(budget_categories: :category) + current_resource_owner.family.budgets + .where(user_id: visible_owner_ids) + .includes(budget_categories: :category) + end + + # nil (the shared household budget) + the caller's own personal budget + + # any owner who granted the caller a BudgetShare (read-only or + # read-write — this API is read-only either way). + def visible_owner_ids + shared_with_me = BudgetShare.where(viewer_id: current_resource_owner.id).pluck(:owner_id) + [ nil, current_resource_owner.id, *shared_with_me ] end end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index 89061834a..bd3ef1da4 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -1,5 +1,8 @@ class BudgetCategoriesController < ApplicationController + include BudgetOwnership + before_action :set_budget + before_action :ensure_budget_editable!, only: %i[index update] def index @budget_categories = @budget.budget_categories.includes(:category) @@ -21,7 +24,7 @@ class BudgetCategoriesController < ApplicationController @budget_category = @budget.uncategorized_budget_category @recent_transactions = @recent_transactions.where(transactions: { category_id: nil }) else - @budget_category = Current.family.budget_categories.find(params[:id]) + @budget_category = @budget.budget_categories.find(params[:id]) @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id") .where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id) end @@ -30,12 +33,12 @@ class BudgetCategoriesController < ApplicationController end def update - @budget_category = Current.family.budget_categories.find(params[:id]) + @budget_category = @budget.budget_categories.find(params[:id]) @budget_category.update_budgeted_spending!(budgeted_spending_param) respond_to do |format| format.turbo_stream - format.html { redirect_to budget_budget_categories_path(@budget) } + format.html { redirect_to budget_budget_categories_path(@budget, **budget_owner_query) } end rescue ActiveRecord::RecordInvalid render :index, status: :unprocessable_entity @@ -51,6 +54,7 @@ class BudgetCategoriesController < ApplicationController 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 = resolve_budget(start_date) + raise ActiveRecord::RecordNotFound unless @budget end end diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb index 5a3166005..93d197a34 100644 --- a/app/controllers/budgets_controller.rb +++ b/app/controllers/budgets_controller.rb @@ -1,5 +1,8 @@ class BudgetsController < ApplicationController + include BudgetOwnership + before_action :set_budget, only: %i[show edit update copy_previous] + before_action :ensure_budget_editable!, only: %i[edit update copy_previous] def index redirect_to_current_month_budget @@ -7,6 +10,8 @@ class BudgetsController < ApplicationController def show @source_budget = @budget.most_recent_initialized_budget unless @budget.initialized? + @editable = @budget.editable_by?(Current.user) + @switch_options = budget_switch_options(@budget) @breadcrumbs = plan_breadcrumb_prefix + [ [ t("breadcrumbs.budgets"), nil ] ] end @@ -16,12 +21,12 @@ class BudgetsController < ApplicationController def update @budget.update!(budget_params) - redirect_to budget_budget_categories_path(@budget) + redirect_to budget_budget_categories_path(@budget, **budget_owner_query) end def copy_previous if @budget.initialized? - redirect_to budget_path(@budget), alert: t("budgets.copy_previous.already_initialized") + redirect_to budget_path(@budget, **budget_owner_query), alert: t("budgets.copy_previous.already_initialized") return end @@ -29,9 +34,9 @@ class BudgetsController < ApplicationController if source_budget @budget.copy_from!(source_budget) - redirect_to budget_budget_categories_path(@budget), notice: t("budgets.copy_previous.success", source_name: source_budget.name) + redirect_to budget_budget_categories_path(@budget, **budget_owner_query), notice: t("budgets.copy_previous.success", source_name: source_budget.name) else - redirect_to budget_path(@budget), alert: t("budgets.copy_previous.no_source") + redirect_to budget_path(@budget, **budget_owner_query), alert: t("budgets.copy_previous.no_source") end end @@ -54,12 +59,12 @@ class BudgetsController < ApplicationController def set_budget start_date = Budget.param_to_date(params[:month_year], family: Current.family) - @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user) + @budget = resolve_budget(start_date) raise ActiveRecord::RecordNotFound unless @budget end def redirect_to_current_month_budget - current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current, user: Current.user) - redirect_to budget_path(current_budget) + current_budget = resolve_budget(Date.current) + redirect_to budget_path(current_budget, **budget_owner_query) end end diff --git a/app/controllers/concerns/budget_ownership.rb b/app/controllers/concerns/budget_ownership.rb new file mode 100644 index 000000000..5e700bf9b --- /dev/null +++ b/app/controllers/concerns/budget_ownership.rb @@ -0,0 +1,107 @@ +# Resolves "whose budget is this request about" from the `owner` query +# param, shared by BudgetsController, BudgetCategoriesController, and +# PlansController now that a household budget and personal budgets can +# coexist. `owner` is either "household", a user id, or absent (defaults to +# the signed-in user's own budget). +module BudgetOwnership + extend ActiveSupport::Concern + + # budget_owner_query lives in BudgetsHelper (not defined here) so it's + # usable both from controller redirects (via this include) and from any + # view context, including isolated partial/view tests. + included do + include BudgetsHelper + end + + private + def viewing_household_budget? + params[:owner] == "household" + end + + # Falls back to Current.user whenever the requested owner can't be + # resolved or isn't shared with the signed-in user, rather than raising + # — worst case the request just shows the viewer their own budget. + def budget_target_user + return Current.user if params[:owner].blank? || viewing_household_budget? + + candidate = Current.family.users.find_by(id: params[:owner]) + return Current.user if candidate.nil? || candidate.id == Current.user.id + return candidate if BudgetShare.exists?(owner_id: candidate.id, viewer_id: Current.user.id) + + Current.user + end + + def resolve_budget(start_date) + target_user = budget_target_user + + if target_user != Current.user + # Viewing another family member's shared budget: resolve an existing + # budget for that period without creating one on their behalf — a + # mere page view shouldn't vivify a row in someone else's budget + # history. Falls back to the viewer's own budget (auto-created) when + # the owner hasn't set one up yet, same as the household fallback + # below, so shared-budget navigation doesn't dead-end. + budget_start, budget_end = Budget.period_for(start_date, family: Current.family) + existing = Current.family.budgets.find_by(start_date: budget_start, end_date: budget_end, user: target_user) + return existing if existing + + return Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user, household: false) + end + + budget = Budget.find_or_bootstrap( + Current.family, + start_date: start_date, + user: target_user, + household: viewing_household_budget? + ) + + return budget if budget || !viewing_household_budget? + + # Household budget was explicitly requested but the family disabled it + # (stale link, bookmarked URL) — fall back to the viewer's own budget + # rather than a hard 404. + Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user, household: false) + end + + def ensure_budget_editable! + raise ActiveRecord::RecordNotFound unless @budget.editable_by?(Current.user) + end + + # Pills for the household/personal budget switcher. Empty (no switcher) + # unless personal_budgets is on — families that never turned it on keep + # the single, switcher-less budget page they've always had. Household + # gets a people icon; every person (the viewer included) gets a + # colored initial avatar, so all three read as "who" at a glance. + def budget_switch_options(budget) + return [] unless Current.family.personal_budgets? + + options = [] + + if Current.family.household_budget_enabled? + options << { + label: t("budgets.switcher.household"), + icon: "users", + owner_param: "household", + active: budget.user_id.nil? + } + end + + options << { + label: Current.user.display_name, + icon: nil, + owner_param: Current.user.id, + active: budget.user_id == Current.user.id + } + + Current.user.budget_owners_shared_with_me.find_each do |owner| + options << { + label: owner.display_name, + icon: nil, + owner_param: owner.id, + active: budget.user_id == owner.id + } + end + + options + end +end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 6b6602431..d84d6a66d 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -1,4 +1,6 @@ class PlansController < ApplicationController + include BudgetOwnership + # The Plan hub fronts budgets + goals under one nav entry, and only # replaces the Budgets entry for preview users (see # ApplicationHelper#plan_nav_item). Without the flag, fall through to the @@ -6,7 +8,9 @@ class PlansController < ApplicationController before_action :redirect_to_budgets_unless_preview def show - @budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current, user: Current.user) + @budget = resolve_budget(Date.current) + @editable = @budget.editable_by?(Current.user) + @switch_options = budget_switch_options(@budget) @top_budget_categories = @budget.initialized? ? @budget.top_spending_categories : [] @goals = Goal.active_prepared_for(Current.family) diff --git a/app/controllers/settings/budget_shares_controller.rb b/app/controllers/settings/budget_shares_controller.rb new file mode 100644 index 000000000..3adbf44b3 --- /dev/null +++ b/app/controllers/settings/budget_shares_controller.rb @@ -0,0 +1,40 @@ +class Settings::BudgetSharesController < ApplicationController + layout "settings" + + def update + eligible_members = Current.family.users.where.not(id: Current.user.id).where(active: true) + + BudgetShare.transaction do + sharing_members_params.each do |member_params| + viewer = eligible_members.find_by(id: member_params[:viewer_id]) + next unless viewer + + share = Current.user.budget_shares_given.find_by(viewer: viewer) + permission = member_params[:permission].presence + + if permission.in?(BudgetShare::PERMISSIONS) + if share + share.update!(permission: permission) + else + Current.user.budget_shares_given.create!(viewer: viewer, permission: permission) + end + elsif share + share.destroy! + end + end + end + + redirect_to settings_preferences_path, notice: t(".success") + end + + private + def sharing_members_params + return [] unless params.dig(:budget_shares, :members) + + members = params.require(:budget_shares).permit( + members: [ :viewer_id, :permission ] + )[:members] + + members.is_a?(Array) ? members : members&.values || [] + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 5798c573e..48e9cdc71 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -3,6 +3,8 @@ class Settings::PreferencesController < ApplicationController def show @user = Current.user + @family_members = Current.family.users.where.not(id: @user.id).where(active: true) + @budget_shares = @user.budget_shares_given.index_by(&:viewer_id) end # Writes per-user boolean preferences stored in the JSONB `users.preferences` diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ab88dcc15..6e8c516c6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -114,6 +114,7 @@ class UsersController < ApplicationController 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, :household_budget_enabled) # Needed for updating existing family family_attrs.push(:moniker, :default_account_sharing) family_attrs << { enabled_currencies: [] } end @@ -137,8 +138,10 @@ class UsersController < ApplicationController 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) + household_budget_enabled_changed = family_attrs.key?(:household_budget_enabled) - moniker_changed || sharing_changed || enabled_currencies_changed + moniker_changed || sharing_changed || enabled_currencies_changed || personal_budgets_changed || household_budget_enabled_changed end def ensure_admin diff --git a/app/helpers/budgets_helper.rb b/app/helpers/budgets_helper.rb index 46e695947..4d588e23b 100644 --- a/app/helpers/budgets_helper.rb +++ b/app/helpers/budgets_helper.rb @@ -1,4 +1,15 @@ module BudgetsHelper + # Forwards the current `owner` param (if any) so links generated while + # viewing the household budget or a shared member's budget keep pointing + # at that same budget instead of silently resetting to "mine". Defined + # here (rather than only in BudgetOwnership) so it's available to any view + # context, including isolated view tests that render budget partials + # without a full BudgetsController/BudgetCategoriesController/ + # PlansController request cycle. + def budget_owner_query + params[:owner].present? ? { owner: params[:owner] } : {} + end + def budget_has_over_budget?(budget) return false unless budget.initialized? diff --git a/app/models/assistant/function/get_budget.rb b/app/models/assistant/function/get_budget.rb index 3d2a3abf0..23902e763 100644 --- a/app/models/assistant/function/get_budget.rb +++ b/app/models/assistant/function/get_budget.rb @@ -88,7 +88,11 @@ class Assistant::Function::GetBudget < Assistant::Function Budget.find_or_bootstrap(family, start_date: start_date, user: user) else budget_start, budget_end = Budget.period_for(start_date, family: family) - family.budgets.find_by(start_date: budget_start, end_date: budget_end) + family.budgets.find_by( + start_date: budget_start, + end_date: budget_end, + user: family.personal_budgets? ? user : nil + ) end return nil unless budget diff --git a/app/models/budget.rb b/app/models/budget.rb index 4c6ced03c..8622a427d 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, @@ -44,16 +45,29 @@ class Budget < ApplicationRecord end end - def find_or_bootstrap(family, start_date:, user: nil) + # `household: true` explicitly requests the shared household budget + # (user_id NULL) regardless of `user:` — this is what lets a household + # budget and personal budgets coexist once `family.personal_budgets?` is + # on. Without it, `user:` resolves to that user's personal budget when + # personal_budgets is on, or the shared budget otherwise (unchanged + # behavior for families that never turned personal budgets on). + # + # Returns nil if the household budget was explicitly requested but the + # family opted out of it via `household_budget_enabled?`. + def find_or_bootstrap(family, start_date:, user: nil, household: false) return nil unless budget_date_valid?(start_date, family: family) + return nil if household && family.personal_budgets? && !family.household_budget_enabled? Budget.transaction do budget_start, budget_end = period_for(start_date, family: family) + owner = (household || !family.personal_budgets?) ? nil : user + budget = Budget.find_or_create_by!( family: family, start_date: budget_start, - end_date: budget_end + end_date: budget_end, + user: owner ) do |b| b.currency = family.currency end @@ -117,11 +131,20 @@ class Budget < ApplicationRecord end end + # Personal budgets only ever reflect the owner's own accounts, regardless + # of who's viewing (a shared read-only/read-write viewer sees the owner's + # numbers, not their own accessible accounts). The household budget keeps + # the pre-personal-budgets behavior: whatever the requesting viewer can + # see, since it has no single owner to scope by. def transactions scope = family.transactions.visible.in_period(period) - if current_user + + if user_id.present? + scope = scope.joins(:entry).where(entries: { account_id: family.accounts.where(owner_id: user_id).included_in_reports.select(:id) }) + elsif current_user scope = scope.joins(:entry).where(entries: { account_id: family.accounts.accessible_by(current_user).included_in_reports.select(:id) }) end + scope end @@ -141,17 +164,37 @@ class Budget < ApplicationRecord budgeted_spending.present? end + # The household budget (user_id nil) is visible/editable by every family + # member, matching pre-personal_budgets behavior. A personal budget is + # only visible/editable by its owner, or by someone the owner shared it + # with via BudgetShare. + def viewable_by?(user) + return true if user_id.nil? + return true if user_id == user.id + + BudgetShare.exists?(owner_id: user_id, viewer_id: user.id) + end + + def editable_by?(user) + return true if user_id.nil? + return true if user_id == user.id + + BudgetShare.exists?(owner_id: user_id, viewer_id: user.id, permission: "read_write") + end + def most_recent_initialized_budget family.budgets .includes(:budget_categories) .where("start_date < ?", start_date) .where.not(budgeted_spending: nil) + .where(user_id: user_id) .order(start_date: :desc) .first end def copy_from!(source_budget) raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id + raise ArgumentError, "source budget must belong to the same user" unless source_budget.user_id == user_id raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date Budget.transaction do @@ -316,11 +359,11 @@ class Budget < ApplicationRecord # Income: How much user earned relative to what they expected to earn # ============================================================================= def estimated_income - family.income_statement.median_income(interval: "month") + income_statement.median_income(interval: "month") end def actual_income - family.income_statement.income_totals(period: self.period).total + income_statement.income_totals(period: self.period).total end def actual_income_percent @@ -341,7 +384,16 @@ class Budget < ApplicationRecord private def income_statement - @income_statement ||= family.income_statement(user: current_user) + @income_statement ||= family.income_statement(user: current_user, accounts: income_statement_accounts) + end + + # nil for the household budget (IncomeStatement falls back to whatever + # `current_user` can see, unchanged pre-personal-budgets behavior). For a + # personal budget, restrict to the owner's own accounts so a shared + # viewer sees the owner's numbers, and household vs. personal actually + # differ instead of both reflecting the viewer's full accessible set. + def income_statement_accounts + family.accounts.where(owner_id: user_id).included_in_reports if user_id.present? end def net_totals diff --git a/app/models/budget_share.rb b/app/models/budget_share.rb new file mode 100644 index 000000000..55da512b3 --- /dev/null +++ b/app/models/budget_share.rb @@ -0,0 +1,30 @@ +class BudgetShare < ApplicationRecord + belongs_to :owner, class_name: "User" + belongs_to :viewer, class_name: "User" + + PERMISSIONS = %w[read_write read_only].freeze + + validates :permission, inclusion: { in: PERMISSIONS } + validates :viewer_id, uniqueness: { scope: :owner_id } + validate :cannot_share_with_self + validate :owner_and_viewer_in_same_family + + def read_write? + permission == "read_write" + end + + def read_only? + permission == "read_only" + end + + private + def cannot_share_with_self + errors.add(:viewer, "can't be the owner") if owner_id.present? && owner_id == viewer_id + end + + def owner_and_viewer_in_same_family + if owner && viewer && owner.family_id != viewer.family_id + errors.add(:viewer, "must be in the same family") + end + end +end diff --git a/app/models/family.rb b/app/models/family.rb index c1a6111c8..b0f7f2ec2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -144,6 +144,8 @@ class Family < ApplicationRecord 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 ] } + validates :household_budget_enabled, inclusion: { in: [ true, false ] } validate :timezone_must_be_a_known_zone, if: :timezone_changed? before_validation :normalize_enabled_currencies! @@ -311,8 +313,8 @@ class Family < ApplicationRecord BalanceSheet.new(self, user: user) end - def income_statement(user: Current.user) - IncomeStatement.new(self, user: user) + def income_statement(user: Current.user, accounts: nil) + IncomeStatement.new(self, user: user, accounts: accounts) end # Returns the Investment Contributions category for this family, creating it if it doesn't exist. diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index e81bbf8b0..03efb043a 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -7,9 +7,14 @@ class IncomeStatement attr_reader :family, :user - def initialize(family, user: nil) + # `accounts:` overrides the account scope entirely (e.g. a personal + # budget's "owned accounts only" view) instead of inferring it from + # `user.finance_accounts`. `user` is still kept for cache-key/estimate + # purposes when both are given. + def initialize(family, user: nil, accounts: nil) @family = family @user = user || Current.user + @accounts = accounts end def totals(transactions_scope: nil, date_range:) @@ -239,7 +244,11 @@ class IncomeStatement end def included_account_ids - @included_account_ids ||= user ? user.finance_accounts.pluck(:id) : nil + @included_account_ids ||= if @accounts + @accounts.pluck(:id) + elsif user + user.finance_accounts.pluck(:id) + end end def included_account_ids_hash diff --git a/app/models/user.rb b/app/models/user.rb index d18531ee3..cd541331b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,8 @@ class User < ApplicationRecord has_many :owned_accounts, class_name: "Account", foreign_key: :owner_id has_many :account_shares, dependent: :destroy has_many :shared_accounts, through: :account_shares, source: :account + has_many :budget_shares_given, class_name: "BudgetShare", foreign_key: :owner_id, inverse_of: :owner, dependent: :destroy + has_many :budget_shares_received, class_name: "BudgetShare", foreign_key: :viewer_id, inverse_of: :viewer, dependent: :destroy accepts_nested_attributes_for :family, update_only: true MFA_BACKUP_CODE_COUNT = 8 @@ -156,6 +158,12 @@ class User < ApplicationRecord family.accounts.included_in_finances_for(self) end + # Other family members who have granted this user access to their personal + # budget (see BudgetShare). Used to build the budget owner switcher. + def budget_owners_shared_with_me + User.where(id: budget_shares_received.select(:owner_id)) + end + def display_name [ first_name, last_name ].compact.join(" ").presence || email end diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 699653ce9..830ce817b 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -3,7 +3,7 @@ <% category_display_name = budget_category.category.display_name %> <%= turbo_frame_tag dom_id(budget_category), class: "flex-1 min-w-0 block" do %> - <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %> + <%= link_to budget_budget_category_path(budget_category.budget, budget_category, **budget_owner_query), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %> <% if budget_category.initialized? %> <%# Category Header with Status Badge %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index 0d81061b0..bb82311ff 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -12,7 +12,7 @@
- <%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form preserve-focus" } do |f| %> + <%= form_with model: [budget_category.budget, budget_category], url: budget_budget_category_path(budget_category.budget, budget_category, **budget_owner_query), data: { controller: "auto-submit-form preserve-focus" } do |f| %>
<%= currency.symbol %> diff --git a/app/views/budget_categories/_confirm_button.html.erb b/app/views/budget_categories/_confirm_button.html.erb index 730ebf1e3..2cdccdfa0 100644 --- a/app/views/budget_categories/_confirm_button.html.erb +++ b/app/views/budget_categories/_confirm_button.html.erb @@ -3,7 +3,7 @@ text: t(".confirm"), variant: "primary", full_width: true, - href: budget_path(budget), + href: budget_path(budget, **budget_owner_query), method: :get, disabled: !budget.allocations_valid? ) %> diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb index 049d5ede8..3730b74d7 100644 --- a/app/views/budget_categories/index.html.erb +++ b/app/views/budget_categories/index.html.erb @@ -2,8 +2,8 @@ <%= render "budgets/budget_nav", budget: @budget %> <% end %> -<%= content_for :previous_path, edit_budget_path(@budget) %> -<%= content_for :cancel_path, budget_path(@budget) %> +<%= content_for :previous_path, edit_budget_path(@budget, **budget_owner_query) %> +<%= content_for :cancel_path, budget_path(@budget, **budget_owner_query) %>
diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 967a8b3fd..610ec68c8 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -1,3 +1,5 @@ +<%# locals: (budget:, editable:) %> + <%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %>
@@ -12,25 +14,31 @@ <%= format_money(budget.actual_spending_money) %>
- <%= render DS::Link.new( - text: t(".of_budget", amount: budget.budgeted_spending_money.format), - variant: "secondary", - icon: "pencil", - icon_position: "right", - size: "sm", - href: edit_budget_path(budget) - ) %> + <% if editable %> + <%= render DS::Link.new( + text: t(".of_budget", amount: budget.budgeted_spending_money.format), + variant: "secondary", + icon: "pencil", + icon_position: "right", + size: "sm", + href: edit_budget_path(budget, **budget_owner_query) + ) %> + <% else %> +

<%= t(".of_budget", amount: budget.budgeted_spending_money.format) %>

+ <% end %> <% else %>
<%= format_money Money.new(0, budget.currency || budget.family.currency) %>
- <%= render DS::Link.new( - text: t(".new_budget"), - size: "sm", - icon: "plus", - href: edit_budget_path(budget) - ) %> + <% if editable %> + <%= render DS::Link.new( + text: t(".new_budget"), + size: "sm", + icon: "plus", + href: edit_budget_path(budget, **budget_owner_query) + ) %> + <% end %> <% end %>
@@ -46,14 +54,18 @@ <%= format_money(bc.actual_spending_money) %>

- <%= render DS::Link.new( - text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)), - variant: "secondary", - icon: "pencil", - icon_position: "right", - size: "sm", - href: budget_budget_categories_path(budget) - ) %> + <% if editable %> + <%= render DS::Link.new( + text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)), + variant: "secondary", + icon: "pencil", + icon_position: "right", + size: "sm", + href: budget_budget_categories_path(budget, **budget_owner_query) + ) %> + <% else %> +

<%= t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)) %>

+ <% end %>
<% end %> diff --git a/app/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb index a64792732..79c122215 100644 --- a/app/views/budgets/_budget_header.html.erb +++ b/app/views/budgets/_budget_header.html.erb @@ -6,7 +6,7 @@ <%= render DS::Link.new( variant: "icon", icon: "chevron-left", - href: budget_path(budget.previous_budget_param), + href: budget_path(budget.previous_budget_param, **budget_owner_query), ) %> <% else %> @@ -18,7 +18,7 @@ <%= render DS::Link.new( variant: "icon", icon: "chevron-right", - href: budget_path(budget.next_budget_param), + href: budget_path(budget.next_budget_param, **budget_owner_query), ) %> <% else %> @@ -29,7 +29,9 @@ <%= render DS::Popover.new(variant: "button") do |popover| %> <% popover.with_button class: "flex items-center gap-1 hover:bg-surface-hover cursor-pointer rounded-md p-2" do %> - <%= @budget.name %> + + <%= @budget.name %> + <%= icon("chevron-down") %> <% end %> @@ -42,7 +44,13 @@ <%= render DS::Link.new( text: t(".today"), variant: "outline", - href: budget_path(Budget.date_to_param(Date.current)), + href: budget_path(Budget.date_to_param(Date.current), **budget_owner_query), ) %>
+ +<% if @switch_options.present? && @switch_options.size > 1 %> +
+ <%= render "budgets/owner_switcher", switch_options: @switch_options, switch_path: ->(owner_param) { budget_path(budget.to_param, owner: owner_param) } %> +
+<% end %> diff --git a/app/views/budgets/_budget_nav.html.erb b/app/views/budgets/_budget_nav.html.erb index 45c8f9e8d..bedeb7805 100644 --- a/app/views/budgets/_budget_nav.html.erb +++ b/app/views/budgets/_budget_nav.html.erb @@ -1,14 +1,14 @@ <%# locals: (budget:) %> <% steps = [ - { name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 }, - { name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 }, + { name: "Setup", path: edit_budget_path(budget, **budget_owner_query), is_complete: budget.initialized?, step_number: 1 }, + { name: "Categories", path: budget_budget_categories_path(budget, **budget_owner_query), is_complete: budget.allocations_valid?, step_number: 2 }, ] %>
    <% steps.each_with_index do |step, idx| %>
  • - <% is_current = request.path == step[:path] %> + <% is_current = request.path == step[:path].split("?").first %> <% text_class = if is_current "text-primary" diff --git a/app/views/budgets/_copy_previous_prompt.html.erb b/app/views/budgets/_copy_previous_prompt.html.erb index 8edd87c1d..a61cc9bbc 100644 --- a/app/views/budgets/_copy_previous_prompt.html.erb +++ b/app/views/budgets/_copy_previous_prompt.html.erb @@ -11,7 +11,7 @@
    <%= render DS::Button.new( text: t("budgets.copy_previous_prompt.copy_button", source_name: source_budget.name), - href: copy_previous_budget_path(budget), + href: copy_previous_budget_path(budget, **budget_owner_query), method: :post, icon: "copy" ) %> @@ -20,7 +20,7 @@ text: t("budgets.copy_previous_prompt.fresh_button"), variant: "secondary", icon: "plus", - href: edit_budget_path(budget) + href: edit_budget_path(budget, **budget_owner_query) ) %>
    diff --git a/app/views/budgets/_over_allocation_warning.html.erb b/app/views/budgets/_over_allocation_warning.html.erb index 16ebf72e6..696c0ee4f 100644 --- a/app/views/budgets/_over_allocation_warning.html.erb +++ b/app/views/budgets/_over_allocation_warning.html.erb @@ -1,15 +1,17 @@ -<%# locals: (budget:) %> +<%# locals: (budget:, editable:) %>
    <%= icon "alert-triangle", size: "lg", color: "destructive" %>

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

    - <%= render DS::Link.new( - text: t(".fix_allocations"), - variant: "secondary", - size: "sm", - icon: "pencil", - icon_position: "right", - href: budget_budget_categories_path(budget) - ) %> + <% if editable %> + <%= render DS::Link.new( + text: t(".fix_allocations"), + variant: "secondary", + size: "sm", + icon: "pencil", + icon_position: "right", + href: budget_budget_categories_path(budget, **budget_owner_query) + ) %> + <% end %>
    diff --git a/app/views/budgets/_owner_switcher.html.erb b/app/views/budgets/_owner_switcher.html.erb new file mode 100644 index 000000000..7251cccee --- /dev/null +++ b/app/views/budgets/_owner_switcher.html.erb @@ -0,0 +1,22 @@ +<%# locals: (switch_options:, switch_path:) %> + +<%# The shared .segmented-control track (bg-gray-50) is too close to both + the page background (bg-surface, also gray-50) and a white card + (bg-container) to read as its own control. Override it with + bg-surface-inset, the same track color DS::Tabs uses for Budgeted/Actual + on this same page — a utility class beats the component's own + `@apply bg-gray-50` in the cascade, so this doesn't touch the shared + CSS or affect other .segmented-control consumers (category filter). %> +<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("budgets.switcher.aria_label"), class: "bg-surface-inset") do |sc| %> + <% switch_options.each do |option| %> + <% segment_content = capture do %> + <% if option[:icon] %> + <%= icon(option[:icon], size: "xs", color: "current") %> + <% else %> + <%= render Goals::AvatarComponent.new(name: option[:label], size: "sm") %> + <% end %> + <%= option[:label] %> + <% end %> + <% sc.with_segment segment_content, active: option[:active], href: switch_path.call(option[:owner_param]) %> + <% end %> +<% end %> diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb index b59a1c650..cf6f961a9 100644 --- a/app/views/budgets/_picker.html.erb +++ b/app/views/budgets/_picker.html.erb @@ -6,7 +6,7 @@ <% last_month_of_previous_year = Date.new(year - 1, 12, 1) %> <% if Budget.budget_date_valid?(last_month_of_previous_year, family: family) %> - <%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-surface-hover rounded-md" do %> + <%= link_to picker_budgets_path(year: year - 1, **budget_owner_query), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-surface-hover rounded-md" do %> <%= icon "chevron-left" %> <% end %> <% else %> @@ -22,7 +22,7 @@ <% first_month_of_next_year = Date.new(year + 1, 1, 1) %> <% if Budget.budget_date_valid?(first_month_of_next_year, family: family) %> - <%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-surface-hover rounded-md" do %> + <%= link_to picker_budgets_path(year: year + 1, **budget_owner_query), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-surface-hover rounded-md" do %> <%= icon "chevron-right" %> <% end %> <% else %> @@ -41,7 +41,7 @@ <%= render DS::Link.new( variant: "ghost", text: month_name, - href: budget_path(param_key), + href: budget_path(param_key, **budget_owner_query), full_width: true, frame: :_top ) %> diff --git a/app/views/budgets/edit.html.erb b/app/views/budgets/edit.html.erb index abe757af6..77fa88561 100644 --- a/app/views/budgets/edit.html.erb +++ b/app/views/budgets/edit.html.erb @@ -2,8 +2,8 @@ <%= render "budgets/budget_nav", budget: @budget %> <% end %> -<%= content_for :previous_path, budget_path(@budget) %> -<%= content_for :cancel_path, budget_path(@budget) %> +<%= content_for :previous_path, budget_path(@budget, **budget_owner_query) %> +<%= content_for :cancel_path, budget_path(@budget, **budget_owner_query) %>
    @@ -15,7 +15,7 @@
    - <%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %> + <%= styled_form_with model: @budget, url: budget_path(@budget, **budget_owner_query), class: "space-y-3", data: { controller: "budget-form" } do |f| %> <%= f.money_field :budgeted_spending, label: t(".budgeted_spending"), required: true, disable_currency: true %> <%= f.money_field :expected_income, label: t(".expected_income"), required: true, disable_currency: true %> diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 0df039ba5..c88fce4da 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -10,12 +10,12 @@
    <%# Budget Donut %>
    - <% if !@budget.initialized? && @source_budget.present? %> + <% if !@budget.initialized? && @source_budget.present? && @editable %> <%= render "budgets/copy_previous_prompt", budget: @budget, source_budget: @source_budget %> <% elsif @budget.initialized? && @budget.available_to_allocate.negative? %> - <%= render "budgets/over_allocation_warning", budget: @budget %> + <%= render "budgets/over_allocation_warning", budget: @budget, editable: @editable %> <% else %> - <%= render "budgets/budget_donut", budget: @budget %> + <%= render "budgets/budget_donut", budget: @budget, editable: @editable %> <% end %>
    @@ -63,12 +63,12 @@ <% end %>
    "> - <% if @budget.initialized? %> + <% if @budget.initialized? && @editable %> <%= render DS::Link.new( text: t("budgets.show.categories.edit"), variant: "secondary", icon: "settings-2", - href: budget_budget_categories_path(@budget) + href: budget_budget_categories_path(@budget, **budget_owner_query) ) %> <% end %>
    diff --git a/app/views/plans/_budget_card.html.erb b/app/views/plans/_budget_card.html.erb index 216ebc23c..a28ce2375 100644 --- a/app/views/plans/_budget_card.html.erb +++ b/app/views/plans/_budget_card.html.erb @@ -1,4 +1,4 @@ -<%# locals: (budget:, top_budget_categories:) %> +<%# locals: (budget:, top_budget_categories:, editable:, switch_options:) %> <%= render DS::Card.new do %>
    @@ -6,7 +6,9 @@ <%= icon("layout-grid", size: "sm") %>

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

    - · <%= budget.name %> + + · <%= budget.name %> + <% if budget.initialized? %> <% if budget.available_to_spend.negative? %> @@ -17,24 +19,32 @@ <%= render DS::Pill.new(label: t("reports.budget_performance.status.good"), tone: :success, marker: false, icon: "check-circle") %> <% end %> -
    - <%# The budget donut's trailing pencil (_budget_donut.html.erb) labels - the VALUE itself ("$12,850 ✎" — click the number to edit it). - This button's label is a static action ("Edit budget"), which - takes a leading icon everywhere else in the app: the categories - "Edit" on this same page (budgets/show.html.erb) and "Edit - split" (transactions/show.html.erb) both do. %> - <%= render DS::Link.new( - text: t(".edit_budget"), - variant: "secondary", - size: "sm", - icon: "pencil", - href: edit_budget_path(budget) - ) %> -
    + <% if editable %> +
    + <%# The budget donut's trailing pencil (_budget_donut.html.erb) labels + the VALUE itself ("$12,850 ✎" — click the number to edit it). + This button's label is a static action ("Edit budget"), which + takes a leading icon everywhere else in the app: the categories + "Edit" on this same page (budgets/show.html.erb) and "Edit + split" (transactions/show.html.erb) both do. %> + <%= render DS::Link.new( + text: t(".edit_budget"), + variant: "secondary", + size: "sm", + icon: "pencil", + href: edit_budget_path(budget, **budget_owner_query) + ) %> +
    + <% end %> <% end %>
    + <% if switch_options.size > 1 %> +
    + <%= render "budgets/owner_switcher", switch_options: switch_options, switch_path: ->(owner_param) { plan_path(owner: owner_param) } %> +
    + <% end %> + <% if budget.initialized? %>
    @@ -90,18 +100,20 @@ <%= icon("map", size: "md") %>

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

    - <%= render DS::Link.new( - text: t(".set_up"), - variant: "primary", - href: edit_budget_path(budget) - ) %> + <% if editable %> + <%= render DS::Link.new( + text: t(".set_up"), + variant: "primary", + href: edit_budget_path(budget, **budget_owner_query) + ) %> + <% end %>
    <% end %> <%= render DS::Link.new( text: t(".view_budget"), variant: "secondary", - href: budget_path(budget), + href: budget_path(budget, **budget_owner_query), icon: "chevron-right", icon_position: :right, full_width: true, diff --git a/app/views/plans/show.html.erb b/app/views/plans/show.html.erb index b9f0ba544..7960f5556 100644 --- a/app/views/plans/show.html.erb +++ b/app/views/plans/show.html.erb @@ -11,7 +11,7 @@
    - <%= render "plans/budget_card", budget: @budget, top_budget_categories: @top_budget_categories %> + <%= render "plans/budget_card", budget: @budget, top_budget_categories: @top_budget_categories, editable: @editable, switch_options: @switch_options %> <%= render "plans/goals_card", goals: @goals, summary: @goals_summary, linkable_account_count: @linkable_account_count, family_has_goals: @family_has_goals %>
    diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 0f6fbab3e..8500703ee 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -24,6 +24,16 @@ country_options, { label: t(".country") }, { data: { auto_submit_form_target: "auto" } } %> +

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

    + <% end %> + <% end %> +
    +<% end %> +<%= settings_section title: t(".budget_title"), subtitle: t(".budget_subtitle") do %> +
    + <%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> + <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= form.fields_for :family do |family_form| %> <%= family_form.select :month_start_day, (1..28).map { |day| [localized_ordinal(day), day] }, { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, @@ -31,11 +41,68 @@ <% if @user.family.uses_custom_month_start? %> <%= render DS::Alert.new(message: t(".month_start_day_warning"), variant: :warning) %> <% end %> -

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

    + <% if Current.user.admin? && preview_features_enabled? %> +
    +
    +

    + <%= t(".personal_budgets") %> + <%= render DS::Pill.new(label: t("shared.preview"), size: :sm) %> +

    +

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

    +
    + <%= family_form.toggle :personal_budgets, + data: { auto_submit_form_target: "auto" } %> +
    + + <% if @user.family.personal_budgets? %> +
    +
    +

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

    +

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

    +
    + <%= family_form.toggle :household_budget_enabled, + data: { auto_submit_form_target: "auto" } %> +
    + <% end %> + <% end %> <% end %> <% end %>
    <% end %> +<% if @user.family.personal_budgets? && preview_features_enabled? %> + <%= settings_section title: t(".budget_sharing_title"), subtitle: t(".budget_sharing_subtitle") do %> + <% if @family_members.any? %> + <%= styled_form_with url: settings_budget_shares_path, method: :patch, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> +
    + <% @family_members.each_with_index do |member, index| %> + <% share = @budget_shares[member.id] %> + <% select_id = "budget_share_permission_#{member.id}" %> + +
    +
    +
    + <%= member.initials %> +
    + <%= member.display_name %> +
    + + +
    + <% end %> +
    + <% end %> + <% else %> +

    <%= t(".budget_sharing_no_members", moniker: family_moniker_downcase) %>

    + <% end %> + <% end %> +<% end %> <% if Current.user.admin? %> <%= settings_section title: t(".currencies_title", moniker: family_moniker), subtitle: t(".currencies_subtitle", moniker: family_moniker_downcase) do %> <% selected_count_translations = t(".selected_currencies_count") %> diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 072fc4773..519ede9dc 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -1,6 +1,9 @@ --- en: budgets: + switcher: + aria_label: "Switch budget" + household: "Household" budget_donut: spent: "Spent" new_budget: "New budget" diff --git a/config/locales/views/budgets/fr.yml b/config/locales/views/budgets/fr.yml index 42cab3ed4..bdc73ee06 100644 --- a/config/locales/views/budgets/fr.yml +++ b/config/locales/views/budgets/fr.yml @@ -34,6 +34,9 @@ fr: status: Statut view_all_transactions: Voir toutes les transactions de catégorie budgets: + switcher: + aria_label: "Changer de budget" + household: "Commun" actuals_summary: expenses: Dépenses income: Revenu diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index b9f13a1f9..66bc4bed2 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -175,10 +175,24 @@ en: language_auto: Browser language page_title: Preferences timezone: Timezone + translations_notice: Please note, we are still working on translations for various languages. + budget_title: Budget + budget_subtitle: Configure how your budgets behave 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. - translations_notice: Please note, we are still working on translations for various languages. + personal_budgets: Personal budgets + personal_budgets_hint: Enable individual budgets for each family member. + household_budget_enabled: Household budget + household_budget_enabled_hint: Keep a shared household budget alongside personal budgets. Turn off to use personal budgets only. + budget_sharing_title: Budget sharing + budget_sharing_subtitle: Let other members view or edit your personal budget + budget_sharing_no_members: No other members in your %{moniker} to share with + budget_sharing_permission_label: "%{name}'s access" + budget_sharing_permissions: + none: No access + read_only: View only + read_write: Can edit currencies_title: "%{moniker} Currencies" currencies_subtitle: Choose which currencies appear in money fields for your %{moniker} base_currency_label: Base currency @@ -204,6 +218,9 @@ en: preview: title: Enable preview features description: Opt in to in-progress features tagged preview or canary. + budget_shares: + update: + success: Budget sharing settings updated profiles: destroy: cannot_remove_self: You cannot remove yourself from the account. diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index e475b7c33..74893a62b 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -139,6 +139,16 @@ fr: additional_currencies_label: Devises supplémentaires base_currency_badge: Devise de base base_currency_label: Devise de base + budget_sharing_title: Partage de budget + budget_sharing_subtitle: Autorisez d'autres membres à consulter ou modifier votre budget personnel + budget_sharing_no_members: Aucun autre membre de votre %{moniker} avec qui partager + budget_sharing_permission_label: "Accès de %{name}" + budget_sharing_permissions: + none: Aucun accès + read_only: Lecture seule + read_write: Peut modifier + budget_subtitle: Configurez le fonctionnement de vos budgets + budget_title: Budget country: Pays currencies_more: "+%{count} de plus" currencies_subtitle: Choisissez les devises qui apparaissent dans les champs monétaires de votre %{moniker} @@ -160,6 +170,10 @@ fr: no_additional_currencies: Aucune sélectionnée no_matching_currencies: Aucune devise trouvée page_title: Préférences + personal_budgets: Budgets personnels + personal_budgets_hint: Activer les budgets individuels pour chaque membre de la famille. + household_budget_enabled: Budget commun + household_budget_enabled_hint: Garder un budget commun au foyer en plus des budgets personnels. Désactivez pour n'utiliser que les budgets personnels. preview: description: Activez les fonctionnalités en cours étiquetées Aperçu ou Canary. title: Activer les fonctionnalités d'aperçu @@ -176,6 +190,9 @@ fr: sharing_title: Partage de %{moniker} timezone: Fuseau horaire translations_notice: Veuillez noter que nous travaillons toujours sur des traductions dans différentes langues. + budget_shares: + update: + success: Paramètres de partage de budget mis à jour profiles: destroy: cannot_remove_self: Vous ne pouvez pas vous enlever de votre compte. diff --git a/config/routes.rb b/config/routes.rb index 8f3e79116..628351161 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -318,6 +318,7 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [ :show, :destroy ] resource :preferences, only: %i[show update] + resource :budget_shares, only: :update resource :appearance, only: %i[show update] resource :debug, only: :show resource :background_jobs, controller: "background_jobs", only: :show do diff --git a/db/migrate/20260818060353_add_personal_budgets.rb b/db/migrate/20260818060353_add_personal_budgets.rb new file mode 100644 index 000000000..44eb911b6 --- /dev/null +++ b/db/migrate/20260818060353_add_personal_budgets.rb @@ -0,0 +1,58 @@ +class AddPersonalBudgets < ActiveRecord::Migration[7.2] + def up + add_column :families, :personal_budgets, :boolean, default: false, null: false + add_column :families, :household_budget_enabled, :boolean, default: true, null: false + + add_reference :budgets, + :user, + type: :uuid, + foreign_key: { on_delete: :cascade }, + null: true + + remove_index :budgets, name: "index_budgets_on_family_id_and_start_date_and_end_date" + + add_index :budgets, + [ :family_id, :start_date, :end_date ], + unique: true, + where: "user_id IS NULL", + name: "index_budgets_shared_unique" + + add_index :budgets, + [ :family_id, :start_date, :end_date, :user_id ], + unique: true, + where: "user_id IS NOT NULL", + name: "index_budgets_personal_unique" + + remove_foreign_key :budget_categories, :budgets + add_foreign_key :budget_categories, :budgets, on_delete: :cascade + + create_table :budget_shares, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.references :owner, type: :uuid, null: false, foreign_key: { to_table: :users } + t.references :viewer, type: :uuid, null: false, foreign_key: { to_table: :users } + t.string :permission, null: false, default: "read_only" + t.timestamps + end + + add_index :budget_shares, [ :owner_id, :viewer_id ], unique: true + end + + def down + drop_table :budget_shares + + remove_foreign_key :budget_categories, :budgets + add_foreign_key :budget_categories, :budgets + + remove_index :budgets, name: "index_budgets_personal_unique" + remove_index :budgets, name: "index_budgets_shared_unique" + + add_index :budgets, + [ :family_id, :start_date, :end_date ], + unique: true, + name: "index_budgets_on_family_id_and_start_date_and_end_date" + + remove_reference :budgets, :user, type: :uuid, foreign_key: true + + remove_column :families, :household_budget_enabled + remove_column :families, :personal_budgets + end +end diff --git a/db/schema.rb b/db/schema.rb index ce3421805..59960979e 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_08_12_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_18_060353) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -372,6 +372,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_000000) do t.index ["category_id"], name: "index_budget_categories_on_category_id" end + create_table "budget_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "owner_id", null: false + t.uuid "viewer_id", null: false + t.string "permission", default: "read_only", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_id", "viewer_id"], name: "index_budget_shares_on_owner_id_and_viewer_id", unique: true + t.index ["owner_id"], name: "index_budget_shares_on_owner_id" + t.index ["viewer_id"], name: "index_budget_shares_on_viewer_id" + end + create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.date "start_date", null: false @@ -381,8 +392,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_000000) do 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_personal_unique", unique: true, where: "(user_id IS NOT NULL)" + t.index ["family_id", "start_date", "end_date"], name: "index_budgets_shared_unique", unique: true, where: "(user_id IS NULL)" 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| @@ -775,6 +789,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_000000) 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 "personal_budgets", default: false, null: false + t.boolean "household_budget_enabled", default: true, null: false t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, '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 @@ -2342,9 +2358,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_000000) do add_foreign_key "binance_items", "families" add_foreign_key "brex_accounts", "brex_items" add_foreign_key "brex_items", "families" - add_foreign_key "budget_categories", "budgets" + add_foreign_key "budget_categories", "budgets", on_delete: :cascade add_foreign_key "budget_categories", "categories" + add_foreign_key "budget_shares", "users", column: "owner_id" + add_foreign_key "budget_shares", "users", column: "viewer_id" add_foreign_key "budgets", "families" + add_foreign_key "budgets", "users", on_delete: :cascade add_foreign_key "categories", "families" add_foreign_key "chats", "users" add_foreign_key "coinbase_accounts", "coinbase_items" diff --git a/test/controllers/api/v1/budget_categories_controller_test.rb b/test/controllers/api/v1/budget_categories_controller_test.rb index 0cd854b6a..c698af53e 100644 --- a/test/controllers/api/v1/budget_categories_controller_test.rb +++ b/test/controllers/api/v1/budget_categories_controller_test.rb @@ -129,6 +129,31 @@ class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal "validation_failed", response_data["error"] end + test "excludes another family member's personal budget category" do + @family.update!(personal_budgets: true) + + other_member_budget = @family.budgets.create!( + user: users(:family_member), + start_date: 7.months.ago.beginning_of_month.to_date, + end_date: 7.months.ago.end_of_month.to_date, + budgeted_spending: 800, + currency: "USD" + ) + other_member_budget_category = other_member_budget.budget_categories.create!( + category: @category, + budgeted_spending: 200, + currency: "USD" + ) + + get api_v1_budget_categories_url, headers: api_headers(@api_key) + assert_response :success + response_data = JSON.parse(response.body) + assert_not_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, other_member_budget_category.id + + get api_v1_budget_category_url(other_member_budget_category), headers: api_headers(@api_key) + assert_response :not_found + end + test "requires authentication" do get api_v1_budget_categories_url diff --git a/test/controllers/api/v1/budgets_controller_test.rb b/test/controllers/api/v1/budgets_controller_test.rb index b4c7252ee..de646d44c 100644 --- a/test/controllers/api/v1/budgets_controller_test.rb +++ b/test/controllers/api/v1/budgets_controller_test.rb @@ -116,6 +116,47 @@ class Api::V1::BudgetsControllerTest < ActionDispatch::IntegrationTest assert_equal "validation_failed", response_data["error"] end + test "excludes another family member's personal budget" do + @family.update!(personal_budgets: true) + + other_member_budget = @family.budgets.create!( + user: users(:family_member), + start_date: 5.months.ago.beginning_of_month.to_date, + end_date: 5.months.ago.end_of_month.to_date, + budgeted_spending: 800, + currency: "USD" + ) + + get api_v1_budgets_url, headers: api_headers(@api_key) + assert_response :success + response_data = JSON.parse(response.body) + assert_not_includes response_data["budgets"].map { |budget| budget["id"] }, other_member_budget.id + + get api_v1_budget_url(other_member_budget.id), headers: api_headers(@api_key) + assert_response :not_found + end + + test "includes another family member's personal budget once they share it" do + @family.update!(personal_budgets: true) + + other_member_budget = @family.budgets.create!( + user: users(:family_member), + start_date: 5.months.ago.beginning_of_month.to_date, + end_date: 5.months.ago.end_of_month.to_date, + budgeted_spending: 800, + currency: "USD" + ) + BudgetShare.create!(owner: users(:family_member), viewer: @user, permission: "read_only") + + get api_v1_budgets_url, headers: api_headers(@api_key) + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["budgets"].map { |budget| budget["id"] }, other_member_budget.id + + get api_v1_budget_url(other_member_budget.id), headers: api_headers(@api_key) + assert_response :success + end + test "requires authentication" do get api_v1_budgets_url diff --git a/test/controllers/budget_categories_controller_test.rb b/test/controllers/budget_categories_controller_test.rb index 5bea22d96..5ed729ddd 100644 --- a/test/controllers/budget_categories_controller_test.rb +++ b/test/controllers/budget_categories_controller_test.rb @@ -139,6 +139,24 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest "matched funds_movement inflow must not appear in Uncategorized drilldown" end + test "show and update do not leak another member's personal budget category" do + @family.update!(personal_budgets: true) + + other_member_budget = Budget.find_or_bootstrap(@family, start_date: @budget.start_date, user: users(:family_member)) + other_budget_category = other_member_budget.budget_categories.find_by!(category: @parent_category) + other_budget_category.update!(budgeted_spending: 999) + + get budget_budget_category_path(@budget, other_budget_category) + assert_response :not_found + + patch budget_budget_category_path(@budget, other_budget_category), + params: { budget_category: { budgeted_spending: 1 } }, + as: :turbo_stream + assert_response :not_found + + assert_equal 999.0, other_budget_category.reload.budgeted_spending.to_f + end + test "show drilldown still lists loan_payment transfers (intentionally budget-tracked)" do # loan_payment is NOT in BUDGET_EXCLUDED_KINDS. The drilldown should # keep showing loan_payment transfers so the user can see what's @@ -163,3 +181,37 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest "loan_payment outflow remains visible (kind is not BUDGET_EXCLUDED)" end end + +class BudgetCategoriesControllerSharingTest < ActionDispatch::IntegrationTest + setup do + @family = families(:empty) + @family.update!(personal_budgets: true) + @owner = users(:josh) + @viewer = users(:ann) + @date = Date.current.beginning_of_month + @family.categories.create!(name: "Groceries", color: "#6172F3") + @owner_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @owner) + end + + test "a read_only viewer cannot reach the categories wizard for the owner's budget" do + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only") + sign_in @viewer + + get budget_budget_categories_path(@owner_budget, owner: @owner.id) + + assert_response :not_found + end + + test "a read_write viewer can update a category on the owner's budget" do + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_write") + budget_category = @owner_budget.budget_categories.first + sign_in @viewer + + patch budget_budget_category_path(@owner_budget, budget_category, owner: @owner.id), + params: { budget_category: { budgeted_spending: 250 } }, + as: :turbo_stream + + assert_response :success + assert_equal 250.0, budget_category.reload.budgeted_spending.to_f + end +end diff --git a/test/controllers/budgets_controller_test.rb b/test/controllers/budgets_controller_test.rb index 210b4a332..0e5e43c0c 100644 --- a/test/controllers/budgets_controller_test.rb +++ b/test/controllers/budgets_controller_test.rb @@ -36,3 +36,74 @@ class BudgetsControllerTest < ActionDispatch::IntegrationTest assert_select "a[href=?]", budgets_path, minimum: 1 end end + +class BudgetsControllerSharingTest < ActionDispatch::IntegrationTest + setup do + @family = families(:empty) + @family.update!(personal_budgets: true) + @owner = users(:josh) + @viewer = users(:ann) + @date = Date.current.beginning_of_month + end + + test "household budget is viewable and editable by any family member" do + Budget.find_or_bootstrap(@family, start_date: @date, user: @owner, household: true) + sign_in @viewer + + get budget_url(Budget.date_to_param(@date), params: { owner: "household" }) + assert_response :success + + patch budget_url(Budget.date_to_param(@date), params: { owner: "household" }), + params: { budget: { budgeted_spending: 1000, expected_income: 2000 } } + assert_response :redirect + end + + test "household tab is unreachable once household_budget_enabled is off, falling back to the viewer's own budget" do + @family.update!(household_budget_enabled: false) + sign_in @viewer + + get budget_url(Budget.date_to_param(@date), params: { owner: "household" }) + + assert_response :success + assert_equal @viewer.id, Budget.find_by(family: @family, start_date: @date).user_id + end + + test "a member without a BudgetShare cannot view another member's personal budget" do + Budget.find_or_bootstrap(@family, start_date: @date, user: @owner) + sign_in @viewer + + get budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }) + + # Falls back to the viewer's own budget rather than the owner's. + assert_response :success + assert Budget.exists?(family: @family, start_date: @date, user_id: @viewer.id) + end + + test "a read_only BudgetShare lets the viewer see but not edit the owner's budget" do + Budget.find_or_bootstrap(@family, start_date: @date, user: @owner) + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only") + sign_in @viewer + + get budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }) + assert_response :success + + get edit_budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }) + assert_response :not_found + + patch budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }), + params: { budget: { budgeted_spending: 1000, expected_income: 2000 } } + assert_response :not_found + end + + test "a read_write BudgetShare lets the viewer edit the owner's budget" do + Budget.find_or_bootstrap(@family, start_date: @date, user: @owner) + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_write") + sign_in @viewer + + patch budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }), + params: { budget: { budgeted_spending: 1000, expected_income: 2000 } } + + assert_redirected_to budget_budget_categories_url(Budget.date_to_param(@date), owner: @owner.id) + assert_equal 1000, Budget.find_by(family: @family, user: @owner).budgeted_spending.to_i + end +end diff --git a/test/controllers/plans_controller_test.rb b/test/controllers/plans_controller_test.rb index 287739952..64b567f60 100644 --- a/test/controllers/plans_controller_test.rb +++ b/test/controllers/plans_controller_test.rb @@ -65,3 +65,34 @@ class PlansControllerTest < ActionDispatch::IntegrationTest assert_select "a[href=?]", edit_budget_path(Budget.date_to_param(Date.current)) end end + +class PlansControllerHouseholdSwitchingTest < ActionDispatch::IntegrationTest + setup do + @family = families(:empty) + @family.update!(personal_budgets: true) + @owner = users(:josh) + @owner.update!(preferences: (@owner.preferences || {}).merge("preview_features_enabled" => true)) + sign_in @owner + ensure_tailwind_build + end + + test "renders a household/mine switcher once personal_budgets is on, and switches to household" do + get plan_url + + assert_response :success + assert_select "a[href=?]", plan_path(owner: "household") + assert_select "a[href=?]", plan_path(owner: @owner.id) + + get plan_url, params: { owner: "household" } + assert_response :success + end + + test "hides the household pill when household_budget_enabled is off" do + @family.update!(household_budget_enabled: false) + + get plan_url + + assert_response :success + assert_select "a[href=?]", plan_path(owner: "household"), count: 0 + end +end diff --git a/test/controllers/settings/budget_shares_controller_test.rb b/test/controllers/settings/budget_shares_controller_test.rb new file mode 100644 index 000000000..b4eb65a3e --- /dev/null +++ b/test/controllers/settings/budget_shares_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Settings::BudgetSharesControllerTest < ActionDispatch::IntegrationTest + setup do + @family = families(:empty) + @owner = users(:josh) + @viewer = users(:ann) + sign_in @owner + end + + test "grants a new share with the selected permission" do + patch settings_budget_shares_url, params: { + budget_shares: { members: { "0" => { viewer_id: @viewer.id, permission: "read_only" } } } + } + + assert_redirected_to settings_preferences_path + share = @owner.budget_shares_given.find_by(viewer: @viewer) + assert_equal "read_only", share.permission + end + + test "updates an existing share's permission" do + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only") + + patch settings_budget_shares_url, params: { + budget_shares: { members: { "0" => { viewer_id: @viewer.id, permission: "read_write" } } } + } + + assert_equal "read_write", @owner.budget_shares_given.find_by(viewer: @viewer).permission + end + + test "revokes a share when permission is blank" do + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only") + + patch settings_budget_shares_url, params: { + budget_shares: { members: { "0" => { viewer_id: @viewer.id, permission: "" } } } + } + + assert_nil @owner.budget_shares_given.find_by(viewer: @viewer) + end + + test "ignores a viewer_id outside the current user's family" do + outsider = users(:family_admin) + + patch settings_budget_shares_url, params: { + budget_shares: { members: { "0" => { viewer_id: outsider.id, permission: "read_only" } } } + } + + assert_nil BudgetShare.find_by(owner: @owner, viewer: outsider) + end +end diff --git a/test/controllers/settings/preferences_controller_test.rb b/test/controllers/settings/preferences_controller_test.rb index 5d49f9ee0..b083aa198 100644 --- a/test/controllers/settings/preferences_controller_test.rb +++ b/test/controllers/settings/preferences_controller_test.rb @@ -50,4 +50,33 @@ class Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to settings_preferences_url assert_not user.reload.preview_features_enabled? end + + test "household budget toggle and sharing card only render once personal_budgets is on" do + user = users(:family_admin) + user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => true)) + + get settings_preferences_url + assert_response :success + assert_not_includes response.body, I18n.t("settings.preferences.show.household_budget_enabled") + assert_not_includes response.body, I18n.t("settings.preferences.show.budget_sharing_title") + + user.family.update!(personal_budgets: true) + + get settings_preferences_url + assert_response :success + assert_includes response.body, I18n.t("settings.preferences.show.household_budget_enabled") + assert_includes response.body, I18n.t("settings.preferences.show.budget_sharing_title") + end + + test "hides the sharing card when personal_budgets is on but preview features are off" do + user = users(:family_admin) + user.family.update!(personal_budgets: true) + assert_not user.preview_features_enabled? + + get settings_preferences_url + + assert_response :success + assert_not_includes response.body, I18n.t("settings.preferences.show.household_budget_enabled") + assert_not_includes response.body, I18n.t("settings.preferences.show.budget_sharing_title") + end end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index bb62df285..2e2aa6d82 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -104,3 +104,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 + diff --git a/test/models/assistant/function/get_budget_test.rb b/test/models/assistant/function/get_budget_test.rb index 84fb10adf..baba3142c 100644 --- a/test/models/assistant/function/get_budget_test.rb +++ b/test/models/assistant/function/get_budget_test.rb @@ -102,6 +102,20 @@ class Assistant::Function::GetBudgetTest < ActiveSupport::TestCase assert_equal target, month[:period][:start_date] end + test "does not leak another family member's personal budget in trend months" do + @family.update!(personal_budgets: true) + prior_start = Date.current.beginning_of_month << 1 + + other_budget = Budget.find_or_bootstrap(@family, start_date: prior_start, user: users(:family_member)) + other_bc = other_budget.budget_categories.find { |bc| bc.category == categories(:food_and_drink) } + other_bc.update!(budgeted_spending: 12345) + + result = @function.call("prior_months" => 1) + + assert_equal 1, result[:months].length, "another member's personal budget must not surface as a trend month" + assert_equal 1, result[:months_unavailable] + end + test "raises on invalid month format" do assert_raises(Assistant::Error) do @function.call("month" => "not-a-month") diff --git a/test/models/budget_share_test.rb b/test/models/budget_share_test.rb new file mode 100644 index 000000000..f448f268b --- /dev/null +++ b/test/models/budget_share_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class BudgetShareTest < ActiveSupport::TestCase + setup do + @owner = users(:josh) + @viewer = users(:ann) + end + + test "valid with a family member and a permitted permission" do + share = BudgetShare.new(owner: @owner, viewer: @viewer, permission: "read_only") + + assert share.valid? + end + + test "invalid with a permission outside PERMISSIONS" do + share = BudgetShare.new(owner: @owner, viewer: @viewer, permission: "full_control") + + assert_not share.valid? + end + + test "invalid sharing with yourself" do + share = BudgetShare.new(owner: @owner, viewer: @owner, permission: "read_only") + + assert_not share.valid? + end + + test "invalid across families" do + outsider = users(:family_admin) + share = BudgetShare.new(owner: @owner, viewer: outsider, permission: "read_only") + + assert_not share.valid? + end + + test "invalid with a duplicate owner/viewer pair" do + BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only") + duplicate = BudgetShare.new(owner: @owner, viewer: @viewer, permission: "read_write") + + assert_not duplicate.valid? + end + + test "read_write? and read_only? reflect the permission" do + share = BudgetShare.new(owner: @owner, viewer: @viewer, permission: "read_write") + + assert share.read_write? + assert_not share.read_only? + end +end diff --git a/test/models/personal_budget_test.rb b/test/models/personal_budget_test.rb new file mode 100644 index 000000000..cd4043208 --- /dev/null +++ b/test/models/personal_budget_test.rb @@ -0,0 +1,183 @@ +require "test_helper" + +class PersonalBudgetTest < ActiveSupport::TestCase + include EntriesTestHelper + + 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 + + test "most_recent_initialized_budget does not bleed across users" do + @family.update!(personal_budgets: true) + + past_date = 1.month.ago.beginning_of_month + + # user1 has an initialized budget last month + user1_past = Budget.find_or_bootstrap(@family, start_date: past_date, user: @user1) + user1_past.update!(budgeted_spending: 3000, expected_income: 5000) + + # user2 has no budget last month — creates a fresh one for this month + user2_current = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) + + # user2's source should be nil, not user1's past budget + assert_nil user2_current.most_recent_initialized_budget + end + + test "copy_previous does not copy another users budget" do + @family.update!(personal_budgets: true) + + past_date = 1.month.ago.beginning_of_month + + user1_past = Budget.find_or_bootstrap(@family, start_date: past_date, user: @user1) + user1_past.update!(budgeted_spending: 9999, expected_income: 9999) + + user2_current = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) + + # copy_from! should not find user1's budget as source + source = user2_current.most_recent_initialized_budget + assert_nil source + assert_nil user2_current.budgeted_spending + end + + test "copy_from! rejects a source budget owned by another user" do + @family.update!(personal_budgets: true) + + past_date = 1.month.ago.beginning_of_month + + user1_past = Budget.find_or_bootstrap(@family, start_date: past_date, user: @user1) + user1_past.update!(budgeted_spending: 9999, expected_income: 9999) + + user2_current = Budget.find_or_bootstrap(@family, start_date: @date, user: @user2) + + assert_raises(ArgumentError) { user2_current.copy_from!(user1_past) } + end + + test "deleting a user cascades through their personal budget and budget categories" do + @family.update!(personal_budgets: true) + category = @family.categories.create!(name: "Groceries", color: "#6172F3") + + budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + budget.update!(budgeted_spending: 500, expected_income: 1000) + budget_category = budget.budget_categories.find_by!(category_id: category.id) + + assert_nothing_raised { @user1.destroy! } + + assert_nil Budget.find_by(id: budget.id) + assert_nil BudgetCategory.find_by(id: budget_category.id) + end + + test "household and personal budgets coexist when household: true is explicitly requested" do + @family.update!(personal_budgets: true) + + household_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1, household: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + + assert_not_equal household_budget.id, personal_budget.id + assert_nil household_budget.user_id + assert_equal @user1.id, personal_budget.user_id + end + + test "household: true returns nil when the family disabled household_budget_enabled" do + @family.update!(personal_budgets: true, household_budget_enabled: false) + + assert_nil Budget.find_or_bootstrap(@family, start_date: @date, user: @user1, household: true) + end + + test "household: true ignores household_budget_enabled when personal_budgets is off" do + @family.update!(personal_budgets: false, household_budget_enabled: false) + + budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1, household: true) + + assert budget.present? + assert_nil budget.user_id + end + + test "viewable_by? and editable_by? allow every family member on the household budget" do + household_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1, household: true) + + assert household_budget.viewable_by?(@user1) + assert household_budget.viewable_by?(@user2) + assert household_budget.editable_by?(@user1) + assert household_budget.editable_by?(@user2) + end + + test "viewable_by? and editable_by? restrict a personal budget to its owner by default" do + @family.update!(personal_budgets: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + + assert personal_budget.viewable_by?(@user1) + assert personal_budget.editable_by?(@user1) + assert_not personal_budget.viewable_by?(@user2) + assert_not personal_budget.editable_by?(@user2) + end + + test "a read_only BudgetShare grants viewing but not editing" do + @family.update!(personal_budgets: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + BudgetShare.create!(owner: @user1, viewer: @user2, permission: "read_only") + + assert personal_budget.viewable_by?(@user2) + assert_not personal_budget.editable_by?(@user2) + end + + test "a read_write BudgetShare grants both viewing and editing" do + @family.update!(personal_budgets: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + BudgetShare.create!(owner: @user1, viewer: @user2, permission: "read_write") + + assert personal_budget.viewable_by?(@user2) + assert personal_budget.editable_by?(@user2) + end + + test "household actual spending reflects the viewer's accessible accounts, personal reflects only the owner's own" do + @family.update!(personal_budgets: true) + + owned_account = Account.create!(family: @family, accountable: Depository.new, name: "Josh checking", status: "active", currency: "USD", balance: 0, owner: @user1) + joint_account = Account.create!(family: @family, accountable: Depository.new, name: "Joint savings", status: "active", currency: "USD", balance: 0, owner: @user2) + AccountShare.create!(account: joint_account, user: @user1, permission: "read_write", include_in_finances: true) + + create_transaction(account: owned_account, amount: 50, date: @date) + create_transaction(account: joint_account, amount: 200, date: @date) + + household_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1, household: true) + personal_budget = Budget.find_or_bootstrap(@family, start_date: @date, user: @user1) + + assert_equal 250.0, household_budget.actual_spending.to_f + assert_equal 50.0, personal_budget.actual_spending.to_f + end +end diff --git a/test/views/budgets/budget_donut_view_test.rb b/test/views/budgets/budget_donut_view_test.rb index 0fe5550c5..d85d96157 100644 --- a/test/views/budgets/budget_donut_view_test.rb +++ b/test/views/budgets/budget_donut_view_test.rb @@ -46,7 +46,7 @@ class BudgetDonutViewTest < ActionView::TestCase budget = Budget.find(budget.id) uncategorized = budget.uncategorized_budget_category - html = render(partial: "budgets/budget_donut", locals: { budget: budget }) + html = render(partial: "budgets/budget_donut", locals: { budget: budget, editable: true }) assert_includes html, "segment_#{uncategorized.id}" assert_includes html, uncategorized.category.display_name