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 }, ] %>