Day 16: Intent Meets Reality

budgets derived intent local-first non-enforcing

We now have a complete financial truth layer: transactions, transfers, splits, reconciliation, rules, undo, and import. But truth alone isn't enough. Users don't just want to know what happenedβ€”they want to express what they intended to happen.

This sprint introduces budgetsβ€”our first venture into the intent layer. And the key insight is that intent must never try to become truth.

The Observer Pattern

The critical design decision for this sprint: budgets observe reality, they don't enforce it.

Most budgeting apps get this wrong. They treat budgets as enforcement mechanismsβ€”blocking transactions, showing angry red warnings, making users feel guilty. This creates two problems:

  • Reality wins anyway. You can't actually stop someone from spending money. The transaction already happened. Your credit card doesn't care about your grocery budget.
  • Judgment doesn't help. Making users feel bad about overspending doesn't help them make better decisions. It just makes them avoid the app.

Our budgets take a different approach. They're purely observational. They show you what you planned, what you spent, and the difference. That's information, not judgment.

"Budgets show progress, not failure. Spending over budget isn't an errorβ€”it's information."

Derived, Not Stored

Every budget calculation is derived on-demand from transactions. We don't store aggregates. This might seem inefficient, but it's essential for several reasons:

  • Undo safety: If you undo a transaction, the budget immediately reflects the change. No stale data.
  • Time travel: You can calculate budget status for any historical period by replaying transactions.
  • Determinism: Same inputs always produce same outputs. Critical for sync.
  • Simplicity: One source of truth (transactions). Budgets just observe.
// All these values are computed, never stored
interface BudgetCalculation {
  budgetedAmount: number;      // From budget.amount
  spentAmount: number;         // Sum of period expenses
  remainingAmount: number;     // budgetedAmount - spentAmount
  rolloverAmount: number;      // Derived from prior periods
  effectiveBudget: number;     // budgetedAmount + rolloverAmount
  effectiveRemaining: number;  // effectiveBudget - spentAmount
}

Period Handling Done Right

Dates are hard. Timezones are harder. Calendar math is hardest. We addressed this by:

  • UTC everywhere: All period calculations use UTC. No timezone ambiguity.
  • Pure functions: getPeriodStart, getPeriodEnd, getNextPeriod are deterministic.
  • ISO weeks: Weekly budgets use ISO week definition (Monday start).
  • Explicit boundaries: Every period has a start timestamp and end timestamp. No implicit assumptions.
// Period boundaries are explicit
getPeriodStart(date, 'monthly')  // First day of month, 00:00:00.000 UTC
getPeriodEnd(date, 'monthly')    // Last day of month, 23:59:59.999 UTC

// Period keys are human-readable
getPeriodKey(date, 'monthly')    // "2024-01"
getPeriodKey(date, 'weekly')     // "2024-W03"
getPeriodKey(date, 'yearly')     // "2024"

Rollover: Intent Carrying Forward

When rollover is enabled, unspent budget carries forward to the next period. If you overspend, that deficit carries forward too. The key insight: rollover is derived, not stored.

To calculate rollover for the current period, we walk through all periods from when the budget started:

async calculateRollover(budget, currentPeriodStart) {
  let rollover = 0;
  let period = budget.startDate;

  while (period < currentPeriodStart) {
    const spent = await getCategorySpending(period);
    rollover = budget.amount - spent + rollover;
    period = getNextPeriodStart(period);
  }

  return rollover;
}

This seems expensive, but it's actually cheap (few database queries), and more importantly, it's correct. If you undo a transaction from three months ago, the rollover for all subsequent months automatically updates.

Undo Preserves Intent

Budget operations emit to the operation log just like everything else:

  • budget.create β€” Created a new budget
  • budget.update β€” Changed amount or settings
  • budget.delete β€” Removed a budget

And they can all be undone:

  • budget.revert β€” Undo creation (soft delete)
  • budget.revert.update β€” Restore previous values
  • budget.restore β€” Undo deletion (undelete)

The critical insight: undoing a budget change never affects transactions. Intent and truth remain separate layers. You can't accidentally delete your transactions by undoing a budget.

The Architecture of Intent

With budgets, we've established a new architectural pattern:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              INTENT LAYER                    β”‚
β”‚   Budgets, Goals, Plans (observational)     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚
                    β”‚ observes (never modifies)
                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              TRUTH LAYER                     β”‚
β”‚   LedgerService (authoritative)             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

This separation is fundamental. Future features like goals, savings targets, and spending forecasts will all live in the intent layer. They'll all observe truth without trying to become it.

What We Built

Budget Primitive

Category, period, amount, rollover settings.

BudgetService

CRUD, operation logging, and derived calculations.

Period Utilities

Monthly/weekly/yearly boundaries, deterministic.

Rollover Calculation

Derived from prior periods, automatically updates.

Full Undo Support

All budget operations are reversible via compensating ops.

Test Suite

Period calculations, spending, rollover, and undo tests.

The Principle

Intent should never rewrite truth.

Your budget says $500 for groceries. You spent $600. The budget doesn't change the $600 to $500. It doesn't hide the difference. It shows you reality and trusts you to make decisions with that information.

This is what local-first means for intent: your plans live alongside your data, fully offline, fully undoable, but never trying to override what actually happened.

Sprint 16 Checklist

// Sprint 16 Verification
βœ“ Budget primitive exists and is documented
βœ“ All calculations are derived only
βœ“ Rollover works correctly
βœ“ Undo works for budget changes
βœ“ Budget UI is non-judgmental
βœ“ Regression suite passes
βœ“ Day 16 Dev Journal is present

════════════════════════════════════════════════════════════
All checklist items complete
════════════════════════════════════════════════════════════

Budgets used to be about control. Now they're about awareness. No blocking. No judgment. Just information about what you intended versus what happened.

Your intent. Your truth. Both respected.

β€” The Accelerate Finance Team