Budget Management#
Overview#
A Budget in Sure is a period-scoped spending plan scoped to a Family. Each budget covers exactly one period (typically a calendar month), holds a top-level budgeted_spending and expected_income, and owns a set of BudgetCategory records that allocate that spending across the family's categories.
Key files:
app/models/budget.rb— core model, period logic, allocation mathapp/models/budget_category.rb— per-category allocation, inheritance, shared-pool logicapp/controllers/budgets_controller.rb— HTTP entry pointsapp/controllers/budget_categories_controller.rb— per-category update entry point
Budget Periods#
Budgets support two period modes, driven by the family's month_start_day setting:
| Mode | Condition | Period boundaries |
|---|---|---|
| Standard | month_start_day == 1 | beginning_of_month → end_of_month |
| Custom | month_start_day != 1 (1–28) | family.custom_month_start_for(date) → family.custom_month_end_for(date) |
Budget.period_for returns the correct [start_date, end_date] pair for any given date based on this setting. The budget's URL slug uses the "MMM-YYYY" format (e.g., jan-2025), with param_to_date adjusting the base date to the family's custom day when needed.
Valid budget dates are bounded: no earlier than the oldest of 2 years ago or the family's oldest entry date, and no later than 2 years into the future .
Bootstrap and Creation#
Budgets are never created directly via a form — instead Budget.find_or_bootstrap is called by both the BudgetsController#set_budget and the index action's redirect . It:
- Validates the date falls within the allowed range
find_or_create_by!the budget record (usingfamily,start_date,end_dateas the unique key)- Calls
sync_budget_categoriesto ensure every current family category has a correspondingBudgetCategoryrow (withbudgeted_spending: 0), and removes stale entries for deleted categories. When syncing categories, therollover_enabledflag is inherited from the previous budget's matching category — rollover is a standing choice about an envelope, not about one month, so a user who switches it on for Vacations expects it to keep going across future budgets. A user who opened a later month before making this choice left that month with nothing to inherit;BudgetCategory#propagate_rollover_choice_forward!closes that hole by applying the choice to all later budgets when the toggle is changed. - Calls
Budget::RolloverCalculatorto materialize rollover amounts for the budget chain
The budgets table has a unique index on (family_id, start_date, end_date) , preventing duplicate budgets for the same period.
Copying from a Previous Budget#
For uninitialized budgets, copy_from! copies both the top-level budgeted_spending/expected_income and all per-category allocations from the most recent initialized (non-nil budgeted_spending) budget. The controller's copy_previous action drives this workflow, guarding against overwriting an already-initialized budget.
When copying budget categories, copy_from! also copies the rollover_enabled flag (in addition to amount and notes). The toggle is a preference and travels with the copy; the rolled_over_amount is derived state that only Budget::RolloverCalculator may write.
Category Allocation and Hierarchy#
Each BudgetCategory joins a Budget to a Category. The budget_categories table enforces uniqueness on (budget_id, category_id) .
Parent vs. Subcategory Budget Categories#
BudgetCategory follows the same two-level parent/child hierarchy as categories. subcategory? checks category.parent_id.present?. BudgetCategory::Group.for builds the grouped display structure by pairing each top-level category with its children.
Allocated spending at the budget level counts only parent-level budget categories to avoid double-counting.
Subcategory Inheritance#
A subcategory inherits the parent's budget when its own budgeted_spending is nil or 0 — checked by inherits_parent_budget?. Inheriting subcategories:
- Display the parent's budget as a reference via
display_budgeted_spending - Report
available_to_spendas the parent'savailable_to_spend - Are hidden from the "on track" view until they have actual spending
Shared Pool Logic (Parent available_to_spend)#
For a parent budget category, available_to_spend implements a ring-fencing + shared pool model:
- Subcategories with their own individual limits are ring-fenced — their budgets are carved out of the parent total
- The remainder is the shared pool, available to the parent and all inheriting subcategories collectively
- Spending from ring-fenced subcategories is subtracted from the total, and only the leftover counts against the shared pool
Parent Budget Auto-sync#
When a subcategory's budgeted_spending is updated via update_budgeted_spending!, it calls the private sync_parent_budgeted_spending!. This recalculates the parent's budgeted_spending as:
sum(sibling allocations) + new subcategory allocation + parent_reserve
The parent reserve is the positive difference between the parent's original budget and the sum of its subcategory allocations — preserving any intentional slack the user added directly to the parent, but never carrying a negative reserve forward .
Moving Allocations Between Categories#
BudgetCategory.move_allocation!(from:, to:, amount:) shifts money from one envelope to another in a single atomic operation — YNAB's "roll with the punches" rule. When one category overspends, cover it by pulling from another category without leaving the budget over-allocated between two edits. No new table: v1 stores the resulting allocations and keeps no history of the move.
Movable Amount Logic#
What a category can send depends on its place in the hierarchy:
- Leaf categories (subcategories): Can move the entire
budgeted_spendingamount - Parent categories: Can only move the reserve — the parent's own allocation excluding what is already ring-fenced by individually-funded subcategories. Moving ring-fenced money would break the parent-child invariant;
sync_parent_budgeted_spending!rebuilds the parent assum(children) + reserve, so the move would be re-derived away on the next child edit.
Move Restrictions#
The operation raises BudgetCategory::InvalidMove with a localized message for these conditions:
| Refusal | Reason |
|---|---|
non_positive_amount | Amount must be positive |
insufficient_funds | Amount exceeds what the source category can send |
uncategorized | Cannot move from/to the Uncategorized synthetic category (no row) |
different_budgets | Both categories must belong to the same budget |
same_category | Cannot move to the same category |
parent_child | Cannot move between a category and its direct child — sync_parent_budgeted_spending! would re-derive the move away |
Lock Ordering and Rollover Recomputation#
move_allocation! uses deterministic lock ordering (ascending ID) to prevent deadlocks. It locks all affected rows — from, to, and their parents if they exist — up front.
The method does NOT recompute the rollover chain internally. The caller must run Budget::RolloverCalculator AFTER the move commits, never inside it. The calculator takes a transaction-scoped advisory lock; taking it while these row locks are held would invert the lock order that update_budgeted_spending! already established (commit first, then recompute), causing two concurrent moves to deadlock. A model test explicitly asserts that move_allocation! never recomputes on its own, so the call cannot drift back inside.
The move is neutral for Budget#allocated_spending (total allocations conserved), but not for the carry: leftover_for depends on budgeted + rolled_over - actual, so moving money changes what both envelopes hand to the next month.
UI and API Support#
The budget categories index renders one shared <dialog> for the entire page (not one per row). A discreet move button appears only on categories that have something to give. The Stimulus controller (budget_move_controller.js) disables options the server would refuse, so impossible moves are never offered.
- Web endpoint:
POST /budgets/:budget_id/budget_categories/move - Required parameters:
from_id,to_id, andamount(viabudget_category_movenamespace) - Returns: Flash notification and re-renders the budget view via Turbo Stream
The controller's move action also calls Budget::RolloverCalculator.recompute! after the move completes, ensuring the chain reflects the new carry amounts.
Budget Rollover#
A budget category can carry its unspent allocation into the next month, opt-in per category via a toggle next to the amount field. This is implemented through two new columns on BudgetCategory:
| Column | Type | Default | Description |
|---|---|---|---|
rollover_enabled | boolean | false | Opt-in toggle to carry unspent budget into the next month |
rolled_over_amount | Money | 0 | The amount carried forward from the previous month, materialized by Budget::RolloverCalculator |
Budget::RolloverCalculator#
Budget::RolloverCalculator is a service class that materializes rolled_over_amount for each budget category in one forward pass over the budget chain, rather than deriving it on read. The calculator:
- Is called after a budget is saved (via
after_commitcallback in theBudgetmodel) - Works on a single budget chain: either the family's household budgets (
usernil) or one member's personal budgets (chains never mix) - For each category with
rollover_enabled, calculates the unspent amount from the previous month (max(0, budgeted + rolled_over - actual)) and stores it in the current month'srolled_over_amount - Handles edge cases:
- Parent carry is net of its ring-fenced subcategories — a parent's allocation already contains its subcategories' allocations, and its actual spending already contains their spending. Subcategories carry their own surplus, so the parent's carry subtracts them rather than rolling the same money over twice.
- Chains never mix, and gaps are crossed untouched — a personal budget only inherits from the same user's earlier personal budgets. A month that was never initialized is a gap, not a month budgeted at zero.
- The carry stops at a currency change — the guard is on
budget_category.currency, notbudget.currency: a budget created before a currency change and re-synced after carries categories in the new currency under a budget in the old one.
- Uses a transaction-scoped advisory lock keyed on the chain to serialize overlapping recomputes for the same chain
Impact on Computed Properties#
Rollover changes several computed properties on BudgetCategory:
| Property | Where | Updated behavior |
|---|---|---|
budgeted? | BudgetCategory | Returns true if display_budgeted_spending > 0 OR rolled_over_amount > 0 (previously only checked display_budgeted_spending). A category funded entirely by its rollover is considered budgeted. |
percent_of_budget_spent | BudgetCategory | Now calculated as actual_spending / (display_budgeted_spending + rolled_over_amount) instead of just actual_spending / display_budgeted_spending |
available_to_spend | BudgetCategory | Now includes the rolled-over amount: display_budgeted_spending + rolled_over_amount - actual_spending |
The Budget model also gains a new computed property:
| Property | Where | Description |
|---|---|---|
total_rolled_over | Budget | Sums rolled_over_amount across all budget categories for that budget period. Informational aggregate only — deliberately kept out of allocated_spending and available_to_allocate. |
Key Computed Properties#
| Property | Where | Description |
|---|---|---|
budgeted_spending | Budget | Top-level total budget |
allocated_spending | Budget | Sum of all parent BudgetCategory allocations |
available_to_allocate | Budget | budgeted_spending - allocated_spending |
available_to_spend | Budget | budgeted_spending - actual_spending |
actual_spending | Budget | From IncomeStatement net expense totals |
over_budget? | BudgetCategory | available_to_spend.negative? |
Allocations are considered valid (allocations_valid?) only when the budget is initialized, available_to_allocate >= 0, and at least some spending has been allocated.