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();
+});