mirror of
https://github.com/we-promise/sure.git
synced 2026-09-04 06:11:12 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Co-authored-by: sure-admin <sure-admin@splashblot.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
sure-admin
parent
8cfd4c9c7d
commit
e5750a6c09
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+59
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= 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| %>
|
||||
<div class="form-field w-[120px]">
|
||||
<div class="flex items-center privacy-sensitive privacy-sensitive-interactive">
|
||||
<span class="text-secondary text-sm mr-2"><%= currency.symbol %></span>
|
||||
|
||||
@@ -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?
|
||||
) %>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -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 %>
|
||||
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
|
||||
|
||||
@@ -12,25 +14,31 @@
|
||||
<%= format_money(budget.actual_spending_money) %>
|
||||
</div>
|
||||
|
||||
<%= 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 %>
|
||||
<p class="text-secondary text-sm"><%= t(".of_budget", amount: budget.budgeted_spending_money.format) %></p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div data-donut-chart-target="amount" class="text-subdued text-3xl mb-2 privacy-sensitive whitespace-nowrap">
|
||||
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
|
||||
</div>
|
||||
|
||||
<%= 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 %>
|
||||
</div>
|
||||
|
||||
@@ -46,14 +54,18 @@
|
||||
<%= format_money(bc.actual_spending_money) %>
|
||||
</p>
|
||||
|
||||
<%= 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 %>
|
||||
<p class="text-secondary text-sm"><%= t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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 %>
|
||||
<span class="text-subdued">
|
||||
@@ -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 %>
|
||||
<span class="text-subdued">
|
||||
@@ -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 %>
|
||||
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
|
||||
<span class="text-primary font-medium text-lg lg:text-base">
|
||||
<%= @budget.name %>
|
||||
</span>
|
||||
<%= 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),
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @switch_options.present? && @switch_options.size > 1 %>
|
||||
<div class="mb-4">
|
||||
<%= render "budgets/owner_switcher", switch_options: @switch_options, switch_path: ->(owner_param) { budget_path(budget.to_param, owner: owner_param) } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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 },
|
||||
] %>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
<% steps.each_with_index do |step, idx| %>
|
||||
<li class="flex items-center gap-2 group">
|
||||
<% is_current = request.path == step[:path] %>
|
||||
<% is_current = request.path == step[:path].split("?").first %>
|
||||
|
||||
<% text_class = if is_current
|
||||
"text-primary"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="flex flex-col sm:flex-row items-center gap-2">
|
||||
<%= 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)
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<%# locals: (budget:) %>
|
||||
<%# locals: (budget:, editable:) %>
|
||||
|
||||
<div class="flex flex-col gap-4 items-center justify-center h-full">
|
||||
<%= icon "alert-triangle", size: "lg", color: "destructive" %>
|
||||
<p class="text-secondary text-sm text-center"><%= t(".over_allocated_message") %></p>
|
||||
|
||||
<%= 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 %>
|
||||
</div>
|
||||
|
||||
@@ -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 %>
|
||||
<span class="ml-1.5"><%= option[:label] %></span>
|
||||
<% end %>
|
||||
<% sc.with_segment segment_content, active: option[:active], href: switch_path.call(option[:owner_param]) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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
|
||||
) %>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg">
|
||||
<%= 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 %>
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<%# Budget Donut %>
|
||||
<div class="min-h-[300px] bg-container rounded-xl shadow-border-xs p-8">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
<% end %>
|
||||
|
||||
<div class="<%= has_over_budget ? "shrink-0 flex justify-end whitespace-nowrap" : "ml-auto" %>">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (budget:, top_budget_categories:) %>
|
||||
<%# locals: (budget:, top_budget_categories:, editable:, switch_options:) %>
|
||||
|
||||
<%= render DS::Card.new do %>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
@@ -6,7 +6,9 @@
|
||||
<%= icon("layout-grid", size: "sm") %>
|
||||
</span>
|
||||
<h2 class="text-sm font-medium text-primary shrink-0"><%= t(".title") %></h2>
|
||||
<span class="text-xs text-subdued truncate">· <%= budget.name %></span>
|
||||
<span class="text-xs text-subdued truncate">
|
||||
· <%= budget.name %>
|
||||
</span>
|
||||
|
||||
<% 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 %>
|
||||
|
||||
<div class="ml-auto shrink-0">
|
||||
<%# 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)
|
||||
) %>
|
||||
</div>
|
||||
<% if editable %>
|
||||
<div class="ml-auto shrink-0">
|
||||
<%# 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)
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if switch_options.size > 1 %>
|
||||
<div class="mb-4">
|
||||
<%= render "budgets/owner_switcher", switch_options: switch_options, switch_path: ->(owner_param) { plan_path(owner: owner_param) } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if budget.initialized? %>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-3xl font-medium text-primary tabular-nums privacy-sensitive">
|
||||
@@ -90,18 +100,20 @@
|
||||
<%= icon("map", size: "md") %>
|
||||
</span>
|
||||
<p class="text-sm text-secondary max-w-xs"><%= t(".empty_body") %></p>
|
||||
<%= 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 %>
|
||||
</div>
|
||||
<% 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,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 @3xl:grid-cols-2 gap-4 items-stretch">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
country_options,
|
||||
{ label: t(".country") },
|
||||
{ data: { auto_submit_form_target: "auto" } } %>
|
||||
<p class="text-xs italic pl-2 text-secondary"><%= t(".translations_notice") %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".budget_title"), subtitle: t(".budget_subtitle") do %>
|
||||
<div>
|
||||
<%= 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 %>
|
||||
<p class="text-xs italic pl-2 text-secondary"><%= t(".translations_notice") %></p>
|
||||
<% if Current.user.admin? && preview_features_enabled? %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm flex items-center gap-1.5">
|
||||
<%= t(".personal_budgets") %>
|
||||
<%= render DS::Pill.new(label: t("shared.preview"), size: :sm) %>
|
||||
</p>
|
||||
<p class="text-secondary text-sm"><%= t(".personal_budgets_hint") %></p>
|
||||
</div>
|
||||
<%= family_form.toggle :personal_budgets,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
</div>
|
||||
|
||||
<% if @user.family.personal_budgets? %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".household_budget_enabled") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".household_budget_enabled_hint") %></p>
|
||||
</div>
|
||||
<%= family_form.toggle :household_budget_enabled,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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| %>
|
||||
<div class="bg-container-inset rounded-xl p-1">
|
||||
<% @family_members.each_with_index do |member, index| %>
|
||||
<% share = @budget_shares[member.id] %>
|
||||
<% select_id = "budget_share_permission_#{member.id}" %>
|
||||
<input type="hidden" name="budget_shares[members][<%= index %>][viewer_id]" value="<%= member.id %>">
|
||||
<div class="flex items-center justify-between gap-3 bg-container p-4 shadow-border-xs rounded-lg mt-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="w-8 h-8 shrink-0 rounded-full bg-surface-inset flex items-center justify-center text-xs font-medium text-secondary">
|
||||
<%= member.initials %>
|
||||
</div>
|
||||
<span class="text-sm text-primary truncate"><%= member.display_name %></span>
|
||||
</div>
|
||||
<label for="<%= select_id %>" class="sr-only"><%= t(".budget_sharing_permission_label", name: member.display_name) %></label>
|
||||
<select id="<%= select_id %>" name="budget_shares[members][<%= index %>][permission]" class="text-sm border border-primary rounded-lg px-3 py-1.5 bg-container text-primary" data-auto-submit-form-target="auto">
|
||||
<option value="" <%= "selected" if share.nil? %>><%= t(".budget_sharing_permissions.none") %></option>
|
||||
<% BudgetShare::PERMISSIONS.each do |perm| %>
|
||||
<option value="<%= perm %>" <%= "selected" if share&.permission == perm %>>
|
||||
<%= t(".budget_sharing_permissions.#{perm}") %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= t(".budget_sharing_no_members", moniker: family_moniker_downcase) %></p>
|
||||
<% 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") %>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
en:
|
||||
budgets:
|
||||
switcher:
|
||||
aria_label: "Switch budget"
|
||||
household: "Household"
|
||||
budget_donut:
|
||||
spent: "Spent"
|
||||
new_budget: "New budget"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+22
-3
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user