diff --git a/openspec/changes/bootstrap-finance-app/design.md b/openspec/changes/bootstrap-finance-app/design.md index 6f8cc4e..9acf62d 100644 --- a/openspec/changes/bootstrap-finance-app/design.md +++ b/openspec/changes/bootstrap-finance-app/design.md @@ -39,6 +39,7 @@ SvelteKit runs under Deno via npm compat; server-only modules host the sync engi - **`APP_URL` derives everything origin-shaped**: `client_id` = `{APP_URL}/client-metadata.json`, redirect URI = `{APP_URL}/oauth/callback`, JWKS at `{APP_URL}/jwks.json`. Client metadata and JWKS are **dynamic SvelteKit routes**, not static files, so they always reflect live env. Caddy proxies the whole app; no special path handling. - Changing `APP_URL` changes the OAuth client identity → both users re-consent. Accepted cost, documented in README. +- **Local development** (non-https `APP_URL`): the client falls back to atproto's loopback-client exception — `http://localhost` client_id with metadata in query params, public client (no keyset), redirect to `127.0.0.1` — so `deno task dev` works without the public origin. Production always uses the confidential client above. ### D3. Ingestion: raw archive first, then idempotent normalization diff --git a/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md b/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md index 611bab7..d93f507 100644 --- a/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md +++ b/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md @@ -50,10 +50,10 @@ The system SHALL normalize archived payloads into `accounts`, `transactions`, an - **THEN** row counts and contents are unchanged ### Requirement: Balance snapshots -The system SHALL record one balance snapshot per account per successful sync, capturing balance and available balance (integer cents) with the capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never be deleted by the application. +The system SHALL record one balance snapshot per account per successful sync fetch (a sync may perform an extra deep-backfill fetch when it discovers a new account), capturing balance and available balance (integer cents) with the capture timestamp, to power net-worth-over-time reporting. Snapshots SHALL never be deleted by the application. #### Scenario: Snapshot captured on sync -- **WHEN** a successful sync reports an account balance +- **WHEN** a successful sync fetch reports an account balance - **THEN** a snapshot row is inserted for that account with the reported balance and timestamp ### Requirement: Pending-to-posted reconciliation diff --git a/openspec/changes/bootstrap-finance-app/tasks.md b/openspec/changes/bootstrap-finance-app/tasks.md index 70c3f40..1c6ccac 100644 --- a/openspec/changes/bootstrap-finance-app/tasks.md +++ b/openspec/changes/bootstrap-finance-app/tasks.md @@ -12,7 +12,7 @@ - [x] 2.2 Integrate `@atproto/oauth-client-node` with SQLite-backed state/session stores; login page with handle input; `/oauth/callback` handler (validates the library works under Deno npm-compat — fallback per design risk if not) - [x] 2.3 DID allowlist check on callback: create/update user record for allowlisted DIDs, 403 otherwise - [x] 2.4 Application sessions: HTTP-only Secure cookie, SQLite sessions table, hooks guard on all protected routes, logout -- [ ] 2.5 Verify full OAuth round-trip end-to-end through the public `APP_URL` origin (both household DIDs) +- [x] 2.5 Verify full OAuth round-trip end-to-end through the public `APP_URL` origin (both household DIDs) ## 3. SimpleFIN ingestion (specs/simplefin-sync) @@ -46,4 +46,4 @@ ## 7. Deployment & verification - [x] 7.1 Production build + run task; README covering Caddy route, env setup, key generation, backup expectations (SQLite file is secret-grade), and known limits (~90-day backfill, APP_URL change forces re-consent) -- [ ] 7.2 End-to-end walkthrough on real infrastructure: both users log in, claim real setup token, first sync lands, classify accounts, create rules, categorize manually, verify monthly report and net worth chart +- [x] 7.2 End-to-end walkthrough on real infrastructure: both users log in, claim real setup token, first sync lands, classify accounts, create rules, categorize manually, verify monthly report and net worth chart diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts new file mode 100644 index 0000000..3f5f4dc --- /dev/null +++ b/src/lib/server/services/categories.test.ts @@ -0,0 +1,46 @@ +/// +import { openDatabase } from '../db.ts'; +import { createCategory, listCategories, setCategoryActive } from './categories.ts'; + +const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( + /^\/([A-Za-z]:)/, + '$1' +); + +Deno.test('built-in Transfer category cannot be deactivated; others can', () => { + const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); + const transfer = listCategories(db).find((c) => c.builtin); + if (!transfer) throw new Error('built-in Transfer missing'); + + let threw = false; + try { + setCategoryActive(db, transfer.id, false); + } catch { + threw = true; + } + if (!threw) throw new Error('deactivating built-in Transfer should throw'); + if (!listCategories(db).find((c) => c.id === transfer.id)?.active) { + throw new Error('Transfer must remain active'); + } + + const groceries = createCategory(db, 'Groceries', 'expense'); + setCategoryActive(db, groceries.id, false); + if (listCategories(db).find((c) => c.id === groceries.id)?.active) { + throw new Error('regular category should deactivate'); + } + setCategoryActive(db, groceries.id, true); // and reactivate + db.close(); +}); + +Deno.test('created categories get distinct least-used color indices', () => { + const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); + const seen = new Set(listCategories(db).map((c) => c.colorIndex)); + for (let i = 0; i < 11; i++) { + const category = createCategory(db, `Cat ${i}`, 'expense'); + if (seen.has(category.colorIndex)) { + throw new Error(`color index ${category.colorIndex} reused while slots remained`); + } + seen.add(category.colorIndex); + } + db.close(); +}); diff --git a/src/lib/server/services/ledger.test.ts b/src/lib/server/services/ledger.test.ts new file mode 100644 index 0000000..66a792a --- /dev/null +++ b/src/lib/server/services/ledger.test.ts @@ -0,0 +1,97 @@ +/// +import { openDatabase } from '../db.ts'; +import { listLedger, listMonths, monthRange } from './ledger.ts'; +import type { DatabaseSync } from 'node:sqlite'; + +const MIGRATIONS_DIR = new URL('../../../../migrations', import.meta.url).pathname.replace( + /^\/([A-Za-z]:)/, + '$1' +); + +const JUNE_10 = Math.floor(Date.UTC(2026, 5, 10) / 1000); +const JULY_02 = Math.floor(Date.UTC(2026, 6, 2) / 1000); + +function testDb(): DatabaseSync { + const db = openDatabase(`${Deno.makeTempDirSync()}/t.db`, MIGRATIONS_DIR); + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO connections (access_url, claimed_at) VALUES ('https://u:p@x/simplefin', ?)" + ).run(now); + for (const [id, state] of [ + ['chk', 'ACTIVE'], + ['ghost', 'HIDDEN'] + ]) { + db.prepare( + `INSERT INTO accounts (id, connection_id, name, currency, state, created_at) + VALUES (?, 1, ?, 'USD', ?, ?)` + ).run(id, id, state, now); + } + db.prepare("INSERT INTO categories (name, kind, created_at) VALUES ('Dining','expense',?)").run(now); + const insert = db.prepare( + `INSERT INTO transactions + (account_id, sfin_id, posted, amount_cents, description, pending, category_id, removed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insert.run('chk', 'june-dining', JUNE_10, -2500, 'THAI PALACE', 0, 2, null, now); + insert.run('chk', 'july-uncat', JULY_02, -1000, 'MYSTERY', 0, null, null, now); + insert.run('chk', 'july-pending', null, -500, 'PENDING COFFEE', 1, null, null, now); + insert.run('chk', 'removed', JULY_02, -999, 'GHOST PENDING', 0, null, now, now); // soft-removed + insert.run('ghost', 'hidden-txn', JULY_02, -777, 'HIDDEN SPEND', 0, null, null, now); + return db; +} + +Deno.test('monthRange parses and rejects', () => { + const { start, end } = monthRange('2026-06'); + if (start !== Date.UTC(2026, 5, 1) / 1000 || end !== Date.UTC(2026, 6, 1) / 1000) { + throw new Error('wrong June range'); + } + let threw = false; + try { + monthRange('junk'); + } catch { + threw = true; + } + if (!threw) throw new Error('expected invalid month to throw'); +}); + +Deno.test('ledger excludes hidden accounts and soft-removed rows by default', () => { + const db = testDb(); + const rows = listLedger(db); + const ids = rows.map((r) => r.description); + if (ids.includes('HIDDEN SPEND')) throw new Error('hidden account leaked'); + if (ids.includes('GHOST PENDING')) throw new Error('soft-removed row leaked'); + if (rows.length !== 3) throw new Error(`expected 3 rows, got ${rows.length}`); + // pending rows sort first + if (!rows[0].pending) throw new Error('pending row should lead'); + db.close(); +}); + +Deno.test('ledger filters: month, category, uncategorized, pending, account', () => { + const db = testDb(); + const june = listLedger(db, { month: '2026-06' }); + if (june.length !== 1 || june[0].description !== 'THAI PALACE') + throw new Error('month filter wrong'); + + const dining = listLedger(db, { category: 2 }); + if (dining.length !== 1 || dining[0].categoryName !== 'Dining') + throw new Error('category filter wrong'); + + const uncat = listLedger(db, { category: 'uncategorized' }); + if (uncat.some((r) => r.categoryId !== null) || uncat.length !== 2) + throw new Error('uncategorized filter wrong'); + + if (listLedger(db, { pending: true }).length !== 1) throw new Error('pending-only wrong'); + if (listLedger(db, { pending: false }).length !== 2) throw new Error('posted-only wrong'); + if (listLedger(db, { accountId: 'chk' }).length !== 3) throw new Error('account filter wrong'); + db.close(); +}); + +Deno.test('listMonths returns distinct months, newest first, excluding removed', () => { + const db = testDb(); + const months = listMonths(db); + // pending row falls into its created_at month (this month), plus 2026-07 and 2026-06 + if (months[months.length - 1] !== '2026-06') throw new Error('oldest month should be June'); + if (!months.includes('2026-07')) throw new Error('July missing'); + if (new Set(months).size !== months.length) throw new Error('months not distinct'); + db.close(); +});