diff --git a/.kiro/specs/enforce-budget-availability/design.md b/.kiro/specs/enforce-budget-availability/design.md new file mode 100644 index 0000000..d24232f --- /dev/null +++ b/.kiro/specs/enforce-budget-availability/design.md @@ -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 +``` + +### 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. +``` diff --git a/.kiro/specs/enforce-budget-availability/requirements.md b/.kiro/specs/enforce-budget-availability/requirements.md new file mode 100644 index 0000000..710c941 --- /dev/null +++ b/.kiro/specs/enforce-budget-availability/requirements.md @@ -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. diff --git a/.kiro/specs/enforce-budget-availability/tasks.md b/.kiro/specs/enforce-budget-availability/tasks.md new file mode 100644 index 0000000..e27b5cc --- /dev/null +++ b/.kiro/specs/enforce-budget-availability/tasks.md @@ -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` + (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 $ — only $ 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. diff --git a/.kiro/steering/hledger-budget-app-design.md b/.kiro/steering/hledger-budget-app-design.md index 5290b52..453293d 100644 --- a/.kiro/steering/hledger-budget-app-design.md +++ b/.kiro/steering/hledger-budget-app-design.md @@ -240,6 +240,32 @@ assets:savings ← physical account (included in R liabilities:credit-card ← liability (included in Ready to Assign) ``` +### Budget availability & single-host model (decision — Issue #7) + +The budget tree hangs off a **single host account** (`resolveBudgetBase` → +`:budget:*`, default `assets:checking`). This is intentional and is **not** +going to become multi-account envelope hosting — that larger model change is not a +goal. The decisions: + +- **Money can physically live anywhere.** "Ready to Assign" is net worth across + **all** real accounts (assets + liabilities) − envelopes, so funds held in + savings count toward what can be assigned even when checking is empty. There is + no need to host envelopes on multiple accounts to budget money that sits in + savings. +- **You can't assign money that doesn't exist.** Assignment is **capped at Ready + to Assign** — enforced server-side in `budget/assign.post.ts` via the shared + `getReadyToAssign()` (`server/utils/budgetData.ts`), the single source of truth + also used by `GET /api/budget`. An over-assignment is rejected (400) and nothing + is written. The gate is the net-worth pool, never a single account's balance. +- **Overspending an envelope is allowed.** Spending past an envelope's balance + drives its Available negative (shown red); cover it by **moving funds from + another envelope** (`budget/transfer.post.ts`). Transfers are never gated — they + reshuffle existing money, they don't create it. +- **A negative `…:budget:unallocated` is correct and intentionally not surfaced.** + Assigning savings-backed money debits the host's unallocated pool below zero + even though net worth covers it (Ready to Assign stays ≥ 0). This is numerically + correct; the UI does not render a per-host unallocated balance, by design. + ### Envelope Transaction Types | User Action | hledger Transaction | diff --git a/AI-MAP.md b/AI-MAP.md index c7b2105..1f33e51 100644 --- a/AI-MAP.md +++ b/AI-MAP.md @@ -77,6 +77,9 @@ rejected on delete and upload**, since they break the date-line ↔ tindex mappi in request handlers, Issue #4). - `activeJournal.ts` — owns reading `config/active-journal.json` (`readActiveJournalPath`) + the `SAMPLE_JOURNAL`/`ACTIVE_JOURNAL_CONFIG` constants (kept out of `hledger.ts`). +- `budgetData.ts` — `getReadyToAssign` (YNAB Rule 1: net worth − envelopes) + + `READY_TO_ASSIGN_EPSILON`. Single source of truth for "Ready to Assign", shared + by `GET /api/budget` and the `budget/assign` availability gate (Issue #7). - `journalWriter.ts` — `validateTransaction`, `formatTransaction`, `appendTransaction`, `fieldHasIllegalChars` (rejects `\r\n\t` in free-text fields — journal-injection guard). - `journalFiles.ts` — `JOURNALS_DIR`, `safeJournalPath` (path-traversal guard for diff --git a/pages/budget.vue b/pages/budget.vue index b24f3bb..95fd902 100644 --- a/pages/budget.vue +++ b/pages/budget.vue @@ -177,6 +177,18 @@ async function saveAssignment(cat: BudgetCategory) { return } + // Can't assign money that doesn't exist (Issue #7). Block over-assignment for + // instant feedback; the server gate stays authoritative. Reductions (delta < 0) + // and transfers are never gated. + if (delta > 0 && budget.value && delta > budget.value.readyToAssign + 0.005) { + toast.add({ + title: 'Not enough to assign', + description: `Only ${formatCurrency(budget.value.readyToAssign)} left to assign.`, + color: 'error', + }) + return + } + const envelopeKey = cat.accountPath.replace(/^expenses:/, '') const budgetAccount = `assets:checking:budget:${envelopeKey}` const today = new Date().toISOString().slice(0, 10) diff --git a/server/api/__tests__/budget-assign.test.ts b/server/api/__tests__/budget-assign.test.ts new file mode 100644 index 0000000..74dbfe2 --- /dev/null +++ b/server/api/__tests__/budget-assign.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { resolveBudgetBase } from '../../utils/hledger' +import { getReadyToAssign } from '../../utils/budgetData' + +/** + * POST /api/budget/assign — availability gate (GitHub Issue #7) + * + * You can't assign money that doesn't exist. "Money that exists" is Ready to + * Assign = net worth across ALL real accounts − envelopes, so savings-held funds + * count even when the host account is empty. These tests drive the real + * `getReadyToAssign` over mocked hledger output, so they exercise the gate + * end-to-end (including the net-worth basis), not just a stubbed number. + * + * getReadyToAssign() (called with no inputs from the endpoint) issues: + * 1. hledgerExecText(['accounts']) → resolveBudgetBase + * 2. hledgerExec(['bal', ':budget:']) → cumulative budget balances + * 3. hledgerExec(['bal', 'assets:', 'liabilities:']) → net worth + */ + +const mockAppendTransaction = vi.fn() +const mockSetResponseStatus = vi.fn() +const mockReadBody = vi.fn() +const mockHledgerExec = vi.fn() +const mockHledgerExecText = vi.fn() +const mockTransformBalanceReport = vi.fn() + +vi.stubGlobal('defineEventHandler', (handler: Function) => handler) +vi.stubGlobal('readBody', mockReadBody) +vi.stubGlobal('setResponseStatus', mockSetResponseStatus) +vi.stubGlobal('createError', (opts: { statusCode: number; message: string }) => { + const err = new Error(opts.message) as any + err.statusCode = opts.statusCode + return err +}) +vi.stubGlobal('hledgerExec', mockHledgerExec) +vi.stubGlobal('hledgerExecText', mockHledgerExecText) +vi.stubGlobal('transformBalanceReport', mockTransformBalanceReport) +vi.stubGlobal('resolveBudgetBase', resolveBudgetBase) +vi.stubGlobal('getReadyToAssign', getReadyToAssign) + +vi.mock('../../utils/journalWriter', () => ({ + appendTransaction: (...args: any[]) => mockAppendTransaction(...args), +})) + +const { default: budgetAssign } = await import('../budget/assign.post') +const fakeEvent = {} as any + +/** + * Make `getReadyToAssign()` report `netWorth − envelopes`. + * @param realRows real-account balance rows (assets/liabilities), summing to netWorth + * @param envelopes total in named envelopes (non-unallocated budget sub-accounts) + */ +function stubReadyToAssign(opts: { + netWorth: number + envelopes: number + realRows?: Array<{ account: string; quantity: number }> +}) { + const { netWorth, envelopes } = opts + mockHledgerExecText.mockResolvedValue( + 'assets:checking\nassets:savings\nassets:checking:budget:unallocated\n', + ) + mockHledgerExec.mockResolvedValue({}) // both reads — transform is stubbed below + const realRows = opts.realRows ?? [{ account: 'assets:checking', quantity: netWorth }] + mockTransformBalanceReport + // cumulative budget balances: one envelope holding `envelopes`, unallocated 0 + .mockReturnValueOnce({ + rows: [ + { account: 'assets:checking:budget:food', amounts: [{ commodity: '$', quantity: envelopes }] }, + { account: 'assets:checking:budget:unallocated', amounts: [{ commodity: '$', quantity: 0 }] }, + ], + totals: [{ commodity: '$', quantity: envelopes }], + }) + // net worth across all real accounts + .mockReturnValueOnce({ + rows: realRows.map(r => ({ account: r.account, amounts: [{ commodity: '$', quantity: r.quantity }] })), + totals: [{ commodity: '$', quantity: netWorth }], + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockAppendTransaction.mockResolvedValue(undefined) +}) + +describe('POST /api/budget/assign — availability gate', () => { + it('rejects an assignment whose total exceeds net worth (no journal write)', async () => { + // Net worth $1,000, $620 already in envelopes → Ready to Assign = $380. + stubReadyToAssign({ netWorth: 1000, envelopes: 620 }) + mockReadBody.mockResolvedValue({ + date: '2026-06-16', + physicalAccount: 'assets:checking', + envelopes: { 'food:dining': 500 }, // $500 > $380 available + }) + + await expect(budgetAssign(fakeEvent)).rejects.toThrow( + "Can't assign $500.00 — only $380.00 left to assign.", + ) + expect(mockAppendTransaction).not.toHaveBeenCalled() + expect(mockSetResponseStatus).not.toHaveBeenCalled() + }) + + it('rejects when the SUM across multiple envelopes exceeds net worth', async () => { + // Ready to Assign = $300; two envelopes summing to $450. + stubReadyToAssign({ netWorth: 300, envelopes: 0 }) + mockReadBody.mockResolvedValue({ + date: '2026-06-16', + physicalAccount: 'assets:checking', + envelopes: { rent: 300, groceries: 150 }, // total $450 > $300 + }) + + await expect(budgetAssign(fakeEvent)).rejects.toThrow( + "Can't assign $450.00 — only $300.00 left to assign.", + ) + expect(mockAppendTransaction).not.toHaveBeenCalled() + }) + + it('accepts an assignment within Ready to Assign', async () => { + stubReadyToAssign({ netWorth: 1000, envelopes: 620 }) // RTA = $380 + mockReadBody.mockResolvedValue({ + date: '2026-06-16', + physicalAccount: 'assets:checking', + envelopes: { 'food:dining': 300 }, // $300 ≤ $380 + }) + + const result = await budgetAssign(fakeEvent) + + expect(result).toEqual({ success: true }) + expect(mockAppendTransaction).toHaveBeenCalledTimes(1) + expect(mockSetResponseStatus).toHaveBeenCalledWith(fakeEvent, 201) + }) + + it('accepts an assignment exactly equal to Ready to Assign (boundary)', async () => { + stubReadyToAssign({ netWorth: 380, envelopes: 0 }) // RTA = $380 + mockReadBody.mockResolvedValue({ + date: '2026-06-16', + physicalAccount: 'assets:checking', + envelopes: { vacation: 380 }, // exactly $380 + }) + + const result = await budgetAssign(fakeEvent) + + expect(result).toEqual({ success: true }) + expect(mockAppendTransaction).toHaveBeenCalledTimes(1) + }) + + it('accepts savings-backed money even when checking is empty (State B, net-worth basis)', async () => { + // Checking $0, savings $1,000 → net worth $1,000, no envelopes yet → RTA $1,000. + stubReadyToAssign({ + netWorth: 1000, + envelopes: 0, + realRows: [ + { account: 'assets:checking', quantity: 0 }, + { account: 'assets:savings', quantity: 1000 }, + ], + }) + mockReadBody.mockResolvedValue({ + date: '2026-06-16', + physicalAccount: 'assets:checking', + envelopes: { vacation: 500 }, // checking has $0 but savings covers it + }) + + const result = await budgetAssign(fakeEvent) + + expect(result).toEqual({ success: true }) + expect(mockAppendTransaction).toHaveBeenCalledTimes(1) + }) +}) diff --git a/server/api/__tests__/budget-data.property.test.ts b/server/api/__tests__/budget-data.property.test.ts index a444fe1..3cd74e9 100644 --- a/server/api/__tests__/budget-data.property.test.ts +++ b/server/api/__tests__/budget-data.property.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import fc from 'fast-check' import { resolveBudgetBase } from '../../utils/hledger' +import { getReadyToAssign } from '../../utils/budgetData' // --- Mock Nitro globals --- @@ -15,6 +16,7 @@ vi.stubGlobal('hledgerExec', mockHledgerExec) vi.stubGlobal('hledgerExecText', mockHledgerExecText) vi.stubGlobal('transformBalanceReport', mockTransformBalanceReport) vi.stubGlobal('resolveBudgetBase', resolveBudgetBase) +vi.stubGlobal('getReadyToAssign', getReadyToAssign) vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), diff --git a/server/api/__tests__/budget-data.test.ts b/server/api/__tests__/budget-data.test.ts index 4378d6a..a908cda 100644 --- a/server/api/__tests__/budget-data.test.ts +++ b/server/api/__tests__/budget-data.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { resolveBudgetBase } from '../../utils/hledger' +import { getReadyToAssign } from '../../utils/budgetData' // --- Mock Nitro globals --- @@ -16,6 +17,9 @@ vi.stubGlobal('transformBalanceReport', mockTransformBalanceReport) // Real implementation: pure when given the account list budget.get.ts passes, // so it derives the base from the mocked accounts (Issue #4 item 3). vi.stubGlobal('resolveBudgetBase', resolveBudgetBase) +// Real RTA util: budget.get.ts hands it the already-fetched base + cumulative +// report, so it only issues the real-balance read the suite already mocks. +vi.stubGlobal('getReadyToAssign', getReadyToAssign) // Mock fs used by loadHiddenEnvelopes / pathExists — no hidden-envelopes file. vi.mock('node:fs/promises', () => ({ diff --git a/server/api/__tests__/budget-endpoints.property.test.ts b/server/api/__tests__/budget-endpoints.property.test.ts index e755c8b..677053d 100644 --- a/server/api/__tests__/budget-endpoints.property.test.ts +++ b/server/api/__tests__/budget-endpoints.property.test.ts @@ -6,6 +6,7 @@ import fc from 'fast-check' const mockAppendTransaction = vi.fn() const mockSetResponseStatus = vi.fn() const mockReadBody = vi.fn() +const mockGetReadyToAssign = vi.fn() vi.stubGlobal('defineEventHandler', (handler: Function) => handler) vi.stubGlobal('readBody', mockReadBody) @@ -15,6 +16,9 @@ vi.stubGlobal('createError', (opts: { statusCode: number; message: string }) => return err }) vi.stubGlobal('setResponseStatus', mockSetResponseStatus) +// Keep the assign pool unlimited so these property tests aren't gated by +// availability (the gate has dedicated tests in budget-assign.test.ts). +vi.stubGlobal('getReadyToAssign', mockGetReadyToAssign) vi.mock('../../utils/journalWriter', () => ({ appendTransaction: (...args: any[]) => mockAppendTransaction(...args), @@ -26,6 +30,7 @@ const fakeEvent = {} as any beforeEach(() => { vi.clearAllMocks() mockAppendTransaction.mockResolvedValue(undefined) + mockGetReadyToAssign.mockResolvedValue(Number.POSITIVE_INFINITY) }) // --- Arbitrary Helpers --- diff --git a/server/api/__tests__/budget-endpoints.test.ts b/server/api/__tests__/budget-endpoints.test.ts index f4980fc..f8d793e 100644 --- a/server/api/__tests__/budget-endpoints.test.ts +++ b/server/api/__tests__/budget-endpoints.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockAppendTransaction = vi.fn() const mockSetResponseStatus = vi.fn() const mockReadBody = vi.fn() +const mockGetReadyToAssign = vi.fn() vi.stubGlobal('defineEventHandler', (handler: Function) => handler) vi.stubGlobal('readBody', mockReadBody) @@ -14,6 +15,9 @@ vi.stubGlobal('createError', (opts: { statusCode: number; message: string }) => return err }) vi.stubGlobal('setResponseStatus', mockSetResponseStatus) +// The assign availability gate is exercised in budget-assign.test.ts; here we +// keep the pool effectively unlimited so these mechanics tests aren't gated. +vi.stubGlobal('getReadyToAssign', mockGetReadyToAssign) vi.mock('../../utils/journalWriter', () => ({ appendTransaction: (...args: any[]) => mockAppendTransaction(...args), @@ -26,6 +30,7 @@ const fakeEvent = {} as any beforeEach(() => { vi.clearAllMocks() + mockGetReadyToAssign.mockResolvedValue(Number.POSITIVE_INFINITY) }) /** diff --git a/server/api/__tests__/migration.test.ts b/server/api/__tests__/migration.test.ts index e68213f..a7ca7c4 100644 --- a/server/api/__tests__/migration.test.ts +++ b/server/api/__tests__/migration.test.ts @@ -12,6 +12,7 @@ const mockTransformBalanceReport = vi.fn() const mockGetQuery = vi.fn() const mockReadBody = vi.fn() const mockSetResponseStatus = vi.fn() +const mockGetReadyToAssign = vi.fn() vi.stubGlobal('defineEventHandler', (handler: Function) => handler) vi.stubGlobal('getQuery', mockGetQuery) @@ -26,6 +27,10 @@ vi.stubGlobal('hledgerExec', mockHledgerExec) vi.stubGlobal('hledgerExecText', mockHledgerExecText) vi.stubGlobal('transformBalanceReport', mockTransformBalanceReport) vi.stubGlobal('resolveBudgetBase', resolveBudgetBase) +// The GET test here hits the no-budget-accounts path (RTA never reached); the +// assign test isn't about the gate — keep the pool unlimited. The real +// getReadyToAssign is covered in budget-assign.test.ts / budget-data.test.ts. +vi.stubGlobal('getReadyToAssign', mockGetReadyToAssign) vi.mock('node:fs', () => ({ existsSync: () => false })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -52,6 +57,7 @@ const fakeEvent = {} as any beforeEach(() => { vi.clearAllMocks() + mockGetReadyToAssign.mockResolvedValue(Number.POSITIVE_INFINITY) }) /** diff --git a/server/api/__tests__/read-args-security.test.ts b/server/api/__tests__/read-args-security.test.ts index 9dabfa5..971ad38 100644 --- a/server/api/__tests__/read-args-security.test.ts +++ b/server/api/__tests__/read-args-security.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { resolveBudgetBase } from '../../utils/hledger' +import { getReadyToAssign } from '../../utils/budgetData' // Issue #2, R4: read routes validate query params before spawning hledger and // pass account queries after a `--` separator. @@ -15,6 +16,7 @@ vi.stubGlobal('hledgerExecText', mockHledgerExecText) vi.stubGlobal('transformTransactions', (raw: any[]) => raw) vi.stubGlobal('transformBalanceReport', () => ({ rows: [], totals: [] })) vi.stubGlobal('resolveBudgetBase', resolveBudgetBase) +vi.stubGlobal('getReadyToAssign', getReadyToAssign) vi.stubGlobal('createError', (opts: { statusCode: number; statusMessage?: string; message?: string }) => { const err = new Error(opts.statusMessage ?? opts.message) as any err.statusCode = opts.statusCode diff --git a/server/api/budget.get.ts b/server/api/budget.get.ts index d56d215..7b7bbbf 100644 --- a/server/api/budget.get.ts +++ b/server/api/budget.get.ts @@ -66,7 +66,6 @@ export default defineEventHandler(async (event) => { // c) Real account totals → compute Ready to Assign via YNAB Rule 1 const budgetBalanceMap = new Map() // cumulative Available const budgetPeriodDeltaMap = new Map() // period net change - let totalBudgetEnvelopes = 0 // sum of all non-unallocated budget sub-accounts let readyToAssign = 0 try { @@ -75,42 +74,21 @@ export default defineEventHandler(async (event) => { const cumulativeRaw = await hledgerExec(cumulativeArgs) const cumulativeReport = transformBalanceReport(cumulativeRaw) - let totalAllBudgetSubAccounts = 0 for (const row of cumulativeReport.rows) { const account = row.account as string - const balance = singleQuantity(row.amounts, `budget balance for ${account}`) - - if (account.startsWith(budgetPrefix)) { - totalAllBudgetSubAccounts += balance - if (account !== unallocatedAccount - && !account.startsWith(pendingPrefix)) { - const categoryKey = account.slice(budgetPrefix.length) - budgetBalanceMap.set(categoryKey, balance) - totalBudgetEnvelopes += balance - } + if (account.startsWith(budgetPrefix) + && account !== unallocatedAccount + && !account.startsWith(pendingPrefix)) { + const categoryKey = account.slice(budgetPrefix.length) + budgetBalanceMap.set(categoryKey, singleQuantity(row.amounts, `budget balance for ${account}`)) } } - // YNAB Rule 1: Ready to Assign = total real account balances - money in envelopes - // Real accounts = assets + liabilities (net worth) - // Money in envelopes = all budget sub-accounts except unallocated - // So: Ready to Assign = net worth - (total envelopes + pending CC) - // Which simplifies to: unallocated balance (since checking = sum of all budget sub-accounts) - // Plus savings, minus credit card liability, etc. - // - // Actually the simplest correct formula: - // Ready to Assign = sum(all real accounts: assets + liabilities) - sum(all non-unallocated budget sub-accounts) - // This accounts for savings, credit cards, and any other real accounts. - const realBalArgs = ['bal', 'assets:', 'liabilities:'] - const realBalRaw = await hledgerExec(realBalArgs) - const realBalReport = transformBalanceReport(realBalRaw) - const netRealBalance = singleQuantity(realBalReport.totals, 'net real account balance') - - // Subtract all envelope balances (including pending CC) from net real balance - const unallocatedRow = cumulativeReport.rows.find((r: any) => r.account === unallocatedAccount) - const envelopesAndPending = totalAllBudgetSubAccounts - - singleQuantity(unallocatedRow?.amounts, 'unallocated balance') - readyToAssign = netRealBalance - envelopesAndPending + // Ready to Assign (YNAB Rule 1) = net worth − money in envelopes. The single + // source of truth lives in server/utils/budgetData.ts and is shared with the + // assign availability gate, so the report and the gate can never disagree. + // Pass the data we already fetched so this adds only the real-balance read. + readyToAssign = await getReadyToAssign({ budgetBase, cumulativeReport }) // b) Period-scoped delta — net change in budget sub-accounts this period if (pd) { diff --git a/server/api/budget/assign.post.ts b/server/api/budget/assign.post.ts index 3ba36b4..61a2452 100644 --- a/server/api/budget/assign.post.ts +++ b/server/api/budget/assign.post.ts @@ -1,4 +1,5 @@ import { appendTransaction } from '../../utils/journalWriter' +import { READY_TO_ASSIGN_EPSILON } from '../../utils/budgetData' import { toUnallocatedAccount } from '../../../utils/budgetAccounts' import type { TransactionInput, PostingInput } from '../../../types/api' @@ -43,6 +44,20 @@ export default defineEventHandler(async (event) => { totalAssigned += amount } + // Availability gate (GitHub Issue #7): you can't assign money that doesn't + // exist. "Money that exists" is Ready to Assign — net worth across ALL real + // accounts minus envelopes — so savings-held funds count even when the host + // account is empty; the check is the net-worth pool, never a single account. + // Overspending an envelope stays allowed and is covered via budget transfers; + // only assigning beyond the pool is rejected. + const available = await getReadyToAssign() + if (totalAssigned > available + READY_TO_ASSIGN_EPSILON) { + throw createError({ + statusCode: 400, + message: `Can't assign $${totalAssigned.toFixed(2)} — only $${Math.max(0, available).toFixed(2)} left to assign.`, + }) + } + // Debit the unallocated pool (Ready-to-Assign), not bare checking. Assigning // moves money out of the pool into envelopes; reducing an assignment returns // it to the pool (budget/transfer → unallocated). Debiting the pool here makes diff --git a/server/utils/budgetData.ts b/server/utils/budgetData.ts new file mode 100644 index 0000000..6e42eea --- /dev/null +++ b/server/utils/budgetData.ts @@ -0,0 +1,78 @@ +import { singleQuantity, type CommodityAmount } from '../../utils/singleQuantity' + +/** + * Half a cent — smaller than any real assignment, larger than float drift from + * cents-rounding. Used to compare an assignment total against available funds + * without rejecting an exactly-affordable assignment over a rounding hair. + */ +export const READY_TO_ASSIGN_EPSILON = 0.005 + +interface BalanceRow { + account?: string + amounts?: CommodityAmount[] +} + +interface BalanceReport { + rows: BalanceRow[] + totals?: CommodityAmount[] +} + +export interface ReadyToAssignInputs { + /** Pre-resolved budget base, to skip re-reading the account list. */ + budgetBase?: string + /** Pre-fetched cumulative budget balances (`bal :budget:`), to skip the read. */ + cumulativeReport?: BalanceReport +} + +/** + * Ready to Assign (YNAB Rule 1): the pool of money that exists but isn't yet + * earmarked. + * + * RTA = net real balance (all assets + liabilities) − sum of envelope balances + * + * "Money that exists" is **net worth across all real accounts**, not the balance + * of any single account — so funds physically held in savings count toward what + * can be assigned even when the budget-host account (checking) is empty + * (GitHub Issue #7). The single source of truth for this figure; consumed by + * `GET /api/budget` (the report) and by the assign endpoint (the availability + * gate), so the two can never disagree. + * + * Callers that have already fetched the budget base and/or cumulative budget + * balances (the report path) may pass them via {@link ReadyToAssignInputs} to + * avoid redundant hledger calls; the assign path calls it with no inputs and it + * fetches everything itself. Always issues one `bal assets: liabilities:` read. + * + * @throws MultiCommodityError if a budget or real account holds >1 commodity. + */ +export async function getReadyToAssign(inputs: ReadyToAssignInputs = {}): Promise { + let budgetBase = inputs.budgetBase + if (budgetBase === undefined) { + const allAccountsRaw = await hledgerExecText(['accounts']) + const allAccounts = allAccountsRaw.trim().split(/\r?\n/).filter(Boolean).map(s => s.trim()) + budgetBase = await resolveBudgetBase(allAccounts) + } + const budgetPrefix = `${budgetBase}:budget:` + const unallocatedAccount = `${budgetPrefix}unallocated` + + const cumulativeReport: BalanceReport = inputs.cumulativeReport + ?? transformBalanceReport(await hledgerExec(['bal', budgetPrefix])) + + // Sum every budget sub-account, then back out unallocated → money sitting in + // named envelopes (+ pending CC). + let totalAllBudgetSubAccounts = 0 + for (const row of cumulativeReport.rows) { + const account = row.account ?? '' + if (account.startsWith(budgetPrefix)) { + totalAllBudgetSubAccounts += singleQuantity(row.amounts, `budget balance for ${account}`) + } + } + const unallocatedRow = cumulativeReport.rows.find(r => r.account === unallocatedAccount) + const envelopesAndPending = totalAllBudgetSubAccounts + - singleQuantity(unallocatedRow?.amounts, 'unallocated balance') + + // Net worth across every real account (assets + liabilities). + const realBalReport: BalanceReport = transformBalanceReport(await hledgerExec(['bal', 'assets:', 'liabilities:'])) + const netRealBalance = singleQuantity(realBalReport.totals, 'net real account balance') + + return netRealBalance - envelopesAndPending +}