mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user