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:
JulienGourmet
2026-08-18 08:36:46 +02:00
committed by GitHub
co-authored by Claude Sonnet 5 sure-admin
parent 8cfd4c9c7d
commit e5750a6c09
52 changed files with 1221 additions and 115 deletions
+2 -2
View File
@@ -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])
+11 -1
View File
@@ -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
+12 -7
View File
@@ -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
+5 -1
View File
@@ -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`
+4 -1
View File
@@ -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
+11
View File
@@ -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?
+5 -1
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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
+4 -2
View File
@@ -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.
+11 -2
View File
@@ -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
+8
View File
@@ -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 -2
View File
@@ -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">
+34 -22
View File
@@ -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 %>
+12 -4
View File
@@ -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 %>
+3 -3
View File
@@ -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 %>
+3 -3
View File
@@ -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
) %>
+3 -3
View File
@@ -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 %>
+5 -5
View File
@@ -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>
+35 -23
View File
@@ -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,
+1 -1
View File
@@ -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>
+68 -1
View File
@@ -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") %>
+3
View File
@@ -1,6 +1,9 @@
---
en:
budgets:
switcher:
aria_label: "Switch budget"
household: "Household"
budget_donut:
spent: "Spent"
new_budget: "New budget"
+3
View File
@@ -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
+18 -1
View File
@@ -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.
+17
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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
+26
View File
@@ -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")
+47
View File
@@ -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
+183
View File
@@ -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
+1 -1
View File
@@ -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