mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* 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>
183 lines
5.9 KiB
Ruby
183 lines
5.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
class Api::V1::BudgetsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@family = @user.family
|
|
@user.api_keys.active.destroy_all
|
|
|
|
@api_key = ApiKey.create!(
|
|
user: @user,
|
|
name: "Test Read Key",
|
|
scopes: [ "read" ],
|
|
source: "web",
|
|
display_key: "test_read_#{SecureRandom.hex(8)}"
|
|
)
|
|
|
|
@budget = @family.budgets.create!(
|
|
start_date: 3.months.ago.beginning_of_month.to_date,
|
|
end_date: 3.months.ago.end_of_month.to_date,
|
|
budgeted_spending: 3000,
|
|
expected_income: 5000,
|
|
currency: "USD"
|
|
)
|
|
|
|
category = categories(:food_and_drink)
|
|
@budget_category = @budget.budget_categories.create!(
|
|
category: category,
|
|
budgeted_spending: 500,
|
|
currency: "USD"
|
|
)
|
|
|
|
other_family = families(:empty)
|
|
@other_budget = other_family.budgets.create!(
|
|
start_date: 4.months.ago.beginning_of_month.to_date,
|
|
end_date: 4.months.ago.end_of_month.to_date,
|
|
budgeted_spending: 1000,
|
|
expected_income: 2000,
|
|
currency: "USD"
|
|
)
|
|
end
|
|
|
|
test "lists budgets scoped to the current family" do
|
|
get api_v1_budgets_url, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data.key?("budgets")
|
|
assert response_data.key?("pagination")
|
|
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
|
|
assert_not_includes response_data["budgets"].map { |budget| budget["id"] }, @other_budget.id
|
|
|
|
budget_response = response_data["budgets"].find { |budget| budget["id"] == @budget.id }
|
|
%w[
|
|
actual_spending
|
|
actual_spending_cents
|
|
actual_income
|
|
actual_income_cents
|
|
available_to_spend
|
|
available_to_spend_cents
|
|
available_to_allocate
|
|
available_to_allocate_cents
|
|
].each do |derived_field|
|
|
assert_not budget_response.key?(derived_field), "Expected budget index to omit #{derived_field}"
|
|
end
|
|
end
|
|
|
|
test "shows a budget" do
|
|
get api_v1_budget_url(@budget.id), headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal @budget.id, response_data["id"]
|
|
assert_equal @budget.start_date.to_s, response_data["start_date"]
|
|
assert_equal "USD", response_data["currency"]
|
|
assert_equal true, response_data["initialized"]
|
|
assert_kind_of Integer, response_data["budgeted_spending_cents"]
|
|
assert_kind_of Integer, response_data["actual_spending_cents"]
|
|
assert_kind_of Integer, response_data["actual_income_cents"]
|
|
assert_kind_of Integer, response_data["available_to_spend_cents"]
|
|
assert_kind_of Integer, response_data["available_to_allocate_cents"]
|
|
end
|
|
|
|
test "returns not found for another family's budget" do
|
|
get api_v1_budget_url(@other_budget.id), headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "returns not found for malformed budget id" do
|
|
get api_v1_budget_url("not-a-uuid"), headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "filters budgets by date range" do
|
|
get api_v1_budgets_url,
|
|
params: { start_date: @budget.start_date.to_s, end_date: @budget.end_date.to_s },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
|
|
end
|
|
|
|
test "rejects invalid date filters" do
|
|
get api_v1_budgets_url, params: { start_date: "03/01/2024" }, headers: api_headers(@api_key)
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
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
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "requires read scope" do
|
|
api_key_without_read = ApiKey.new(
|
|
user: @user,
|
|
name: "No Read Key",
|
|
scopes: [],
|
|
source: "mobile",
|
|
display_key: "no_read_#{SecureRandom.hex(8)}"
|
|
)
|
|
api_key_without_read.save!(validate: false)
|
|
|
|
get api_v1_budgets_url, headers: api_headers(api_key_without_read)
|
|
|
|
assert_response :forbidden
|
|
ensure
|
|
api_key_without_read&.destroy
|
|
end
|
|
end
|