From abd77c58ed3d8bc6622df147ddf6346805819107 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 16:13:40 +0200 Subject: [PATCH] docs: add budget app implementation plan 25 TDD tasks across five phases: foundation, pure domain core, IndexedDB persistence, JSON/ODS export, UI, and shipping (PWA + Docker + CI). Self-review caught five defects, fixed inline: - distributeByWeight divided by zero on all-zero weights, which a zero-total purchase reaches and which would take the whole fold down - summaryView's fold binding needed an explicit type or CI typecheck fails - a duplicated module import in registerSw.ts - a shell render test that would break once its routes read the store, since happy-dom has no IndexedDB - an unreadable assertion in the ODS numeric-cell test Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-09-01-budget-app.md | 7376 +++++++++++++++++ 1 file changed, 7376 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-budget-app.md diff --git a/docs/superpowers/plans/2026-09-01-budget-app.md b/docs/superpowers/plans/2026-09-01-budget-app.md new file mode 100644 index 0000000..ca3ed31 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-budget-app.md @@ -0,0 +1,7376 @@ +# Budget 2.0 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a personal budgeting PWA that replaces a spreadsheet — envelope-style monthly budgets with rollover, split purchases, finance plans, multi-currency, and JSON/ODS export — deployable to both GitHub Pages and self-hosted Docker. + +**Architecture:** A pure derivation core over an IndexedDB fact store. IndexedDB holds only raw facts; a dependency-free TypeScript domain package folds them into every derived figure. There is no backend in the data path, so the same static bundle serves Pages and Docker. + +**Tech Stack:** Bun 1.3+ (runtime, package manager, bundler, test runner), React 19, Tailwind 4, shadcn/ui, `fflate` for ODS zip generation, hand-written service worker. No Vite. No Electron. + +**Spec:** `docs/superpowers/specs/2026-09-01-budget-app-design.md` + +## Global Constraints + +Every task's requirements implicitly include this section. + +- **Bun only.** `bun `, `bun test`, `bun install`, `bunx`. Never node, npm, jest, vitest, or vite. +- **The domain layer (`src/domain/`) must never import React, IndexedDB, or read the ambient clock.** It must be importable by a test file with no browser environment. This is an architectural invariant, not a style preference. +- **Money is a float.** Round at every boundary via `roundMoney` — after each division, after each FX conversion, and before persisting. Never round the last distributed part independently; compute it as `total − sum(others)`. +- **Currencies:** `"DKK" | "USD" | "EUR"`, all with 2 decimal places. +- **`MonthId` is the string `"YYYY-MM"`.** Never a `Date` object in stored data. +- **Over-budget is always permitted.** Negative balances get a warning treatment; they never block saving. Allocation percentages may sum past 100%. +- **`splitMode` is per purchase, not per split.** All splits on one purchase are percentages, or all are fixed amounts. +- **Exactly one split per purchase has `absorbsRemainder: true`.** +- **`FxRate.baseUnitsPerOne`** is base units per one unit of the named currency. The base currency has no row. +- **Base path** comes from `BUN_PUBLIC_BASE_PATH` (default `/`). Never hardcode `/budget2.0/`. +- **Tests compare rounded values, never raw floats.** +- Seed posts are exactly: `Video Games`, `Food`, `Events and Social`. + +## File Structure + +| File | Responsibility | +|---|---| +| `src/domain/types.ts` | All domain types. No logic. | +| `src/domain/money.ts` | `roundMoney`, `distributeByWeight`, `distributeByAmount` | +| `src/domain/months.ts` | `MonthId` arithmetic and ranges | +| `src/domain/fx.ts` | Currency conversion against the global rate table | +| `src/domain/allocation.ts` | Resolve a `Rule` to a base-currency allocation | +| `src/domain/charges.ts` | Which purchase slices hit a month, distributed across posts | +| `src/domain/fold.ts` | Envelope rollover fold across months | +| `src/domain/views.ts` | `monthView`, `yearView`, `summaryView` | +| `src/domain/seed.ts` | Default dataset for first run | +| `src/store/db.ts` | IndexedDB open + object stores | +| `src/store/migrations.ts` | `schemaVersion` + ordered migrations | +| `src/store/snapshot.ts` | In-memory snapshot, write-through mutations | +| `src/export/json.ts` | Dataset export/import | +| `src/export/odsXml.ts` | ODS XML document builders | +| `src/export/ods.ts` | ODS zip packaging | +| `src/ui/App.tsx` | Routes and app shell | +| `src/ui/routes/*.tsx` | One file per route | +| `src/ui/components/*.tsx` | Purchase dialog, split editor, plan editor, tables | +| `src/ui/hooks/useDataset.ts` | Snapshot subscription | +| `src/sw.ts` | Service worker source | +| `build.ts` | Static build: `publicPath`, hashed names, SW precache list, `404.html` | +| `Dockerfile` | Static server for `dist/` | +| `.github/workflows/` | CI and Pages deploy | + +Tests are co-located: `src/domain/money.test.ts` beside `src/domain/money.ts`. + +--- + +## Phase 0 — Foundation + +### Task 1: Scaffold the project + +**Files:** +- Create: `package.json`, `tsconfig.json`, `bunfig.toml`, `build.ts`, `src/index.ts`, `src/index.html`, `src/frontend.tsx`, `src/index.css` (all via `bun init`) +- Modify: `CLAUDE.md` (append project invariants), `.gitignore` +- Delete: `src/APITester.tsx`, `src/logo.svg`, `src/react.svg` +- Test: `src/domain/smoke.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: a working `bun --hot src/index.ts` dev server and a passing `bun test`. + +- [ ] **Step 1: Scaffold with Bun** + +Run in the repo root. It will not overwrite the existing `README.md` or `.gitignore`. + +```bash +bun init --react=shadcn . +``` + +- [ ] **Step 2: Verify the scaffold and remove the API demo** + +There is no backend in this app, so the scaffolded `/api/*` routes and their demo component must go. + +```bash +rm -f src/APITester.tsx src/logo.svg src/react.svg +``` + +Then edit `src/index.ts` so `routes` contains only the HTML entrypoint: + +```ts +import { serve } from "bun"; +import index from "./index.html"; + +const server = serve({ + routes: { + "/*": index, + }, + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}); + +console.log(`Dev server running at ${server.url}`); +``` + +Remove the `` usage and the logo imports from `src/App.tsx`, leaving a placeholder heading: + +```tsx +export function App() { + return

Budget 2.0

; +} +export default App; +``` + +- [ ] **Step 3: Write the failing smoke test** + +This test exists to prove `bun test` works and that the domain directory is importable with no browser environment. + +```ts +// src/domain/smoke.test.ts +import { test, expect } from "bun:test"; +import { DOMAIN_READY } from "./index.ts"; + +test("domain layer is importable without a DOM", () => { + expect(DOMAIN_READY).toBe(true); +}); +``` + +- [ ] **Step 4: Run it and confirm it fails** + +Run: `bun test src/domain/smoke.test.ts` +Expected: FAIL — cannot resolve `./index.ts`. + +- [ ] **Step 5: Create the domain barrel to make it pass** + +```ts +// src/domain/index.ts +export const DOMAIN_READY = true; +``` + +- [ ] **Step 6: Run the test and the dev server** + +Run: `bun test` +Expected: PASS, 1 test. + +Run: `bun --hot src/index.ts` +Expected: server starts, and the printed URL shows "Budget 2.0". Stop it with Ctrl-C. + +- [ ] **Step 7: Append project invariants to CLAUDE.md** + +The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to this project: + +```markdown +## Project invariants (Budget 2.0) + +- `src/domain/` is pure: no React, no IndexedDB, no `Date.now()`, no `new Date()` + without an explicit argument. It must be testable with no DOM. +- Money is a float. Always round with `roundMoney` from `src/domain/money.ts` + after any division, after any FX conversion, and before persisting. +- When splitting an amount, never round the last part independently. Compute it + as `total - sum(others)` so the parts sum exactly to the whole. +- `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects. +- The envelope rollover fold has exactly one implementation, in + `src/domain/fold.ts`. Year and summary views aggregate over it — they never + reimplement the math. +- Over-budget is always allowed. Never add validation that blocks it. +- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath. +- Read the spec at `docs/superpowers/specs/2026-09-01-budget-app-design.md`. +``` + +- [ ] **Step 8: Ignore the IDE directory** + +The repo has an untracked `.idea/` from a JetBrains IDE. Append to `.gitignore`: + +``` +# JetBrains +.idea/ +``` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "chore: scaffold Bun + React + shadcn project + +bun init --react=shadcn, with the demo API routes removed since this app +has no backend. Adds project invariants to CLAUDE.md and a smoke test +proving the domain layer imports with no DOM." +``` + +--- + +## Phase 1 — Domain core + +### Task 2: Domain types + +**Files:** +- Create: `src/domain/types.ts` +- Test: `src/domain/types.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Currency`, `Money`, `MonthId`, `PostId`, `Rule`, `Post`, `Month`, `Split`, `Schedule`, `Purchase`, `FxRate`, `Settings`, `Dataset`, `CURRENCIES`, `CURRENCY_DIGITS`. Every later task imports from here. + +- [ ] **Step 1: Write the failing test** + +Types alone are not testable, so this test pins the runtime constants that accompany them. + +```ts +// src/domain/types.test.ts +import { test, expect } from "bun:test"; +import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts"; + +test("supported currencies are exactly DKK, USD, EUR", () => { + expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]); +}); + +test("every supported currency has 2 decimal digits", () => { + for (const c of CURRENCIES) { + expect(CURRENCY_DIGITS[c]).toBe(2); + } +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `bun test src/domain/types.test.ts` +Expected: FAIL — cannot resolve `./types.ts`. + +- [ ] **Step 3: Write the types** + +```ts +// src/domain/types.ts + +export const CURRENCIES = ["DKK", "USD", "EUR"] as const; +export type Currency = (typeof CURRENCIES)[number]; + +export const CURRENCY_DIGITS: Record = { + DKK: 2, + USD: 2, + EUR: 2, +}; + +/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */ +export interface Money { + amount: number; + currency: Currency; +} + +/** "YYYY-MM". Never a Date. */ +export type MonthId = string; +/** "YYYY-MM-DD". Never a Date. */ +export type IsoDate = string; +export type PostId = string; +export type PurchaseId = string; + +export type Rule = + | { kind: "fixed"; amount: Money } + | { kind: "percentOfIncome"; percent: number }; + +export interface Post { + id: PostId; + name: string; + order: number; + archived: boolean; + /** Display currency for this post's own views only. All totals use base. */ + currency: Currency; + standingRule: Rule; +} + +export interface Month { + id: MonthId; + /** Always in Settings.baseCurrency. */ + income: Money; + ruleOverrides: Record; +} + +export interface Split { + postId: PostId; + /** A percentage, or an amount in the purchase's currency, per Purchase.splitMode. */ + value: number; + absorbsRemainder: boolean; +} + +export interface ScheduleSlice { + month: MonthId; + amount: Money; +} + +export interface Schedule { + slices: ScheduleSlice[]; + /** Slices in this month AND every month after it are ignored by the fold. */ + cancelledFromMonth?: MonthId; +} + +export interface Purchase { + id: PurchaseId; + date: IsoDate; + description: string; + /** In the purchase's own currency. */ + total: Money; + splitMode: "percent" | "fixed"; + splits: Split[]; + /** null means the whole total is charged in the month of `date`. */ + schedule: Schedule | null; +} + +export interface FxRate { + currency: Currency; + /** How many base units one unit of `currency` buys. Base has no row. */ + baseUnitsPerOne: number; + updatedAt: string; + source: "manual" | "api"; +} + +export interface Settings { + baseCurrency: Currency; + foldStartMonth: MonthId; + schemaVersion: number; +} + +export interface Dataset { + settings: Settings; + fxRates: FxRate[]; + posts: Post[]; + months: Month[]; + purchases: Purchase[]; +} +``` + +- [ ] **Step 4: Run the test** + +Run: `bun test src/domain/types.test.ts` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/types.ts src/domain/types.test.ts +git commit -m "feat(domain): add domain types" +``` + +--- + +### Task 3: Money arithmetic + +**Files:** +- Create: `src/domain/money.ts` +- Test: `src/domain/money.test.ts` + +**Interfaces:** +- Consumes: `Currency`, `Money`, `CURRENCY_DIGITS` from `./types.ts`. +- Produces: + - `roundMoney(amount: number, currency?: Currency): number` + - `money(amount: number, currency: Currency): Money` + - `addAmounts(...amounts: number[]): number` + - `distributeByWeight(total: number, weights: number[], remainderIndex: number): number[]` + - `distributeByAmount(total: number, amounts: number[], remainderIndex: number): number[]` + +- [ ] **Step 1: Write the failing tests** + +The third and fourth tests are the important ones: they encode the "last part by subtraction" rule that keeps float splits exact. + +```ts +// src/domain/money.test.ts +import { test, expect, describe } from "bun:test"; +import { + roundMoney, + distributeByWeight, + distributeByAmount, +} from "./money.ts"; + +describe("roundMoney", () => { + test("rounds to 2 decimal places", () => { + expect(roundMoney(1.234)).toBe(1.23); + expect(roundMoney(1.235)).toBe(1.24); + expect(roundMoney(0.1 + 0.2)).toBe(0.3); + }); + + test("handles negatives symmetrically", () => { + expect(roundMoney(-1.235)).toBe(-1.24); + }); +}); + +describe("distributeByWeight", () => { + test("splits proportionally", () => { + expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]); + }); + + test("thirds sum exactly to the total", () => { + const parts = distributeByWeight(100, [1, 1, 1], 2); + expect(parts).toEqual([33.33, 33.33, 33.34]); + expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100); + }); + + test("the remainder index absorbs the rounding drift", () => { + const parts = distributeByWeight(100, [1, 1, 1], 0); + expect(parts).toEqual([33.34, 33.33, 33.33]); + }); + + test("a single split takes the whole total", () => { + expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]); + }); + + test("weights that do not sum to 100 are still proportional", () => { + expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]); + }); + + test("all-zero weights put everything on the remainder rather than throwing", () => { + // Reachable from a zero-value purchase; must not divide by zero. + expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]); + expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]); + }); +}); + +describe("distributeByAmount", () => { + test("uses the given amounts as-is", () => { + expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]); + }); + + test("the remainder index absorbs a shortfall", () => { + expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]); + }); + + test("the remainder index absorbs an excess, even going negative", () => { + expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]); + }); + + test("parts always sum exactly to the total", () => { + const parts = distributeByAmount(10, [3.33, 3.33, 0], 2); + expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/money.test.ts` +Expected: FAIL — cannot resolve `./money.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/money.ts +import { CURRENCY_DIGITS, type Currency, type Money } from "./types.ts"; + +/** + * Rounds a float to its currency's minor unit. `toFixed` rounds the double's + * actual decimal value, which is the most predictable option available for + * binary floats. Call this after every division and FX conversion, and before + * persisting. + */ +export function roundMoney(amount: number, currency: Currency = "DKK"): number { + const digits = CURRENCY_DIGITS[currency]; + return Number(amount.toFixed(digits)); +} + +export function money(amount: number, currency: Currency): Money { + return { amount: roundMoney(amount, currency), currency }; +} + +export function addAmounts(...amounts: number[]): number { + return roundMoney(amounts.reduce((sum, a) => sum + a, 0)); +} + +function withRemainder( + total: number, + parts: number[], + remainderIndex: number, + currency: Currency, +): number[] { + const result = [...parts]; + let othersSum = 0; + for (let i = 0; i < result.length; i++) { + if (i !== remainderIndex) othersSum += result[i]!; + } + // Never round this independently — subtraction is what makes the parts sum + // exactly to the total. + result[remainderIndex] = roundMoney(total - othersSum, currency); + return result; +} + +/** Splits `total` proportionally to `weights`. Weights need not sum to 100. */ +export function distributeByWeight( + total: number, + weights: number[], + remainderIndex: number, + currency: Currency = "DKK", +): number[] { + const weightSum = weights.reduce((a, b) => a + b, 0); + // All-zero weights would divide by zero. A zero-total purchase is legal (and + // reachable via import), so the whole amount goes to the remainder instead of + // throwing and taking the whole fold down with it. + const parts = weights.map((w, i) => + i === remainderIndex || weightSum === 0 + ? 0 + : roundMoney((total * w) / weightSum, currency), + ); + return withRemainder(total, parts, remainderIndex, currency); +} + +/** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */ +export function distributeByAmount( + total: number, + amounts: number[], + remainderIndex: number, + currency: Currency = "DKK", +): number[] { + const parts = amounts.map((a, i) => + i === remainderIndex ? 0 : roundMoney(a, currency), + ); + return withRemainder(total, parts, remainderIndex, currency); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/money.test.ts` +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/money.ts src/domain/money.test.ts +git commit -m "feat(domain): add money arithmetic with exact distribution + +Distribution computes the remainder part by subtraction so float splits +always sum exactly to the total." +``` + +--- + +### Task 4: Month arithmetic + +**Files:** +- Create: `src/domain/months.ts` +- Test: `src/domain/months.test.ts` + +**Interfaces:** +- Consumes: `MonthId`, `IsoDate` from `./types.ts`. +- Produces: + - `monthOf(date: IsoDate): MonthId` + - `nextMonth(id: MonthId): MonthId` + - `prevMonth(id: MonthId): MonthId` + - `compareMonths(a: MonthId, b: MonthId): number` + - `monthRange(from: MonthId, to: MonthId): MonthId[]` + - `monthsOfYear(year: number): MonthId[]` + - `addMonths(id: MonthId, count: number): MonthId` + - `yearOf(id: MonthId): number` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/months.test.ts +import { test, expect, describe } from "bun:test"; +import { + monthOf, + nextMonth, + prevMonth, + compareMonths, + monthRange, + monthsOfYear, + addMonths, + yearOf, +} from "./months.ts"; + +test("monthOf extracts the month from an ISO date", () => { + expect(monthOf("2026-09-14")).toBe("2026-09"); +}); + +describe("month stepping", () => { + test("nextMonth crosses the year boundary", () => { + expect(nextMonth("2026-11")).toBe("2026-12"); + expect(nextMonth("2026-12")).toBe("2027-01"); + }); + + test("prevMonth crosses the year boundary", () => { + expect(prevMonth("2026-01")).toBe("2025-12"); + }); + + test("addMonths handles multi-year spans in both directions", () => { + expect(addMonths("2026-09", 6)).toBe("2027-03"); + expect(addMonths("2026-09", -12)).toBe("2025-09"); + expect(addMonths("2026-09", 0)).toBe("2026-09"); + }); +}); + +test("compareMonths orders chronologically", () => { + expect(compareMonths("2026-01", "2026-02")).toBeLessThan(0); + expect(compareMonths("2027-01", "2026-12")).toBeGreaterThan(0); + expect(compareMonths("2026-05", "2026-05")).toBe(0); +}); + +describe("monthRange", () => { + test("is inclusive of both ends", () => { + expect(monthRange("2026-11", "2027-01")).toEqual([ + "2026-11", + "2026-12", + "2027-01", + ]); + }); + + test("returns a single month when both ends match", () => { + expect(monthRange("2026-03", "2026-03")).toEqual(["2026-03"]); + }); + + test("returns empty when `to` precedes `from`", () => { + expect(monthRange("2026-05", "2026-04")).toEqual([]); + }); +}); + +test("monthsOfYear returns twelve padded months", () => { + const months = monthsOfYear(2026); + expect(months).toHaveLength(12); + expect(months[0]).toBe("2026-01"); + expect(months[11]).toBe("2026-12"); +}); + +test("yearOf parses the year", () => { + expect(yearOf("2026-09")).toBe(2026); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/months.test.ts` +Expected: FAIL — cannot resolve `./months.ts`. + +- [ ] **Step 3: Implement** + +Arithmetic is done on a month ordinal rather than with `Date`, which keeps it timezone-proof. + +```ts +// src/domain/months.ts +import type { IsoDate, MonthId } from "./types.ts"; + +function parse(id: MonthId): { year: number; month: number } { + const match = /^(\d{4})-(\d{2})$/.exec(id); + if (!match) throw new Error(`Invalid MonthId: ${id}`); + return { year: Number(match[1]), month: Number(match[2]) }; +} + +function format(year: number, month: number): MonthId { + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`; +} + +/** Months since year 0. Avoids Date and therefore avoids timezones entirely. */ +function toOrdinal(id: MonthId): number { + const { year, month } = parse(id); + return year * 12 + (month - 1); +} + +function fromOrdinal(ordinal: number): MonthId { + return format(Math.floor(ordinal / 12), (ordinal % 12) + 1); +} + +export function monthOf(date: IsoDate): MonthId { + const match = /^(\d{4})-(\d{2})/.exec(date); + if (!match) throw new Error(`Invalid IsoDate: ${date}`); + return `${match[1]}-${match[2]}`; +} + +export function yearOf(id: MonthId): number { + return parse(id).year; +} + +export function addMonths(id: MonthId, count: number): MonthId { + return fromOrdinal(toOrdinal(id) + count); +} + +export function nextMonth(id: MonthId): MonthId { + return addMonths(id, 1); +} + +export function prevMonth(id: MonthId): MonthId { + return addMonths(id, -1); +} + +export function compareMonths(a: MonthId, b: MonthId): number { + return toOrdinal(a) - toOrdinal(b); +} + +export function monthRange(from: MonthId, to: MonthId): MonthId[] { + const start = toOrdinal(from); + const end = toOrdinal(to); + const out: MonthId[] = []; + for (let i = start; i <= end; i++) out.push(fromOrdinal(i)); + return out; +} + +export function monthsOfYear(year: number): MonthId[] { + return monthRange(format(year, 1), format(year, 12)); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/months.test.ts` +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/months.ts src/domain/months.test.ts +git commit -m "feat(domain): add MonthId arithmetic + +Uses a month ordinal rather than Date, so month stepping is timezone-proof." +``` + +--- + +### Task 5: Currency conversion + +**Files:** +- Create: `src/domain/fx.ts` +- Test: `src/domain/fx.test.ts` + +**Interfaces:** +- Consumes: `Currency`, `Money`, `FxRate` from `./types.ts`; `roundMoney` from `./money.ts`. +- Produces: + - `toBase(value: Money, baseCurrency: Currency, rates: FxRate[]): number` + - `fromBase(amount: number, target: Currency, baseCurrency: Currency, rates: FxRate[]): number` + - `MissingRateError` (an `Error` subclass with a `currency` property) + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/fx.test.ts +import { test, expect, describe } from "bun:test"; +import { toBase, fromBase, MissingRateError } from "./fx.ts"; +import type { FxRate } from "./types.ts"; + +const rates: FxRate[] = [ + { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, + { currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" }, +]; + +describe("toBase", () => { + test("passes base-currency values through untouched", () => { + expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100); + }); + + test("converts using baseUnitsPerOne", () => { + expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6); + }); + + test("rounds the conversion result", () => { + expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98); + }); + + test("throws MissingRateError naming the currency", () => { + expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow( + MissingRateError, + ); + try { + toBase({ amount: 1, currency: "USD" }, "DKK", []); + } catch (error) { + expect((error as MissingRateError).currency).toBe("USD"); + } + }); +}); + +describe("fromBase", () => { + test("passes base through untouched", () => { + expect(fromBase(100, "DKK", "DKK", rates)).toBe(100); + }); + + test("inverts the conversion", () => { + expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/fx.test.ts` +Expected: FAIL — cannot resolve `./fx.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/fx.ts +import { roundMoney } from "./money.ts"; +import type { Currency, FxRate, Money } from "./types.ts"; + +export class MissingRateError extends Error { + constructor(public readonly currency: Currency) { + super(`No exchange rate configured for ${currency}`); + this.name = "MissingRateError"; + } +} + +function rateFor(currency: Currency, rates: FxRate[]): number { + const found = rates.find((r) => r.currency === currency); + if (!found) throw new MissingRateError(currency); + return found.baseUnitsPerOne; +} + +/** Converts a Money value into the base currency, rounded. */ +export function toBase( + value: Money, + baseCurrency: Currency, + rates: FxRate[], +): number { + if (value.currency === baseCurrency) { + return roundMoney(value.amount, baseCurrency); + } + return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency); +} + +/** Converts a base-currency amount into `target`, rounded. */ +export function fromBase( + amount: number, + target: Currency, + baseCurrency: Currency, + rates: FxRate[], +): number { + if (target === baseCurrency) return roundMoney(amount, target); + return roundMoney(amount / rateFor(target, rates), target); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/fx.test.ts` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/fx.ts src/domain/fx.test.ts +git commit -m "feat(domain): add currency conversion + +Missing rates throw MissingRateError rather than silently treating the +value as base currency." +``` + +--- + +### Task 6: Allocation resolution + +**Files:** +- Create: `src/domain/allocation.ts` +- Test: `src/domain/allocation.test.ts` + +**Interfaces:** +- Consumes: `Dataset`, `Month`, `MonthId`, `Post`, `PostId`, `Rule`, `Settings`, `FxRate` from `./types.ts`; `toBase` from `./fx.ts`; `roundMoney` from `./money.ts`. +- Produces: + - `resolveRule(rule: Rule, income: number, baseCurrency: Currency, rates: FxRate[]): number` + - `ruleForPost(month: Month | undefined, post: Post): Rule` + - `isOverridden(month: Month | undefined, postId: PostId): boolean` + - `allocationFor(dataset: Dataset, postId: PostId, monthId: MonthId): number` + - `incomeFor(dataset: Dataset, monthId: MonthId): number` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/allocation.test.ts +import { test, expect, describe } from "bun:test"; +import { + resolveRule, + ruleForPost, + isOverridden, + allocationFor, + incomeFor, +} from "./allocation.ts"; +import type { Dataset, FxRate, Post } from "./types.ts"; + +const rates: FxRate[] = [ + { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, + { currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" }, +]; + +function post(overrides: Partial = {}): Post { + return { + id: "p1", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } }, + ...overrides, + }; +} + +function dataset(overrides: Partial = {}): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: rates, + posts: [post()], + months: [ + { id: "2026-09", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, + ], + purchases: [], + ...overrides, + }; +} + +describe("resolveRule", () => { + test("a fixed rule in base currency is its amount", () => { + expect( + resolveRule({ kind: "fixed", amount: { amount: 400, currency: "DKK" } }, 20000, "DKK", rates), + ).toBe(400); + }); + + test("a fixed rule in a foreign currency converts to base", () => { + expect( + resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates), + ).toBe(373); + }); + + test("a percentage rule resolves against income", () => { + expect(resolveRule({ kind: "percentOfIncome", percent: 25 }, 20000, "DKK", rates)).toBe(5000); + }); + + test("a percentage rule rounds", () => { + expect(resolveRule({ kind: "percentOfIncome", percent: 33 }, 1000.5, "DKK", rates)).toBe(330.17); + }); + + test("a percentage above 100 is permitted", () => { + expect(resolveRule({ kind: "percentOfIncome", percent: 150 }, 1000, "DKK", rates)).toBe(1500); + }); +}); + +describe("override precedence", () => { + test("a month override wins over the standing rule", () => { + const data = dataset({ + months: [ + { + id: "2026-09", + income: { amount: 20000, currency: "DKK" }, + ruleOverrides: { p1: { kind: "fixed", amount: { amount: 999, currency: "DKK" } } }, + }, + ], + }); + expect(allocationFor(data, "p1", "2026-09")).toBe(999); + expect(isOverridden(data.months[0], "p1")).toBe(true); + }); + + test("without an override the standing rule applies", () => { + const data = dataset(); + expect(allocationFor(data, "p1", "2026-09")).toBe(400); + expect(isOverridden(data.months[0], "p1")).toBe(false); + }); + + test("ruleForPost falls back to the standing rule when the month is unknown", () => { + expect(ruleForPost(undefined, post())).toEqual({ + kind: "fixed", + amount: { amount: 400, currency: "DKK" }, + }); + }); +}); + +describe("incomeFor", () => { + test("returns the month's income", () => { + expect(incomeFor(dataset(), "2026-09")).toBe(20000); + }); + + test("a month with no record has zero income", () => { + expect(incomeFor(dataset(), "2026-10")).toBe(0); + }); +}); + +test("a percentage allocation in a month with no record is zero", () => { + const data = dataset({ + posts: [post({ standingRule: { kind: "percentOfIncome", percent: 25 } })], + }); + expect(allocationFor(data, "p1", "2026-10")).toBe(0); +}); + +test("an unknown post id throws", () => { + expect(() => allocationFor(dataset(), "nope", "2026-09")).toThrow(/nope/); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/allocation.test.ts` +Expected: FAIL — cannot resolve `./allocation.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/allocation.ts +import { toBase } from "./fx.ts"; +import { roundMoney } from "./money.ts"; +import type { + Currency, + Dataset, + FxRate, + Month, + MonthId, + Post, + PostId, + Rule, +} from "./types.ts"; + +/** Resolves a rule to a base-currency amount. Percentages may exceed 100. */ +export function resolveRule( + rule: Rule, + income: number, + baseCurrency: Currency, + rates: FxRate[], +): number { + if (rule.kind === "fixed") { + return toBase(rule.amount, baseCurrency, rates); + } + return roundMoney((income * rule.percent) / 100, baseCurrency); +} + +export function ruleForPost(month: Month | undefined, post: Post): Rule { + return month?.ruleOverrides[post.id] ?? post.standingRule; +} + +export function isOverridden(month: Month | undefined, postId: PostId): boolean { + return month?.ruleOverrides[postId] !== undefined; +} + +export function incomeFor(dataset: Dataset, monthId: MonthId): number { + const month = dataset.months.find((m) => m.id === monthId); + if (!month) return 0; + return toBase(month.income, dataset.settings.baseCurrency, dataset.fxRates); +} + +export function allocationFor( + dataset: Dataset, + postId: PostId, + monthId: MonthId, +): number { + const post = dataset.posts.find((p) => p.id === postId); + if (!post) throw new Error(`Unknown post: ${postId}`); + const month = dataset.months.find((m) => m.id === monthId); + return resolveRule( + ruleForPost(month, post), + incomeFor(dataset, monthId), + dataset.settings.baseCurrency, + dataset.fxRates, + ); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/allocation.test.ts` +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/allocation.ts src/domain/allocation.test.ts +git commit -m "feat(domain): resolve allocation rules + +Month overrides win over standing rules; percentages above 100 are allowed." +``` + +--- + +### Task 7: Charges — slices distributed across posts + +This is the task where splits and finance plans compose. The key idea: first +work out how the **whole** purchase divides across posts, then distribute each +slice proportionally to that division. Both `splitMode` values therefore +collapse to one code path per slice, and a financed split purchase needs no +special case. + +**Files:** +- Create: `src/domain/charges.ts` +- Test: `src/domain/charges.test.ts` + +**Interfaces:** +- Consumes: `Dataset`, `Purchase`, `MonthId`, `PostId`, `Currency`, `FxRate`, `Money` from `./types.ts`; `distributeByWeight`, `distributeByAmount`, `roundMoney` from `./money.ts`; `toBase` from `./fx.ts`; `monthOf`, `compareMonths` from `./months.ts`. +- Produces: + - `type Charge = { postId: PostId; amount: number }` (amount in base currency) + - `splitPartsOfTotal(purchase: Purchase): number[]` (purchase currency, sums exactly to total) + - `sliceAmountForMonth(purchase: Purchase, monthId: MonthId): Money | null` + - `chargesForPurchaseInMonth(purchase, monthId, baseCurrency, rates): Charge[]` + - `chargesForMonth(dataset: Dataset, monthId: MonthId): Map` + - `remainderIndexOf(purchase: Purchase): number` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/charges.test.ts +import { test, expect, describe } from "bun:test"; +import { + splitPartsOfTotal, + sliceAmountForMonth, + chargesForPurchaseInMonth, + chargesForMonth, +} from "./charges.ts"; +import { roundMoney } from "./money.ts"; +import type { Dataset, FxRate, Purchase } from "./types.ts"; + +const rates: FxRate[] = [ + { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, + { currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" }, +]; + +function purchase(overrides: Partial = {}): Purchase { + return { + id: "x1", + date: "2026-09-14", + description: "Groceries", + total: { amount: 200, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "food", value: 100, absorbsRemainder: true }], + schedule: null, + ...overrides, + }; +} + +describe("splitPartsOfTotal", () => { + test("a single split takes the whole total", () => { + expect(splitPartsOfTotal(purchase())).toEqual([200]); + }); + + test("percent mode divides proportionally", () => { + const parts = splitPartsOfTotal( + purchase({ + splits: [ + { postId: "food", value: 60, absorbsRemainder: true }, + { postId: "events", value: 40, absorbsRemainder: false }, + ], + }), + ); + expect(parts).toEqual([120, 80]); + }); + + test("fixed mode uses the given amounts, remainder absorbing the shortfall", () => { + const parts = splitPartsOfTotal( + purchase({ + splitMode: "fixed", + splits: [ + { postId: "food", value: 120, absorbsRemainder: false }, + { postId: "events", value: 50, absorbsRemainder: true }, + ], + }), + ); + expect(parts).toEqual([120, 80]); + }); + + test("three-way percent splits sum exactly to the total", () => { + const parts = splitPartsOfTotal( + purchase({ + total: { amount: 100, currency: "DKK" }, + splits: [ + { postId: "a", value: 33.33, absorbsRemainder: false }, + { postId: "b", value: 33.33, absorbsRemainder: false }, + { postId: "c", value: 33.34, absorbsRemainder: true }, + ], + }), + ); + expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100); + }); +}); + +describe("sliceAmountForMonth", () => { + test("an unscheduled purchase charges its whole total in the month of its date", () => { + expect(sliceAmountForMonth(purchase(), "2026-09")).toEqual({ + amount: 200, + currency: "DKK", + }); + }); + + test("an unscheduled purchase charges nothing in other months", () => { + expect(sliceAmountForMonth(purchase(), "2026-10")).toBeNull(); + }); + + test("a scheduled purchase charges only its slice for that month", () => { + const financed = purchase({ + total: { amount: 3000, currency: "DKK" }, + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 500, currency: "DKK" } }, + { month: "2026-12", amount: { amount: 1500, currency: "DKK" } }, + ], + }, + }); + expect(sliceAmountForMonth(financed, "2026-11")).toEqual({ + amount: 500, + currency: "DKK", + }); + expect(sliceAmountForMonth(financed, "2026-09")).toBeNull(); + }); + + test("cancellation drops the cancelled month and every later one", () => { + const cancelled = purchase({ + total: { amount: 3000, currency: "DKK" }, + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-12", amount: { amount: 1000, currency: "DKK" } }, + ], + cancelledFromMonth: "2026-11", + }, + }); + expect(sliceAmountForMonth(cancelled, "2026-10")).not.toBeNull(); + expect(sliceAmountForMonth(cancelled, "2026-11")).toBeNull(); + expect(sliceAmountForMonth(cancelled, "2026-12")).toBeNull(); + }); +}); + +describe("chargesForPurchaseInMonth", () => { + test("a simple purchase charges one post", () => { + expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([ + { postId: "food", amount: 200 }, + ]); + }); + + test("converts a foreign-currency purchase into base", () => { + const eur = purchase({ total: { amount: 10, currency: "EUR" } }); + expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([ + { postId: "food", amount: 74.6 }, + ]); + }); + + test("a financed split purchase divides each slice by the split ratio", () => { + const financedSplit = purchase({ + total: { amount: 3000, currency: "DKK" }, + splits: [ + { postId: "games", value: 70, absorbsRemainder: true }, + { postId: "events", value: 30, absorbsRemainder: false }, + ], + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 2000, currency: "DKK" } }, + ], + }, + }); + expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([ + { postId: "games", amount: 700 }, + { postId: "events", amount: 300 }, + ]); + expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([ + { postId: "games", amount: 1400 }, + { postId: "events", amount: 600 }, + ]); + }); + + test("a financed fixed-mode split scales the fixed amounts down per slice", () => { + const financed = purchase({ + total: { amount: 3000, currency: "DKK" }, + splitMode: "fixed", + splits: [ + { postId: "games", value: 2000, absorbsRemainder: true }, + { postId: "events", value: 1000, absorbsRemainder: false }, + ], + schedule: { + slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }], + }, + }); + expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([ + { postId: "games", amount: 400 }, + { postId: "events", amount: 200 }, + ]); + }); + + test("charges in a month the purchase does not touch are empty", () => { + expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]); + }); + + test("slice charges always sum to the slice total", () => { + const odd = purchase({ + total: { amount: 100, currency: "DKK" }, + splits: [ + { postId: "a", value: 1, absorbsRemainder: false }, + { postId: "b", value: 1, absorbsRemainder: false }, + { postId: "c", value: 1, absorbsRemainder: true }, + ], + }); + const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates); + expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100); + }); +}); + +describe("chargesForMonth", () => { + test("sums charges per post across all purchases", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: rates, + posts: [], + months: [], + purchases: [ + purchase({ id: "a" }), + purchase({ id: "b", total: { amount: 50, currency: "DKK" } }), + purchase({ id: "c", date: "2026-10-01" }), + ], + }; + const charges = chargesForMonth(data, "2026-09"); + expect(charges.get("food")).toBe(250); + expect(charges.size).toBe(1); + }); + + test("a month with no purchases yields an empty map", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: rates, + posts: [], + months: [], + purchases: [], + }; + expect(chargesForMonth(data, "2026-09").size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/charges.test.ts` +Expected: FAIL — cannot resolve `./charges.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/charges.ts +import { toBase } from "./fx.ts"; +import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts"; +import { compareMonths, monthOf } from "./months.ts"; +import type { + Currency, + Dataset, + FxRate, + Money, + MonthId, + PostId, + Purchase, +} from "./types.ts"; + +export interface Charge { + postId: PostId; + /** Base currency. */ + amount: number; +} + +export function remainderIndexOf(purchase: Purchase): number { + const index = purchase.splits.findIndex((s) => s.absorbsRemainder); + if (index === -1) { + throw new Error( + `Purchase ${purchase.id} has no split flagged absorbsRemainder`, + ); + } + return index; +} + +/** + * How the whole purchase divides across its posts, in the purchase's own + * currency. Parts sum exactly to the total. + */ +export function splitPartsOfTotal(purchase: Purchase): number[] { + const index = remainderIndexOf(purchase); + const values = purchase.splits.map((s) => s.value); + const total = purchase.total.amount; + const currency = purchase.total.currency; + return purchase.splitMode === "percent" + ? distributeByWeight(total, values, index, currency) + : distributeByAmount(total, values, index, currency); +} + +/** + * The amount charged in `monthId`, in the purchase's currency, or null if the + * purchase does not touch that month. + */ +export function sliceAmountForMonth( + purchase: Purchase, + monthId: MonthId, +): Money | null { + if (!purchase.schedule) { + return monthOf(purchase.date) === monthId ? purchase.total : null; + } + const { slices, cancelledFromMonth } = purchase.schedule; + if (cancelledFromMonth && compareMonths(monthId, cancelledFromMonth) >= 0) { + return null; + } + const slice = slices.find((s) => s.month === monthId); + return slice ? slice.amount : null; +} + +/** + * Distributes this month's slice across the purchase's posts, in base currency. + * + * Weights are the whole-purchase split parts, so a financed purchase divides + * each slice in the same proportion as the purchase as a whole — which is what + * makes splits and finance plans compose without a special case. + */ +export function chargesForPurchaseInMonth( + purchase: Purchase, + monthId: MonthId, + baseCurrency: Currency, + rates: FxRate[], +): Charge[] { + const slice = sliceAmountForMonth(purchase, monthId); + if (!slice) return []; + + const sliceBase = toBase(slice, baseCurrency, rates); + const index = remainderIndexOf(purchase); + const weights = splitPartsOfTotal(purchase); + const parts = distributeByWeight(sliceBase, weights, index, baseCurrency); + + return purchase.splits.map((split, i) => ({ + postId: split.postId, + amount: parts[i]!, + })); +} + +/** Total charged per post in `monthId`, in base currency. */ +export function chargesForMonth( + dataset: Dataset, + monthId: MonthId, +): Map { + const totals = new Map(); + for (const purchase of dataset.purchases) { + const charges = chargesForPurchaseInMonth( + purchase, + monthId, + dataset.settings.baseCurrency, + dataset.fxRates, + ); + for (const charge of charges) { + const previous = totals.get(charge.postId) ?? 0; + totals.set( + charge.postId, + roundMoney(previous + charge.amount, dataset.settings.baseCurrency), + ); + } + } + return totals; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/charges.test.ts` +Expected: PASS, 15 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/charges.ts src/domain/charges.test.ts +git commit -m "feat(domain): distribute purchase slices across posts + +Slices are weighted by the whole-purchase split parts, so financed split +purchases need no special case and parts always sum to the slice total." +``` + +--- + +### Task 8: The rollover fold + +**Files:** +- Create: `src/domain/fold.ts` +- Test: `src/domain/fold.test.ts` + +**Interfaces:** +- Consumes: `Dataset`, `MonthId`, `PostId` from `./types.ts`; `allocationFor` from `./allocation.ts`; `chargesForMonth` from `./charges.ts`; `monthRange`, `compareMonths` from `./months.ts`; `roundMoney` from `./money.ts`. +- Produces: + - `interface PostMonthFigures { carriedIn: number; allocation: number; charges: number; remaining: number }` + - `type Fold = Map>` + - `foldBalances(dataset: Dataset, upToMonth: MonthId): Fold` + - `figuresFor(fold: Fold, postId: PostId, monthId: MonthId): PostMonthFigures` + - `EMPTY_FIGURES: PostMonthFigures` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/fold.test.ts +import { test, expect, describe } from "bun:test"; +import { foldBalances, figuresFor } from "./fold.ts"; +import type { Dataset, Post, Purchase } from "./types.ts"; + +function post(id: string, fixed: number, archived = false): Post { + return { + id, + name: id, + order: 0, + archived, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } }, + }; +} + +function spend(id: string, postId: string, amount: number, date: string): Purchase { + return { + id, + date, + description: id, + total: { amount, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId, value: 100, absorbsRemainder: true }], + schedule: null, + }; +} + +function dataset(posts: Post[], purchases: Purchase[]): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: [], + posts, + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-02", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + ], + purchases, + }; +} + +describe("envelope rollover", () => { + test("the first month carries in zero", () => { + const fold = foldBalances(dataset([post("food", 500)], []), "2026-01"); + expect(figuresFor(fold, "food", "2026-01")).toEqual({ + carriedIn: 0, + allocation: 500, + charges: 0, + remaining: 500, + }); + }); + + test("unspent allocation carries into the next month", () => { + const data = dataset([post("food", 500)], [spend("a", "food", 300, "2026-01-05")]); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(200); + expect(figuresFor(fold, "food", "2026-02")).toEqual({ + carriedIn: 200, + allocation: 500, + charges: 0, + remaining: 700, + }); + }); + + test("overspend carries forward as debt", () => { + const data = dataset([post("food", 500)], [spend("a", "food", 700, "2026-01-05")]); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(-200); + expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(-200); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(300); + }); + + test("the chain accumulates across three months", () => { + const data = dataset( + [post("food", 500)], + [spend("a", "food", 100, "2026-01-05"), spend("b", "food", 900, "2026-02-05")], + ); + const fold = foldBalances(data, "2026-03"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(400); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(0); + expect(figuresFor(fold, "food", "2026-03").remaining).toBe(500); + }); + + test("editing an early purchase changes every later month", () => { + const before = foldBalances( + dataset([post("food", 500)], [spend("a", "food", 100, "2026-01-05")]), + "2026-03", + ); + const after = foldBalances( + dataset([post("food", 500)], [spend("a", "food", 200, "2026-01-05")]), + "2026-03", + ); + expect(figuresFor(before, "food", "2026-03").remaining).toBe(1400); + expect(figuresFor(after, "food", "2026-03").remaining).toBe(1300); + }); +}); + +test("archived posts are still folded", () => { + const data = dataset( + [post("food", 500), post("old", 100, true)], + [spend("a", "old", 40, "2026-01-05")], + ); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "old", "2026-02").carriedIn).toBe(60); +}); + +test("percentage allocations follow the month's income", () => { + const data: Dataset = { + ...dataset([], []), + posts: [ + { + id: "food", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + standingRule: { kind: "percentOfIncome", percent: 10 }, + }, + ], + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-02", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, + ], + }; + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").allocation).toBe(1000); + expect(figuresFor(fold, "food", "2026-02").allocation).toBe(2000); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(3000); +}); + +test("months before foldStartMonth contribute nothing", () => { + const data: Dataset = { + ...dataset([post("food", 500)], [spend("a", "food", 5000, "2025-06-01")]), + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + }; + const fold = foldBalances(data, "2026-01"); + expect(fold.has("2025-06")).toBe(false); + expect(figuresFor(fold, "food", "2026-01").carriedIn).toBe(0); +}); + +test("figuresFor returns zeroes for a month outside the fold", () => { + const fold = foldBalances(dataset([post("food", 500)], []), "2026-01"); + expect(figuresFor(fold, "food", "2030-01")).toEqual({ + carriedIn: 0, + allocation: 0, + charges: 0, + remaining: 0, + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/fold.test.ts` +Expected: FAIL — cannot resolve `./fold.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/fold.ts +import { allocationFor } from "./allocation.ts"; +import { chargesForMonth } from "./charges.ts"; +import { roundMoney } from "./money.ts"; +import { compareMonths, monthRange } from "./months.ts"; +import type { Dataset, MonthId, PostId } from "./types.ts"; + +export interface PostMonthFigures { + carriedIn: number; + allocation: number; + charges: number; + /** carriedIn + allocation - charges. Negative means overspent. */ + remaining: number; +} + +export const EMPTY_FIGURES: PostMonthFigures = { + carriedIn: 0, + allocation: 0, + charges: 0, + remaining: 0, +}; + +export type Fold = Map>; + +/** + * The single implementation of envelope rollover. Year and summary views + * aggregate over this — they never recompute the math. + * + * Unspent allocation carries into the next month; overspend carries forward as + * a negative balance. Archived posts are included, since their history still + * has to fold. + */ +export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { + const { foldStartMonth, baseCurrency } = dataset.settings; + const fold: Fold = new Map(); + + if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; + + const carried = new Map(); + + for (const monthId of monthRange(foldStartMonth, upToMonth)) { + const charges = chargesForMonth(dataset, monthId); + const monthFigures = new Map(); + + for (const post of dataset.posts) { + const carriedIn = carried.get(post.id) ?? 0; + const allocation = allocationFor(dataset, post.id, monthId); + const spent = charges.get(post.id) ?? 0; + const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency); + + monthFigures.set(post.id, { + carriedIn, + allocation, + charges: spent, + remaining, + }); + carried.set(post.id, remaining); + } + + fold.set(monthId, monthFigures); + } + + return fold; +} + +export function figuresFor( + fold: Fold, + postId: PostId, + monthId: MonthId, +): PostMonthFigures { + return fold.get(monthId)?.get(postId) ?? EMPTY_FIGURES; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/fold.test.ts` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/domain/fold.ts src/domain/fold.test.ts +git commit -m "feat(domain): add the envelope rollover fold + +Unspent carries forward, overspend carries as debt. This is the single +implementation of the rollover math." +``` + +--- + +### Task 9: View models + +**Files:** +- Create: `src/domain/views.ts` +- Test: `src/domain/views.test.ts` + +**Interfaces:** +- Consumes: `Dataset`, `MonthId`, `Post` from `./types.ts`; `foldBalances`, `figuresFor`, `EMPTY_FIGURES`, `PostMonthFigures`, `Fold` from `./fold.ts`; `incomeFor`, `isOverridden` from `./allocation.ts`; `monthsOfYear`, `monthRange` from `./months.ts`; `roundMoney` from `./money.ts`. +- Produces: + - `interface MonthPostRow { post: Post; figures: PostMonthFigures; overridden: boolean }` + - `interface MonthViewModel { monthId; income; totalAllocation; totalCharges; unallocated; rows: MonthPostRow[] }` + - `interface YearPostRow { post: Post; byMonth: PostMonthFigures[]; totalAllocation; totalCharges; closingBalance }` + - `interface YearViewModel { year; months: MonthId[]; incomeByMonth: number[]; totalIncome; totalCharges; rows: YearPostRow[] }` + - `interface SummaryViewModel { from; to; byPost; byMonth; totalCharges; totalIncome }` + - `monthView(dataset, monthId): MonthViewModel` + - `yearView(dataset, year): YearViewModel` + - `summaryView(dataset, from, to): SummaryViewModel` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/views.test.ts +import { test, expect, describe } from "bun:test"; +import { monthView, yearView, summaryView } from "./views.ts"; +import type { Dataset, Post, Purchase } from "./types.ts"; + +function post(id: string, order: number, percent: number, archived = false): Post { + return { + id, + name: id, + order, + archived, + currency: "DKK", + standingRule: { kind: "percentOfIncome", percent }, + }; +} + +function spend(id: string, postId: string, amount: number, date: string): Purchase { + return { + id, + date, + description: id, + total: { amount, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId, value: 100, absorbsRemainder: true }], + schedule: null, + }; +} + +const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: [], + posts: [post("food", 0, 20), post("games", 1, 10)], + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { + id: "2026-02", + income: { amount: 10000, currency: "DKK" }, + ruleOverrides: { games: { kind: "fixed", amount: { amount: 2000, currency: "DKK" } } }, + }, + ], + purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")], +}; + +describe("monthView", () => { + test("reports income, allocations, and the unallocated remainder", () => { + const view = monthView(data, "2026-01"); + expect(view.income).toBe(10000); + expect(view.totalAllocation).toBe(3000); + expect(view.totalCharges).toBe(500); + expect(view.unallocated).toBe(7000); + }); + + test("rows are ordered by post order and carry their figures", () => { + const view = monthView(data, "2026-01"); + expect(view.rows.map((r) => r.post.id)).toEqual(["food", "games"]); + expect(view.rows[0]!.figures.charges).toBe(500); + expect(view.rows[0]!.figures.remaining).toBe(1500); + }); + + test("flags rows whose rule is overridden this month", () => { + const view = monthView(data, "2026-02"); + expect(view.rows.find((r) => r.post.id === "games")!.overridden).toBe(true); + expect(view.rows.find((r) => r.post.id === "food")!.overridden).toBe(false); + }); + + test("unallocated goes negative when percentages exceed income", () => { + const greedy: Dataset = { ...data, posts: [post("food", 0, 80), post("games", 1, 40)] }; + expect(monthView(greedy, "2026-01").unallocated).toBe(-2000); + }); + + test("archived posts appear only when they have activity", () => { + const withArchived: Dataset = { + ...data, + posts: [...data.posts, post("old", 2, 0, true)], + }; + expect(monthView(withArchived, "2026-01").rows.map((r) => r.post.id)).toEqual([ + "food", + "games", + ]); + + const withActivity: Dataset = { + ...withArchived, + purchases: [...data.purchases, spend("c", "old", 50, "2026-01-11")], + }; + expect(monthView(withActivity, "2026-01").rows.map((r) => r.post.id)).toEqual([ + "food", + "games", + "old", + ]); + }); +}); + +describe("yearView", () => { + test("returns twelve months with per-post totals and a closing balance", () => { + const view = yearView(data, 2026); + expect(view.months).toHaveLength(12); + expect(view.incomeByMonth[0]).toBe(10000); + expect(view.incomeByMonth[2]).toBe(0); + expect(view.totalIncome).toBe(20000); + + const food = view.rows.find((r) => r.post.id === "food")!; + expect(food.byMonth).toHaveLength(12); + expect(food.totalCharges).toBe(500); + expect(food.totalAllocation).toBe(4000); + expect(food.closingBalance).toBe(3500); + }); + + test("the closing balance is December's remaining, carrying all year", () => { + const games = yearView(data, 2026).rows.find((r) => r.post.id === "games")!; + expect(games.totalAllocation).toBe(3000); + expect(games.closingBalance).toBe(2700); + }); +}); + +describe("summaryView", () => { + test("totals charges by post and by month across a range", () => { + const view = summaryView(data, "2026-01", "2026-02"); + expect(view.totalCharges).toBe(800); + expect(view.totalIncome).toBe(20000); + expect(view.byPost.find((r) => r.post.id === "food")!.charges).toBe(500); + expect(view.byMonth).toHaveLength(2); + expect(view.byMonth[1]!.charges).toBe(300); + }); + + test("an empty range totals zero", () => { + const view = summaryView(data, "2026-05", "2026-04"); + expect(view.totalCharges).toBe(0); + expect(view.byMonth).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/views.test.ts` +Expected: FAIL — cannot resolve `./views.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/views.ts +import { incomeFor, isOverridden } from "./allocation.ts"; +import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts"; +import { roundMoney } from "./money.ts"; +import { monthRange, monthsOfYear } from "./months.ts"; +import type { Dataset, MonthId, Post } from "./types.ts"; + +export interface MonthPostRow { + post: Post; + figures: PostMonthFigures; + overridden: boolean; +} + +export interface MonthViewModel { + monthId: MonthId; + income: number; + totalAllocation: number; + totalCharges: number; + /** income - totalAllocation. Negative when allocations exceed income. */ + unallocated: number; + rows: MonthPostRow[]; +} + +function hasActivity(figures: PostMonthFigures): boolean { + return ( + figures.carriedIn !== 0 || figures.allocation !== 0 || figures.charges !== 0 + ); +} + +function visiblePosts(dataset: Dataset): Post[] { + return [...dataset.posts].sort((a, b) => a.order - b.order); +} + +export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { + const fold = foldBalances(dataset, monthId); + const month = dataset.months.find((m) => m.id === monthId); + const income = incomeFor(dataset, monthId); + + const rows: MonthPostRow[] = []; + for (const post of visiblePosts(dataset)) { + const figures = figuresFor(fold, post.id, monthId); + // Archived posts stay visible only while they still have activity. + if (post.archived && !hasActivity(figures)) continue; + rows.push({ post, figures, overridden: isOverridden(month, post.id) }); + } + + const base = dataset.settings.baseCurrency; + const totalAllocation = roundMoney( + rows.reduce((sum, r) => sum + r.figures.allocation, 0), + base, + ); + const totalCharges = roundMoney( + rows.reduce((sum, r) => sum + r.figures.charges, 0), + base, + ); + + return { + monthId, + income, + totalAllocation, + totalCharges, + unallocated: roundMoney(income - totalAllocation, base), + rows, + }; +} + +export interface YearPostRow { + post: Post; + /** Twelve entries, January to December. */ + byMonth: PostMonthFigures[]; + totalAllocation: number; + totalCharges: number; + /** December's remaining balance. */ + closingBalance: number; +} + +export interface YearViewModel { + year: number; + months: MonthId[]; + incomeByMonth: number[]; + totalIncome: number; + totalCharges: number; + rows: YearPostRow[]; +} + +export function yearView(dataset: Dataset, year: number): YearViewModel { + const months = monthsOfYear(year); + const fold = foldBalances(dataset, months[11]!); + const base = dataset.settings.baseCurrency; + + const incomeByMonth = months.map((m) => incomeFor(dataset, m)); + + const rows: YearPostRow[] = visiblePosts(dataset).map((post) => { + const byMonth = months.map((m) => figuresFor(fold, post.id, m)); + return { + post, + byMonth, + totalAllocation: roundMoney( + byMonth.reduce((sum, f) => sum + f.allocation, 0), + base, + ), + totalCharges: roundMoney( + byMonth.reduce((sum, f) => sum + f.charges, 0), + base, + ), + closingBalance: byMonth[11]!.remaining, + }; + }); + + return { + year, + months, + incomeByMonth, + totalIncome: roundMoney( + incomeByMonth.reduce((a, b) => a + b, 0), + base, + ), + totalCharges: roundMoney( + rows.reduce((sum, r) => sum + r.totalCharges, 0), + base, + ), + rows, + }; +} + +export interface SummaryViewModel { + from: MonthId; + to: MonthId; + byPost: { post: Post; charges: number }[]; + byMonth: { monthId: MonthId; charges: number }[]; + totalCharges: number; + totalIncome: number; +} + +export function summaryView( + dataset: Dataset, + from: MonthId, + to: MonthId, +): SummaryViewModel { + const months = monthRange(from, to); + const base = dataset.settings.baseCurrency; + const fold: Fold = + months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map(); + + const byPost = visiblePosts(dataset).map((post) => ({ + post, + charges: roundMoney( + months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0), + base, + ), + })); + + const byMonth = months.map((monthId) => ({ + monthId, + charges: roundMoney( + dataset.posts.reduce( + (sum, p) => sum + figuresFor(fold, p.id, monthId).charges, + 0, + ), + base, + ), + })); + + return { + from, + to, + byPost, + byMonth, + totalCharges: roundMoney( + byMonth.reduce((sum, m) => sum + m.charges, 0), + base, + ), + totalIncome: roundMoney( + months.reduce((sum, m) => sum + incomeFor(dataset, m), 0), + base, + ), + }; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/domain/views.test.ts` +Expected: PASS, 9 tests. + +- [ ] **Step 5: Run the whole domain suite** + +Run: `bun test` +Expected: PASS, all tests. The domain core is complete. + +- [ ] **Step 6: Commit** + +```bash +git add src/domain/views.ts src/domain/views.test.ts +git commit -m "feat(domain): add month, year, and summary view models + +All three aggregate over foldBalances rather than recomputing rollover." +``` + +--- + +### Task 10: Seed dataset + +**Files:** +- Create: `src/domain/seed.ts` +- Modify: `src/domain/index.ts` (export the whole domain surface) +- Test: `src/domain/seed.test.ts` + +**Interfaces:** +- Consumes: `Dataset`, `MonthId`, `Post` from `./types.ts`. +- Produces: `SCHEMA_VERSION: number`, `createSeedDataset(startMonth: MonthId): Dataset`, `newId(): string`. + +Note the signature: `createSeedDataset` takes the month as an argument rather +than reading the clock, because the domain layer must stay pure. Callers in the +UI pass the current month in. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/domain/seed.test.ts +import { test, expect } from "bun:test"; +import { createSeedDataset, SCHEMA_VERSION, newId } from "./seed.ts"; +import { monthView } from "./views.ts"; + +test("seeds exactly the three named posts, in order", () => { + const data = createSeedDataset("2026-09"); + expect(data.posts.map((p) => p.name)).toEqual([ + "Video Games", + "Food", + "Events and Social", + ]); + expect(data.posts.map((p) => p.order)).toEqual([0, 1, 2]); +}); + +test("seeded posts are active with a zero fixed standing rule", () => { + const data = createSeedDataset("2026-09"); + for (const post of data.posts) { + expect(post.archived).toBe(false); + expect(post.standingRule).toEqual({ + kind: "fixed", + amount: { amount: 0, currency: "DKK" }, + }); + } +}); + +test("the fold starts at the given month and DKK is the base currency", () => { + const data = createSeedDataset("2026-09"); + expect(data.settings.foldStartMonth).toBe("2026-09"); + expect(data.settings.baseCurrency).toBe("DKK"); + expect(data.settings.schemaVersion).toBe(SCHEMA_VERSION); +}); + +test("seeds a month record with zero income and no purchases", () => { + const data = createSeedDataset("2026-09"); + expect(data.months.map((m) => m.id)).toEqual(["2026-09"]); + expect(data.purchases).toEqual([]); +}); + +test("the seed dataset renders a valid month view", () => { + const view = monthView(createSeedDataset("2026-09"), "2026-09"); + expect(view.rows).toHaveLength(3); + expect(view.income).toBe(0); + expect(view.unallocated).toBe(0); +}); + +test("newId returns distinct ids", () => { + expect(newId()).not.toBe(newId()); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/domain/seed.test.ts` +Expected: FAIL — cannot resolve `./seed.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/domain/seed.ts +import type { Dataset, MonthId, Post } from "./types.ts"; + +export const SCHEMA_VERSION = 1; + +const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; + +export function newId(): string { + return crypto.randomUUID(); +} + +/** + * The first-run dataset. Takes the start month as an argument because the + * domain layer must not read the ambient clock. + */ +export function createSeedDataset(startMonth: MonthId): Dataset { + const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({ + id: newId(), + name, + order, + archived: false, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } }, + })); + + return { + settings: { + baseCurrency: "DKK", + foldStartMonth: startMonth, + schemaVersion: SCHEMA_VERSION, + }, + fxRates: [], + posts, + months: [ + { id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }, + ], + purchases: [], + }; +} +``` + +- [ ] **Step 4: Replace the domain barrel** + +```ts +// src/domain/index.ts +export const DOMAIN_READY = true; + +export * from "./types.ts"; +export * from "./money.ts"; +export * from "./months.ts"; +export * from "./fx.ts"; +export * from "./allocation.ts"; +export * from "./charges.ts"; +export * from "./fold.ts"; +export * from "./views.ts"; +export * from "./seed.ts"; +``` + +- [ ] **Step 5: Run the full suite** + +Run: `bun test` +Expected: PASS, all tests including the smoke test from Task 1. + +- [ ] **Step 6: Commit** + +```bash +git add src/domain/seed.ts src/domain/seed.test.ts src/domain/index.ts +git commit -m "feat(domain): add seed dataset and domain barrel" +``` + +--- + +## Phase 2 — Persistence + +### Task 11: IndexedDB store and migrations + +The dataset is written as **one record in one object store**, not a store per +entity. Reads load everything anyway, and a single record makes every write +atomic — there is no way to persist posts without their purchases and corrupt +the fold. + +**Files:** +- Create: `src/store/db.ts`, `src/store/migrations.ts` +- Test: `src/store/migrations.test.ts` + +**Interfaces:** +- Consumes: `Dataset` from `../domain/types.ts`; `SCHEMA_VERSION` from `../domain/seed.ts`. +- Produces: + - `DB_NAME`, `STORE_NAME`, `RECORD_KEY` constants + - `openDb(): Promise` + - `readDataset(): Promise` + - `writeDataset(dataset: Dataset): Promise` + - `migrate(raw: unknown): Dataset` (from `migrations.ts`) + - `UnsupportedSchemaError` + +- [ ] **Step 1: Write the failing tests for migrations** + +Migrations are pure, so they are tested with no browser. `db.ts` itself is +exercised in the browser by Task 12's manual verification. + +```ts +// src/store/migrations.test.ts +import { test, expect, describe } from "bun:test"; +import { migrate, UnsupportedSchemaError } from "./migrations.ts"; +import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts"; + +describe("migrate", () => { + test("passes a current-version dataset through unchanged", () => { + const data = createSeedDataset("2026-09"); + expect(migrate(data)).toEqual(data); + }); + + test("rejects a version newer than this build understands", () => { + const future = { ...createSeedDataset("2026-09"), settings: { baseCurrency: "DKK", foldStartMonth: "2026-09", schemaVersion: SCHEMA_VERSION + 1 } }; + expect(() => migrate(future)).toThrow(UnsupportedSchemaError); + }); + + test("rejects a value that is not an object", () => { + expect(() => migrate("nope")).toThrow(/not a dataset/i); + expect(() => migrate(null)).toThrow(/not a dataset/i); + }); + + test("rejects a dataset with no schemaVersion", () => { + expect(() => migrate({ posts: [] })).toThrow(/schemaVersion/); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/store/migrations.test.ts` +Expected: FAIL — cannot resolve `./migrations.ts`. + +- [ ] **Step 3: Implement migrations** + +```ts +// src/store/migrations.ts +import { SCHEMA_VERSION } from "../domain/seed.ts"; +import type { Dataset } from "../domain/types.ts"; + +export class UnsupportedSchemaError extends Error { + constructor(public readonly found: number) { + super( + `Data was written by a newer version of the app (schema ${found}, this build understands ${SCHEMA_VERSION}). Update the app before opening it.`, + ); + this.name = "UnsupportedSchemaError"; + } +} + +/** + * Ordered migration steps. Each entry upgrades from its index version to the + * next. Add one whenever SCHEMA_VERSION increases; never edit an existing one, + * because users' stored data has already been through it. + */ +const MIGRATIONS: Array<(data: any) => any> = [ + // index 0: there is no version 0 in the wild yet. +]; + +export function migrate(raw: unknown): Dataset { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("Stored value is not a dataset"); + } + const data = raw as Record; + const version = data.settings?.schemaVersion; + if (typeof version !== "number") { + throw new Error("Stored dataset has no settings.schemaVersion"); + } + if (version > SCHEMA_VERSION) throw new UnsupportedSchemaError(version); + + let current = data; + for (let v = version; v < SCHEMA_VERSION; v++) { + const step = MIGRATIONS[v]; + if (!step) throw new Error(`No migration from schema ${v} to ${v + 1}`); + current = step(current); + } + return current as Dataset; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/store/migrations.test.ts` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Implement the IndexedDB wrapper** + +```ts +// src/store/db.ts +import type { Dataset } from "../domain/types.ts"; +import { migrate } from "./migrations.ts"; + +export const DB_NAME = "budget2"; +export const STORE_NAME = "state"; +export const RECORD_KEY = "dataset"; + +/** IndexedDB's own version. Bumped only when the object stores change. */ +const IDB_VERSION = 1; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export function openDb(): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DB_NAME, IDB_VERSION); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} + +export async function readDataset(): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const raw = await request(tx.objectStore(STORE_NAME).get(RECORD_KEY)); + return raw === undefined ? null : migrate(raw); + } finally { + db.close(); + } +} + +/** Writes the whole dataset in one transaction, so a write can never tear. */ +export async function writeDataset(dataset: Dataset): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + await request(tx.objectStore(STORE_NAME).put(dataset, RECORD_KEY)); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/store/db.ts src/store/migrations.ts src/store/migrations.test.ts +git commit -m "feat(store): add IndexedDB persistence and schema migrations + +The dataset is one atomic record, so a write can never leave posts and +purchases inconsistent." +``` + +--- + +### Task 12: In-memory snapshot with write-through + +**Files:** +- Create: `src/store/snapshot.ts`, `src/store/index.ts` +- Test: `src/store/snapshot.test.ts` + +**Interfaces:** +- Consumes: `Dataset` from `../domain/types.ts`; `readDataset`, `writeDataset` from `./db.ts`; `createSeedDataset` from `../domain/seed.ts`. +- Produces: + - `interface Persistence { read(): Promise; write(d: Dataset): Promise }` + - `createSnapshotStore(persistence: Persistence, currentMonth: MonthId): SnapshotStore` + - `SnapshotStore` with `load()`, `get()`, `mutate(fn)`, `replace(dataset)`, `subscribe(listener)` + - `store` — the app-wide instance, wired to `db.ts` (from `./index.ts`) + +The store takes its persistence as an argument, which is what lets it be tested +with an in-memory fake and no browser. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/store/snapshot.test.ts +import { test, expect, describe } from "bun:test"; +import { createSnapshotStore, type Persistence } from "./snapshot.ts"; +import { createSeedDataset } from "../domain/seed.ts"; +import type { Dataset } from "../domain/types.ts"; + +function fakePersistence(initial: Dataset | null = null) { + const writes: Dataset[] = []; + let stored = initial; + const persistence: Persistence = { + async read() { + return stored; + }, + async write(dataset) { + stored = dataset; + writes.push(dataset); + }, + }; + return { persistence, writes, get stored() { return stored; } }; +} + +describe("load", () => { + test("seeds and persists on first run", async () => { + const fake = fakePersistence(null); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + expect(store.get().posts).toHaveLength(3); + expect(fake.writes).toHaveLength(1); + expect(fake.stored!.settings.foldStartMonth).toBe("2026-09"); + }); + + test("uses stored data when it exists and does not rewrite it", async () => { + const existing = createSeedDataset("2025-01"); + const fake = fakePersistence(existing); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + expect(store.get().settings.foldStartMonth).toBe("2025-01"); + expect(fake.writes).toHaveLength(0); + }); +}); + +describe("mutate", () => { + test("applies the change, writes through, and notifies subscribers", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + + let notifications = 0; + store.subscribe(() => notifications++); + + await store.mutate((draft) => { + draft.months[0]!.income = { amount: 20000, currency: "DKK" }; + }); + + expect(store.get().months[0]!.income.amount).toBe(20000); + expect(fake.stored!.months[0]!.income.amount).toBe(20000); + expect(notifications).toBe(1); + }); + + test("does not mutate the previous snapshot object", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + const before = store.get(); + + await store.mutate((draft) => { + draft.posts[0]!.name = "Renamed"; + }); + + expect(before.posts[0]!.name).toBe("Video Games"); + expect(store.get().posts[0]!.name).toBe("Renamed"); + }); + + test("leaves the snapshot untouched when the write fails", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore( + { read: fake.persistence.read, write: async () => { throw new Error("disk full"); } }, + "2026-09", + ); + await store.load(); + + await expect( + store.mutate((draft) => { draft.posts[0]!.name = "Renamed"; }), + ).rejects.toThrow("disk full"); + expect(store.get().posts[0]!.name).toBe("Video Games"); + }); +}); + +test("replace swaps the whole dataset, as JSON import needs", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + + const incoming = createSeedDataset("2020-01"); + await store.replace(incoming); + expect(store.get().settings.foldStartMonth).toBe("2020-01"); + expect(fake.stored!.settings.foldStartMonth).toBe("2020-01"); +}); + +test("unsubscribe stops notifications", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + + let notifications = 0; + const unsubscribe = store.subscribe(() => notifications++); + unsubscribe(); + await store.mutate((draft) => { draft.posts[0]!.name = "X"; }); + expect(notifications).toBe(0); +}); + +test("get before load throws rather than returning empty data", () => { + const fake = fakePersistence(null); + const store = createSnapshotStore(fake.persistence, "2026-09"); + expect(() => store.get()).toThrow(/not loaded/i); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/store/snapshot.test.ts` +Expected: FAIL — cannot resolve `./snapshot.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/store/snapshot.ts +import { createSeedDataset } from "../domain/seed.ts"; +import type { Dataset, MonthId } from "../domain/types.ts"; + +export interface Persistence { + read(): Promise; + write(dataset: Dataset): Promise; +} + +export type Listener = () => void; + +export interface SnapshotStore { + load(): Promise; + get(): Dataset; + mutate(fn: (draft: Dataset) => void): Promise; + replace(dataset: Dataset): Promise; + subscribe(listener: Listener): () => void; +} + +export function createSnapshotStore( + persistence: Persistence, + currentMonth: MonthId, +): SnapshotStore { + let snapshot: Dataset | null = null; + const listeners = new Set(); + + function notify() { + for (const listener of listeners) listener(); + } + + function get(): Dataset { + if (!snapshot) throw new Error("Snapshot store is not loaded yet"); + return snapshot; + } + + async function commit(next: Dataset) { + // Write first: if persistence fails, the in-memory snapshot must not have + // moved, or the UI would show data that was never saved. + await persistence.write(next); + snapshot = next; + notify(); + } + + return { + async load() { + const stored = await persistence.read(); + if (stored) { + snapshot = stored; + notify(); + return; + } + await commit(createSeedDataset(currentMonth)); + }, + + get, + + async mutate(fn) { + const draft = structuredClone(get()); + fn(draft); + await commit(draft); + }, + + async replace(dataset) { + await commit(dataset); + }, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} +``` + +```ts +// src/store/index.ts +import { monthOf } from "../domain/months.ts"; +import { readDataset, writeDataset } from "./db.ts"; +import { createSnapshotStore } from "./snapshot.ts"; + +/** The current month, read at module load. The UI owns the clock; the domain does not. */ +export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); + +export const store = createSnapshotStore( + { read: readDataset, write: writeDataset }, + currentMonth, +); + +export * from "./snapshot.ts"; +export { UnsupportedSchemaError } from "./migrations.ts"; +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/store/snapshot.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/store/snapshot.ts src/store/index.ts src/store/snapshot.test.ts +git commit -m "feat(store): add in-memory snapshot with write-through + +Persistence is injected, so the store is testable with no browser. Writes +land before the snapshot moves, so the UI never shows unsaved data." +``` + +--- + +## Phase 3 — Export and import + +### Task 13: JSON export and import + +**Files:** +- Create: `src/export/json.ts` +- Test: `src/export/json.test.ts` + +**Interfaces:** +- Consumes: `Dataset` from `../domain/types.ts`; `migrate` from `../store/migrations.ts`; `remainderIndexOf` from `../domain/charges.ts`. +- Produces: + - `exportDatasetJson(dataset: Dataset): string` + - `parseDatasetJson(text: string): Dataset` + - `describeDataset(dataset: Dataset): { posts: number; months: number; purchases: number }` + - `ImportValidationError` + - `exportFilename(monthId: MonthId): string` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/export/json.test.ts +import { test, expect, describe } from "bun:test"; +import { + exportDatasetJson, + parseDatasetJson, + describeDataset, + ImportValidationError, + exportFilename, +} from "./json.ts"; +import { createSeedDataset } from "../domain/seed.ts"; +import { monthView, yearView } from "../domain/views.ts"; +import type { Dataset } from "../domain/types.ts"; + +function populated(): Dataset { + const data = createSeedDataset("2026-01"); + data.months[0]!.income = { amount: 20000, currency: "DKK" }; + data.purchases.push({ + id: "p1", + date: "2026-01-15", + description: "Console", + total: { amount: 3000, currency: "DKK" }, + splitMode: "percent", + splits: [ + { postId: data.posts[0]!.id, value: 70, absorbsRemainder: true }, + { postId: data.posts[2]!.id, value: 30, absorbsRemainder: false }, + ], + schedule: { + slices: [ + { month: "2026-01", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-02", amount: { amount: 2000, currency: "DKK" } }, + ], + }, + }); + return data; +} + +describe("round trip", () => { + test("export then import yields an equal dataset", () => { + const data = populated(); + expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data); + }); + + test("derived output is identical after a round trip", () => { + const data = populated(); + const restored = parseDatasetJson(exportDatasetJson(data)); + expect(monthView(restored, "2026-01")).toEqual(monthView(data, "2026-01")); + expect(yearView(restored, 2026)).toEqual(yearView(data, 2026)); + }); + + test("export is human-readable, indented JSON", () => { + expect(exportDatasetJson(createSeedDataset("2026-01"))).toContain("\n "); + }); +}); + +describe("validation", () => { + test("rejects malformed JSON", () => { + expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError); + }); + + test("rejects a dataset missing a required collection", () => { + const data = populated() as any; + delete data.purchases; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/); + }); + + test("rejects a purchase whose splits reference an unknown post", () => { + const data = populated(); + data.purchases[0]!.splits[0]!.postId = "ghost"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/ghost/); + }); + + test("rejects a purchase with no remainder-absorbing split", () => { + const data = populated(); + data.purchases[0]!.splits[0]!.absorbsRemainder = false; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/absorbsRemainder/); + }); + + test("rejects a purchase with two remainder-absorbing splits", () => { + const data = populated(); + data.purchases[0]!.splits[1]!.absorbsRemainder = true; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i); + }); + + test("rejects a purchase with no splits", () => { + const data = populated(); + data.purchases[0]!.splits = []; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i); + }); + + test("rejects an unsupported currency", () => { + const data = populated() as any; + data.settings.baseCurrency = "GBP"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/GBP/); + }); + + test("rejects a malformed MonthId", () => { + const data = populated(); + data.months[0]!.id = "2026-1"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/); + }); +}); + +test("describeDataset counts what an import would replace", () => { + expect(describeDataset(populated())).toEqual({ + posts: 3, + months: 1, + purchases: 1, + }); +}); + +test("exportFilename is stable and sortable", () => { + expect(exportFilename("2026-09")).toBe("budget-2026-09.json"); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `bun test src/export/json.test.ts` +Expected: FAIL — cannot resolve `./json.ts`. + +- [ ] **Step 3: Implement** + +```ts +// src/export/json.ts +import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts"; +import { migrate } from "../store/migrations.ts"; + +export class ImportValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportValidationError"; + } +} + +export function exportDatasetJson(dataset: Dataset): string { + return JSON.stringify(dataset, null, 2); +} + +export function describeDataset(dataset: Dataset) { + return { + posts: dataset.posts.length, + months: dataset.months.length, + purchases: dataset.purchases.length, + }; +} + +export function exportFilename(monthId: MonthId): string { + return `budget-${monthId}.json`; +} + +const MONTH_ID = /^\d{4}-\d{2}$/; + +function requireCurrency(value: unknown, where: string): Currency { + if (!CURRENCIES.includes(value as Currency)) { + throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`); + } + return value as Currency; +} + +function requireArray(data: Record, key: string): unknown[] { + const value = data[key]; + if (!Array.isArray(value)) { + throw new ImportValidationError(`Dataset field "${key}" is missing or not an array`); + } + return value; +} + +/** + * Validates far enough that the fold cannot throw on the imported data: every + * split points at a real post, every purchase has exactly one remainder + * absorber, and every MonthId parses. + */ +export function parseDatasetJson(text: string): Dataset { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new ImportValidationError( + `File is not valid JSON: ${(error as Error).message}`, + ); + } + + let dataset: Dataset; + try { + dataset = migrate(raw); + } catch (error) { + throw new ImportValidationError((error as Error).message); + } + + const data = dataset as unknown as Record; + requireArray(data, "posts"); + requireArray(data, "months"); + requireArray(data, "purchases"); + requireArray(data, "fxRates"); + + requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency"); + if (!MONTH_ID.test(dataset.settings.foldStartMonth)) { + throw new ImportValidationError( + `Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`, + ); + } + + const postIds = new Set(dataset.posts.map((p) => p.id)); + for (const post of dataset.posts) { + requireCurrency(post.currency, `post "${post.name}"`); + } + + for (const month of dataset.months) { + if (!MONTH_ID.test(month.id)) { + throw new ImportValidationError(`Invalid month id "${month.id}"`); + } + requireCurrency(month.income.currency, `month ${month.id} income`); + } + + for (const purchase of dataset.purchases) { + const label = `purchase "${purchase.description}"`; + requireCurrency(purchase.total.currency, label); + + if (purchase.splits.length === 0) { + throw new ImportValidationError(`${label} has no splits; at least one split is required`); + } + const absorbers = purchase.splits.filter((s) => s.absorbsRemainder).length; + if (absorbers !== 1) { + throw new ImportValidationError( + `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, + ); + } + for (const split of purchase.splits) { + if (!postIds.has(split.postId)) { + throw new ImportValidationError( + `${label} references unknown post "${split.postId}"`, + ); + } + } + for (const slice of purchase.schedule?.slices ?? []) { + if (!MONTH_ID.test(slice.month)) { + throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`); + } + requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`); + } + } + + return dataset; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test src/export/json.test.ts` +Expected: PASS, 13 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/export/json.ts src/export/json.test.ts +git commit -m "feat(export): add JSON export and validated import + +Import validates far enough that the fold cannot throw on imported data." +``` + +--- + +### Task 14: ODS XML documents + +An ODS file is a zip containing four XML documents. This task builds the XML; +Task 15 packages it. Splitting them keeps the fiddly XML testable as strings. + +**Files:** +- Create: `src/export/odsXml.ts` +- Test: `src/export/odsXml.test.ts` + +**Interfaces:** +- Consumes: nothing outside itself. +- Produces: + - `type Cell = { kind: "text"; value: string } | { kind: "number"; value: number } | { kind: "empty" }` + - `text(value: string): Cell`, `num(value: number): Cell`, `empty(): Cell` + - `interface Sheet { name: string; rows: Cell[][] }` + - `escapeXml(value: string): string` + - `buildContentXml(sheets: Sheet[]): string` + - `buildStylesXml(): string` + - `buildManifestXml(): string` + - `ODS_MIMETYPE: string` + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/export/odsXml.test.ts +import { test, expect, describe } from "bun:test"; +import { + buildContentXml, + buildManifestXml, + buildStylesXml, + escapeXml, + text, + num, + empty, + ODS_MIMETYPE, +} from "./odsXml.ts"; + +test("the ODS mimetype is the spreadsheet one", () => { + expect(ODS_MIMETYPE).toBe("application/vnd.oasis.opendocument.spreadsheet"); +}); + +describe("escapeXml", () => { + test("escapes the five XML entities", () => { + expect(escapeXml(` "c" 'd'`)).toBe( + "<a & b> "c" 'd'", + ); + }); + + test("leaves ordinary text alone", () => { + expect(escapeXml("Events and Social")).toBe("Events and Social"); + }); +}); + +describe("buildContentXml", () => { + const xml = buildContentXml([ + { + name: "Summary", + rows: [ + [text("Post"), text("Spent")], + [text("Food"), num(1234.56)], + [text("Empty"), empty()], + ], + }, + ]); + + test("declares the spreadsheet document namespaces", () => { + expect(xml).toStartWith(''); + expect(xml).toContain("office:document-content"); + expect(xml).toContain("urn:oasis:names:tc:opendocument:xmlns:office:1.0"); + }); + + test("names the table", () => { + expect(xml).toContain('table:name="Summary"'); + }); + + test("writes numbers as float cells with office:value, not text", () => { + expect(xml).toContain('office:value-type="float" office:value="1234.56"'); + expect(xml).not.toContain("1234.56"); + }); + + test("writes text cells as strings", () => { + expect(xml).toContain('office:value-type="string">Food'); + }); + + test("writes empty cells with no value type", () => { + expect(xml).toContain(""); + }); + + test("escapes sheet names and cell text", () => { + const nasty = buildContentXml([ + { name: 'Food & "drink"', rows: [[text("