Skip to main content

Overview

Budgets in Home Account allow users to set spending limits for specific categories. Each budget can have a custom period (monthly, weekly, or yearly) and alert thresholds to notify users when they’re approaching their limits.
Budget tracking compares actual spending from transactions against configured limits, helping users maintain financial discipline.

Budget Structure

Each budget is associated with a category and account:
backend/models/budget/index.ts
export type BudgetPeriod = 'monthly' | 'weekly' | 'yearly'

export interface CategoryBudget {
  id: string
  account_id: string
  category_id: string
  amount: number
  period: BudgetPeriod
  alert_threshold: number
  created_at: Date
  updated_at?: Date
}

Budget Fields

  • amount: The budget limit in the account’s currency
  • period: How often the budget resets (monthly, weekly, yearly)
  • alert_threshold: Percentage (0-100) at which to trigger alerts (e.g., 80 means alert at 80% spent)
  • category_id: The category this budget applies to

Creating Budgets

Client-Side: React Query Hook

frontend/lib/queries/budget.ts
export function useCreateBudget() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: createBudget,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['budgets'] })
    },
  })
}

// Usage in a component
const createBudgetMutation = useCreateBudget()

const handleCreateBudget = () => {
  createBudgetMutation.mutate({
    account_id: activeAccountId,
    category_id: selectedCategoryId,
    amount: 500,
    period: 'monthly',
    alert_threshold: 80,
  })
}

Server-Side: Budget Controller

backend/controllers/budget/budget-controller.ts
export const createBudget = asyncHandler(async (req: Request, res: Response) => {
  const result = createBudgetSchema.safeParse(req.body)
  
  if (!result.success) {
    const messages = result.error.issues.map(i => i.message).join(', ')
    throw new AppError(messages, 400)
  }

  const { account_id, category_id, amount, period, alert_threshold } = result.data

  const budget = await BudgetRepository.create({
    account_id,
    category_id,
    amount,
    period,
    alert_threshold,
  })

  res.status(201).json({
    success: true,
    data: budget,
  })
})

API Endpoint

POST /api/budget
Content-Type: application/json
X-CSRF-Token: <token>

{
  "account_id": "uuid",
  "category_id": "uuid",
  "amount": 500,
  "period": "monthly",
  "alert_threshold": 80
}
Default values: period defaults to "monthly" and alert_threshold defaults to 75 if not specified.

Viewing Budgets

Fetching All Budgets for an Account

frontend/lib/queries/budget.ts
export function useBudgets(accountId?: string) {
  return useQuery({
    queryKey: ['budgets', accountId],
    queryFn: () => fetchBudgets(accountId!),
    enabled: !!accountId,
  })
}

// Usage
const { data: budgets, isLoading, error } = useBudgets(activeAccountId)

Server Endpoint

backend/controllers/budget/budget-controller.ts
export const getBudgets = asyncHandler(async (req: Request, res: Response) => {
  const { account_id } = req.query

  if (!account_id || typeof account_id !== 'string') {
    throw new AppError('account_id es requerido', 400)
  }

  const budgets = await BudgetRepository.getByAccountId(account_id)

  res.status(200).json({
    success: true,
    data: budgets,
  })
})

Getting a Single Budget

GET /api/budget/:id?account_id=uuid
backend/controllers/budget/budget-controller.ts
export const getBudget = asyncHandler(async (req: Request, res: Response) => {
  const { id } = req.params
  const { account_id } = req.query

  if (!account_id || typeof account_id !== 'string') {
    throw new AppError('account_id es requerido', 400)
  }

  const budget = await BudgetRepository.getById(id)

  if (!budget || budget.account_id !== account_id) {
    throw new AppError('Presupuesto no encontrado', 404)
  }

  res.status(200).json({
    success: true,
    data: budget,
  })
})

Updating Budgets

Update Hook

frontend/lib/queries/budget.ts
export function useUpdateBudget() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({
      id,
      payload,
    }: {
      id: string
      payload: UpdateBudgetPayload & { account_id: string }
    }) => updateBudget(id, payload),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['budgets'] })
    },
  })
}

Server Controller

backend/controllers/budget/budget-controller.ts
export const updateBudget = asyncHandler(async (req: Request, res: Response) => {
  const { id } = req.params

  const result = updateBudgetSchema.safeParse(req.body)
  
  if (!result.success) {
    const messages = result.error.issues.map(i => i.message).join(', ')
    throw new AppError(messages, 400)
  }

  const { account_id, amount, period, alert_threshold } = result.data

  const existing = await BudgetRepository.getById(id)
  if (!existing || existing.account_id !== account_id) {
    throw new AppError('Presupuesto no encontrado', 404)
  }

  const budget = await BudgetRepository.update(id, {
    amount,
    period,
    alert_threshold,
  })

  res.status(200).json({
    success: true,
    data: budget,
  })
})
  • amount: New budget limit
  • period: Change the budget period
  • alert_threshold: Update the alert percentage
Note: You cannot change the category_id or account_id of an existing budget. Create a new budget instead.

Deleting Budgets

Delete Hook

frontend/lib/queries/budget.ts
export function useDeleteBudget() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, accountId }: { id: string; accountId: string }) =>
      deleteBudget(id, accountId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['budgets'] })
    },
  })
}

Server Controller

backend/controllers/budget/budget-controller.ts
export const deleteBudget = asyncHandler(async (req: Request, res: Response) => {
  const { id } = req.params
  const { account_id } = req.body

  if (!account_id) {
    throw new AppError('account_id es requerido', 400)
  }

  const existing = await BudgetRepository.getById(id)
  if (!existing || existing.account_id !== account_id) {
    throw new AppError('Presupuesto no encontrado', 404)
  }

  await BudgetRepository.delete(id)

  res.status(200).json({
    success: true,
    message: 'Presupuesto eliminado',
  })
})

Budget Periods

Home Account supports three budget periods:
Budget resets on the 1st of each month. This is the most common period for household budgets.
{
  period: 'monthly',
  amount: 500
}

Alert Thresholds

Alert thresholds trigger notifications when spending reaches a certain percentage:
// Alert when 80% of budget is spent
{
  amount: 500,
  alert_threshold: 80,
  // Alerts at $400 spent
}

// Alert when 90% of budget is spent
{
  amount: 1000,
  alert_threshold: 90,
  // Alerts at $900 spent
}
Alert thresholds must be between 0 and 100. The system will track spending and compare it against the threshold percentage.

Visual Progress Tracking

Budgets are typically displayed with progress bars showing:
  1. Current spending: Sum of transactions in the current period
  2. Budget limit: The configured amount
  3. Percentage: Current spending / Budget limit * 100
  4. Alert status: Whether the threshold has been exceeded
// Example progress calculation (client-side)
function calculateBudgetProgress(budget: CategoryBudget, transactions: Transaction[]) {
  const spent = transactions
    .filter(t => t.subcategory_id === budget.category_id)
    .filter(t => isInCurrentPeriod(t.date, budget.period))
    .reduce((sum, t) => sum + Math.abs(t.amount), 0)
    
  const percentage = (spent / budget.amount) * 100
  const isOverBudget = percentage > 100
  const isNearLimit = percentage >= budget.alert_threshold
  
  return {
    spent,
    remaining: Math.max(0, budget.amount - spent),
    percentage: Math.min(100, percentage),
    isOverBudget,
    isNearLimit,
  }
}

API Routes

All budget routes require authentication:
backend/routes/budget/budget-routes.ts
const router: Router = Router()

router.use(authenticateToken)

// GET /budget - Get all budgets for active account
router.get('/', getBudgets)

// GET /budget/:id - Get single budget
router.get('/:id', getBudget)

// POST /budget - Create budget
router.post('/', checkCSRF, createBudget)

// PATCH /budget/:id - Update budget
router.patch('/:id', checkCSRF, updateBudget)

// DELETE /budget/:id - Delete budget
router.delete('/:id', checkCSRF, deleteBudget)
Mutation endpoints (POST, PATCH, DELETE) require CSRF tokens for security.

Data Transfer Objects

Create Budget DTO

backend/models/budget/index.ts
export interface CreateBudgetDTO {
  account_id: string
  category_id: string
  amount: number
  period?: BudgetPeriod
  alert_threshold?: number
}

Update Budget DTO

backend/models/budget/index.ts
export interface UpdateBudgetDTO {
  amount?: number
  period?: BudgetPeriod
  alert_threshold?: number
}

Best Practices

One Budget Per Category

Each category should have only one active budget. Create separate budgets for different spending categories.

Set Realistic Limits

Base budget amounts on historical spending data. Review and adjust budgets quarterly.

Use Alert Thresholds

Set thresholds at 75-80% to get early warnings before going over budget.

Match Period to Spending

Use monthly budgets for recurring expenses, weekly for discretionary spending, and yearly for infrequent costs.

Integration with Categories

Budgets are tightly integrated with the category system:
  • Each budget tracks spending for a specific category
  • Transactions assigned to that category count toward the budget
  • Subcategories roll up into their parent category’s budget
  • Budget progress is calculated client-side after decrypting transaction amounts
// Example: Get budget with spending data
const budget = useBudgets(accountId)
const transactions = useTransactions({
  account_id: accountId,
  start_date: getPeriodStartDate(budget.period),
  end_date: getPeriodEndDate(budget.period),
})

// Calculate spending for this budget's category
const spending = transactions.data?.transactions
  .filter(t => t.category_id === budget.category_id)
  .reduce((sum, t) => sum + Math.abs(t.amount), 0) || 0

Build docs developers (and LLMs) love