Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .kiro/specs/enforce-budget-availability/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Design — Enforce budget availability (no assigning money you don't have)

> Traceability: implements **GitHub Issue #7** ("Envelopes physically backed by a
> non-host account — e.g. budget in an envelope, money kept in savings").
> Relates to #4 (Item 3 generalized the budget host via `resolveBudgetBase` but
> kept the single-host model — `server/api/budget.get.ts:44-49`).
>
> **Scope pivot (post-design discussion):** the original draft treated this as a
> presentation-only "show a negative Ready-to-Assign nicely" change. The product
> owner clarified the intended model: **you should not be able to assign money to
> an envelope that doesn't exist.** Overspending an *envelope* stays allowed
> (covered by moving funds between envelopes); over-*assigning* from the pool is
> prevented. This design reflects that model.

## The budgeting model (as clarified)

- Money enters as an **inflow to a real account** (e.g. checking) and lands in the
**Ready-to-Assign pool**. Where the cash physically sits is irrelevant to how
much you can assign — savings counts too.
- **Ready to Assign (RTA) = net real balance (all assets + liabilities) − sum of
envelope balances.** This is the "money that actually exists and isn't yet
earmarked." Already computed correctly in `budget.get.ts:104-113`.
- **Assigning** moves money from the RTA pool into an envelope. You may assign up
to RTA and **no more** — you can't budget money that doesn't exist.
- **Spending** debits an expense and credits the envelope. Spending more than an
envelope holds drives that envelope's **Available negative (overspending)** —
this **is allowed**. RTA is unchanged by spending (cash and envelope both drop
by the same amount).
- **Covering overspending** = move funds from another envelope into the overspent
one via a **budget transfer** (`budget/transfer.post.ts`). Transfers are
unrestricted — they reshuffle already-existing money, they don't create it.

The pool (RTA) is the single "does this money exist?" gate. Envelopes may go
negative (overspending); the pool may not be over-drawn (over-assigning).

## Problem framing

1. **No availability gate on assignment (the core fix).** `assign.post.ts`
appends the assignment unconditionally — it never checks that RTA covers it.
So the UI lets you assign money you don't have, producing a negative RTA /
confusing state. We add a **server-side availability check**.
2. **State B is correct and must keep working.** Assigning money that physically
lives in savings (host account = checking holds $0) is legitimate: RTA counts
savings, so the assignment is within budget. The check must be against **RTA
(net worth)**, never against the host account's cash — otherwise we'd break the
exact scenario Issue #7 is about. A side effect is a negative
`…:budget:unallocated` sub-account; that is numerically correct and stays
invisible (documented, not surfaced).
3. **The model decision is undocumented.** Single-host + money-can-be-anywhere,
and "negative unallocated is fine," need to be written down (Issue #7's second
ask). Multi-account envelope hosting is explicitly **not** a goal.

## Proposed solution

### 1. Extract the RTA computation to a shared server util (one source of truth)

`server/utils/budgetData.ts` — new `getReadyToAssign()` that performs the two
hledger reads and returns the current RTA number. `budget.get.ts` is refactored
to call it (no behavior change there); `assign.post.ts` calls it to gate writes.
This avoids duplicating accounting logic across two endpoints
(`separation-of-concerns.md`: accounting math has one home; server utils own
hledger access).

```ts
// server/utils/budgetData.ts
/** YNAB Rule 1 pool: net real balance (assets + liabilities) − sum of envelopes. */
export async function getReadyToAssign(): Promise<number>
```

### 2. Gate assignment server-side in `assign.post.ts`

Before `appendTransaction`, compute `available = await getReadyToAssign()`. If
`totalAssigned > available + EPSILON` (half a cent), reject:

```
400 "Can't assign $500.00 — only $380.00 left to assign."
```

- The check is on the **global pool**, independent of `physicalAccount` — savings
money counts. State B (savings-backed assignment) passes.
- `EPSILON = 0.005` absorbs float drift so an exactly-affordable assignment isn't
rejected by a rounding hair.
- **Only assignment is gated.** `transfer.post.ts` (envelope↔envelope, incl.
reduce-to-unallocated) stays unrestricted — that's how overspending is covered
and how assignments are walked back.

### 3. UI guard + messaging in `pages/budget.vue`

The inline-assign flow (`saveAssignment`) computes a positive `delta` when
raising an assignment. Add an instant client-side check: if
`delta > budget.readyToAssign + EPSILON`, show a friendly toast ("Only $X left to
assign") and don't fire the request. The **server remains the source of truth** —
a rejected request still surfaces its message via the existing error toast. The
RTA badge keeps its red treatment as a defensive fallback (RTA shouldn't go
negative through normal flow once gated, but legacy/manual journals might).

### 4. Document the model decision

`.kiro/steering/hledger-budget-app-design.md` (under *Budget Page* / *Envelope
Account Structure*): single budget host is intentional; money can physically live
in any real account; **assignment is capped at Ready to Assign**; overspending an
envelope is allowed and covered by inter-envelope transfers; a negative
`…:budget:unallocated` is numerically correct and intentionally not surfaced;
multi-account envelope backing is not a goal. Reference Issue #7.

## Data flow

```
POST /api/budget/assign
→ getReadyToAssign() [NEW shared util: net worth − envelopes via hledger]
→ if totalAssigned > available → 400 "only $X left to assign" (NO write)
→ else appendTransaction(...) (debit unallocated pool, credit envelopes)

GET /api/budget
→ getReadyToAssign() [same util — refactor, no behavior change]

pages/budget.vue saveAssignment()
→ delta > readyToAssign ? toast + abort (instant)
→ else POST assign ; server 400 also toasts if it slips through
```

## Components & files

| File | Change | Why |
|---|---|---|
| `server/utils/budgetData.ts` | **New.** `getReadyToAssign()` (+ `EPSILON`). | Single source of truth for the pool; reused by gate + report. |
| `server/api/budget.get.ts` | Refactor RTA block to call `getReadyToAssign()`. No behavior change. | De-dupe accounting logic. |
| `server/api/budget/assign.post.ts` | Compute available, reject over-assignment with a clear 400. | The core fix. |
| `server/api/__tests__/budget-assign.test.ts` | **New/extended.** Over-assign rejected; within-budget allowed; savings-backed assignment allowed (State B). | Cover the gate. |
| `pages/budget.vue` | Client-side over-assign guard + message in `saveAssignment`. | Instant feedback; server still authoritative. |
| `.kiro/steering/hledger-budget-app-design.md` | Add the model-decision note. | Issue #7 ask 2. |
| `AI-MAP.md` | Add `server/utils/budgetData.ts` row. | Map upkeep. |

## Edge cases

- **Savings-backed assignment (State B):** host account holds $0 but net worth
covers it → `getReadyToAssign()` ≥ requested → **allowed**. Negative
`unallocated` results and is intentionally invisible.
- **Exact-budget assignment:** `totalAssigned == available` → allowed (EPSILON
guard prevents a rounding rejection).
- **Reducing / reshuffling:** handled by `transfer.post.ts`, never gated — you can
always walk an assignment back or cover an overspent envelope.
- **Overspending:** unaffected — spending still allowed, envelope Available goes
red (existing `availableColor`), RTA unchanged.
- **Concurrency:** single-user local app; read-then-append race is acceptable and
the journal writer's own balance validation remains the final guard.

## Alternatives considered

- **Soft-warn only (original draft / earlier proposal).** Rejected by the product
owner: the app should not let you assign money that doesn't exist.
- **Gate on the host account's cash instead of RTA.** Rejected — would block the
legitimate savings-backed assignment that Issue #7 is specifically about.
- **Also block transfers that overdraw the source envelope.** Rejected —
overspending and covering it by moving funds is the intended mechanism;
restricting transfers would remove the only way to fix an overspent envelope.
- **Duplicate the RTA formula inside `assign.post.ts`.** Rejected — two copies of
accounting logic drift; extract a shared util instead.
- **Build multi-account envelope hosting.** Rejected — large model change, not a
goal; net-worth RTA already delivers the YNAB outcome.
```
101 changes: 101 additions & 0 deletions .kiro/specs/enforce-budget-availability/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Requirements — Enforce budget availability (no assigning money you don't have)

> Traceability: **GitHub Issue #7**. Derived from the approved
> [design.md](./design.md). EARS-style acceptance criteria.

## User stories

### US-1 — Can't budget money I don't have
**As a** budgeter, **I want** the app to stop me from assigning more to envelopes
than actually exists (Ready to Assign), **so that** my budget never promises money
I don't have — like a physical envelope you can't put cash in that you don't hold.

### US-2 — Money in any account counts
**As a** budgeter who keeps cash in savings while budgeting it into envelopes,
**I want** "money that exists" to mean my **total net worth** (all real
accounts), **so that** I can assign savings-backed money even when checking is
empty.

### US-3 — Overspending is allowed and recoverable
**As a** budgeter, **I want** to be able to overspend an envelope and cover it by
moving funds from another envelope, **so that** real-life overspending is handled
by reshuffling, not blocked.

### US-4 — Know the intended model
**As a** future contributor, **I want** the design doc to record the single-host /
money-can-be-anywhere model and the assignment cap, **so that** I don't mistake
them (or an invisible negative `unallocated`) for bugs.

## Acceptance criteria (EARS)

### Assignment availability gate
- **AC-1** — WHEN an assignment's total would exceed current Ready to Assign
(net worth − envelopes) beyond a half-cent epsilon, THE SYSTEM SHALL reject the
assignment with HTTP 400 and SHALL NOT write any transaction to the journal.
- **AC-2** — THE rejection message SHALL state both the requested amount and the
amount actually available (e.g. "Can't assign $500.00 — only $380.00 left to
assign.").
- **AC-3** — WHEN an assignment's total is within Ready to Assign (including the
exact-budget boundary, within epsilon), THE SYSTEM SHALL accept it and write the
assignment as today.
- **AC-4** — THE availability check SHALL be enforced **server-side** in the
assign endpoint (independent of any client-side guard), using the shared
`getReadyToAssign()` util.

### Net-worth basis (savings counts)
- **AC-5** — WHEN the budget-host account (checking) holds less than the requested
assignment but total net worth (assets + liabilities, incl. savings) covers it,
THE SYSTEM SHALL accept the assignment.
- **AC-6** — THE SYSTEM SHALL NOT gate assignment on a single account's balance;
the gate SHALL be the net-worth-based Ready-to-Assign pool only.

### Over-assignment scenario test (explicit, per request)
- **AC-7** — THERE SHALL be a test asserting that when the **sum of envelope
assignments would exceed total net worth**, the assignment is **rejected** (400,
no journal write) — i.e. total assigned can never exceed net worth.
- **AC-8** — THERE SHALL be a complementary test asserting that an assignment of
savings-backed money (checking empty, net worth sufficient) is **accepted**
(State B), proving the gate is net-worth-based, not host-account-based.

### Overspending & transfers unaffected
- **AC-9** — THE SYSTEM SHALL continue to allow an envelope's Available to go
negative through spending (overspending is not blocked).
- **AC-10** — THE SYSTEM SHALL NOT gate budget transfers (envelope↔envelope,
incl. reduce-to-unallocated); they remain available to cover overspending and to
walk back assignments.

### Client feedback
- **AC-11** — WHEN the user raises an inline assignment beyond Ready to Assign,
THE budget page SHALL show a clear message and SHALL NOT silently appear to
succeed; a server rejection that reaches the client SHALL surface its message.

### Shared computation (no duplication)
- **AC-12** — THE Ready-to-Assign computation SHALL live in one shared server util
(`getReadyToAssign()`), consumed by both `budget.get.ts` and the assign gate;
the `GET /api/budget` figures SHALL be unchanged by the refactor.

### Documentation
- **AC-13** — THE design doc SHALL record: single budget host is intentional;
money can physically live in any real account; assignment is capped at Ready to
Assign; overspending is allowed and covered by transfers; a negative
`…:budget:unallocated` is correct and intentionally not surfaced; multi-account
envelope backing is not a goal. Reference Issue #7.

## Non-functional requirements
- **NFR-1 — Layer boundaries:** accounting/RTA logic in `server/utils`; the assign
endpoint orchestrates (gate → write); no RTA math duplicated in the `.vue` or in
two endpoints (`separation-of-concerns.md`).
- **NFR-2 — Delegate to hledger:** `getReadyToAssign()` derives the figure from
hledger balance reads, not a hand-rolled ledger walk.
- **NFR-3 — Verification:** `npm run test` (incl. new gate tests) and
`npx nuxi typecheck` pass.
- **NFR-4 — Tests beside source / conventions:** API tests under
`server/api/__tests__/`, Nitro globals stubbed with `vi.stubGlobal()`.

## Out of scope
- Multi-account envelope hosting / multiple budget bases (not a goal).
- Changing the RTA formula or the accounting engine.
- Surfacing per-host `unallocated` balances in the UI.
- New "move funds to cover overspending" UI beyond the existing transfer endpoint
(the mechanism exists; dedicated UI is a separate effort).
- Restricting transfers / blocking overspending.
82 changes: 82 additions & 0 deletions .kiro/specs/enforce-budget-availability/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Tasks — Enforce budget availability (no assigning money you don't have)

> Traceability: **GitHub Issue #7**. Implements [design.md](./design.md),
> satisfies [requirements.md](./requirements.md). Implement top-to-bottom, one at
> a time; check off and report verification after each.

- [x] **T1 — Extract `getReadyToAssign()` into a shared server util**
Add `server/utils/budgetData.ts` exporting `getReadyToAssign(): Promise<number>`
(net real balance from `bal assets: liabilities:` minus sum of non-unallocated
budget sub-accounts, using `resolveBudgetBase`) and `READY_TO_ASSIGN_EPSILON =
0.005`. Mirror the existing math in `budget.get.ts:94-113` exactly.
_Covers: AC-4, AC-12, NFR-1, NFR-2. Files: `server/utils/budgetData.ts`._

- [x] **T2 — Refactor `budget.get.ts` to use the shared util**
Replace the inline RTA computation with a call to `getReadyToAssign()`. No
change to the returned `readyToAssign` value or other figures.
_Covers: AC-12. Files: `server/api/budget.get.ts`._

- [x] **T3 — Add the availability gate to `assign.post.ts`**
Before `appendTransaction`, compute `available = await getReadyToAssign()`.
If `totalAssigned > available + READY_TO_ASSIGN_EPSILON`, throw a 400 with
message `Can't assign $<requested> — only $<available> left to assign.`
(2-dp formatted). Otherwise proceed unchanged.
_Covers: AC-1, AC-2, AC-3, AC-4. Files: `server/api/budget/assign.post.ts`._

- [x] **T4 — Gate tests: over-assignment rejected, within-budget accepted**
`server/api/__tests__/budget-assign.test.ts` (new or extend existing), Nitro
globals via `vi.stubGlobal()`:
- **Over-assign vs net worth → rejected (the requested scenario):** stub
hledger so net worth = $1,000 and envelopes already total such that RTA =
$380; assign $500 → expect 400, message names $500 and $380, and
`appendTransaction` is **not** called. (AC-1, AC-2, AC-7)
- **Within budget → accepted:** assign ≤ RTA → 201, `appendTransaction` called
once. (AC-3)
- **Exact-budget boundary → accepted** (epsilon). (AC-3)
_Covers: AC-1, AC-2, AC-3, AC-7. Files:
`server/api/__tests__/budget-assign.test.ts`._

- [x] **T5 — Gate test: savings-backed assignment accepted (State B)**
In the same suite: stub hledger so checking = $0 but savings makes net worth
cover the assignment (RTA sufficient); assign → **accepted** (201), proving the
gate is net-worth-based, not host-account-based.
_Covers: AC-5, AC-6, AC-8. Files:
`server/api/__tests__/budget-assign.test.ts`._

- [x] **T6 — Client-side over-assign guard in `pages/budget.vue`**
In `saveAssignment`, when `delta > 0` and `delta > budget.readyToAssign +
0.005`, toast "Only $X left to assign" and abort before the request. Keep the
existing error toast so a server 400 still surfaces. No RTA math beyond the
comparison.
_Covers: AC-11, NFR-1. Files: `pages/budget.vue`._

- [x] **T7 — Confirm overspending & transfers remain unrestricted**
Verify (and add a focused assertion if not already covered) that
`transfer.post.ts` is not gated and that spending can drive an envelope negative
— no code change expected; document the confirmation in the task report.
_Covers: AC-9, AC-10. Files: (verification; `server/api/__tests__/` if an
assertion is added)._

- [x] **T8 — Document the model decision**
In `.kiro/steering/hledger-budget-app-design.md` (under *Budget Page* /
*Envelope Account Structure*) add the "Budget availability & single-host model
(decision, Issue #7)" note per AC-13.
_Covers: AC-13. Files: `.kiro/steering/hledger-budget-app-design.md`._

- [x] **T9 — Update `AI-MAP.md`**
Add a `server/utils/budgetData.ts` row (shared RTA util) to the server-utils
listing. Main agent only.
_Covers: map upkeep. Files: `AI-MAP.md`._

- [x] **T10 — Verification checkpoint**
Run `npx vitest run server/api/__tests__/budget-assign.test.ts` (new gate tests
green), then full `npm run test` and `npx nuxi typecheck` — all pass. Report
results.
_Covers: NFR-3, NFR-4._

## Notes
- **The requested test** (assigned totals > net worth ⇒ rejected) is T4, first
bullet; AC-8/T5 is its accepted counterpart that proves savings counts.
- The RTA badge keeps its existing red-for-negative styling as a defensive
fallback; no dedicated presentation helper is needed now that over-assignment is
prevented at the source.
Loading
Loading