From e8575f48f5ec926759ae6875d8981470cc1ea40d Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Mon, 13 Jul 2026 00:37:17 -0700 Subject: [PATCH] propose bootstrap-finance-app change --- .../bootstrap-finance-app/.openspec.yaml | 2 + .../changes/bootstrap-finance-app/design.md | 96 +++++++++++++++++++ .../changes/bootstrap-finance-app/proposal.md | 35 +++++++ .../specs/account-management/spec.md | 45 +++++++++ .../bootstrap-finance-app/specs/auth/spec.md | 51 ++++++++++ .../specs/categorization/spec.md | 49 ++++++++++ .../specs/reporting/spec.md | 38 ++++++++ .../specs/simplefin-sync/spec.md | 68 +++++++++++++ .../changes/bootstrap-finance-app/tasks.md | 49 ++++++++++ 9 files changed, 433 insertions(+) create mode 100644 openspec/changes/bootstrap-finance-app/.openspec.yaml create mode 100644 openspec/changes/bootstrap-finance-app/design.md create mode 100644 openspec/changes/bootstrap-finance-app/proposal.md create mode 100644 openspec/changes/bootstrap-finance-app/specs/account-management/spec.md create mode 100644 openspec/changes/bootstrap-finance-app/specs/auth/spec.md create mode 100644 openspec/changes/bootstrap-finance-app/specs/categorization/spec.md create mode 100644 openspec/changes/bootstrap-finance-app/specs/reporting/spec.md create mode 100644 openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md create mode 100644 openspec/changes/bootstrap-finance-app/tasks.md diff --git a/openspec/changes/bootstrap-finance-app/.openspec.yaml b/openspec/changes/bootstrap-finance-app/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/bootstrap-finance-app/design.md b/openspec/changes/bootstrap-finance-app/design.md new file mode 100644 index 0000000..6f8cc4e --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/design.md @@ -0,0 +1,96 @@ +## Context + +Greenfield repo. Requirements settled during exploration: a Mint-style (reporting-first) household finance app for exactly two users, self-hosted at home behind an existing Caddy reverse proxy, using SimpleFIN as the sole bank-data source and AT Protocol OAuth for identity. Hard constraints: Deno 2.9 and SvelteKit. + +Key external facts that shaped the design: + +- **SimpleFIN** is pull-only and read-only. A one-time setup token is claimed for a permanent Access URL (Basic Auth baked in). `GET {access_url}/accounts` returns everything; the Bridge refreshes bank data roughly daily, so polling faster is pointless. There are no webhooks, no categories, no account types, and no dedup guarantee across the pending→posted transition (a pending transaction may re-post under a different id). +- **ATProto OAuth** requires the `client_id` to be a publicly fetchable HTTPS URL serving a client-metadata JSON document; DPoP and PAR are mandatory; confidential clients authenticate with `private_key_jwt` and a published JWKS. Consequence: the home server must have one public HTTPS origin. + +## Goals / Non-Goals + +**Goals:** + +- Ship a complete vertical slice: login → connect SimpleFIN → daily sync → categorize → monthly category totals + net worth chart. +- Preserve every byte SimpleFIN ever sends (raw archive) so future visualizations and normalization changes can replay history. +- Make every categorization explainable: which rule fired, or which person acted, and when. +- Keep deferred features (category groups, request-partner-to-categorize, payee normalization) cheap to add later via schema choices made now. + +**Non-Goals:** + +- Budgeting (envelopes, targets, caps) — this is reporting-first; budgets are a possible later change. +- Multi-household/tenant support, roles, or any user management beyond the two-DID allowlist. +- Writing anything back to banks or SimpleFIN; the app is a read-only mirror plus local annotations. +- Native desktop/mobile apps, offline mode, or non-SimpleFIN import (CSV/OFX) in v1 — but the architecture MUST keep the frontend/backend boundary separable so future native clients (e.g., Deno desktop) can target a standalone Quantum backend (see D6). + +## Decisions + +### D1. Runtime & persistence: SvelteKit on Deno 2.9, SQLite via `node:sqlite` + +SvelteKit runs under Deno via npm compat; server-only modules host the sync engine and auth. SQLite through the built-in `node:sqlite` means zero native dependencies and a single-file database that is trivial to back up. *Alternatives*: Deno KV (no relational queries, weaker reporting story); Postgres (a server process to babysit for a two-user app — unjustified). + +- Amounts are stored as **integer cents**, converted exactly once at ingestion from SimpleFIN's numeric strings. Floats never touch money. +- **Numbered SQL migrations** (`migrations/NNN_*.sql`) applied at startup, tracked in a `schema_version` table. The database will hold years of irreplaceable categorization and balance history; schema evolution is a first-class concern. +- Scheduling via **`Deno.cron`** in the server process (daily sync), with a manual "sync now" action in the UI. + +### D2. Identity: ATProto OAuth, confidential client, DID allowlist + +`@atproto/oauth-client-node` handles DPoP, PAR, `private_key_jwt`, and token refresh. We use it for **identity only** (scope `atproto`); the app never touches a PDS after login. Authorization is `ALLOWED_DIDS` (two DIDs) checked after callback — anyone else gets a 403. App sessions are plain HTTP-only cookies backed by a sessions table; the library's state/session stores are also SQLite tables. *Alternatives*: passkeys/local passwords (less machinery, but the household is standardizing on ATProto identity and this eliminates all credential storage); Tailscale-only access (breaks the OAuth flow — the authorization server must fetch client metadata over the public internet). + +- **`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. + +### D3. Ingestion: raw archive first, then idempotent normalization + +Every sync stores the **verbatim response JSON** in `raw_syncs(id, fetched_at, payload, ok, error)` before anything else. Normalization is a pure, idempotent function from payload → upserts, keyed on SimpleFIN ids: + +- `connections(id, access_url, claimed_at)` — plural by design even though v1 has one row (a second Bridge account later is an insert, not a migration). The Access URL lives here, not in env; bootstrap is a settings-page flow: paste setup token → POST claim → store. A used token's 403 gets a clear message. +- `accounts` — SimpleFIN id, connection FK, org info, currency, plus app-owned fields (state, type, display name, `last_successful_data_at`). +- `transactions` — SimpleFIN id, account FK, posted/transacted timestamps, `amount_cents`, raw description, pending flag, verbatim `extra` JSON column, and a denormalized `category_id` cache (see D4). +- `balance_snapshots(account_id, captured_at, balance_cents, available_balance_cents)` — one row per account per sync. This is the net-worth history that can never be backfilled; capturing it from day one is the reason it ships in v1. + +**Pending→posted reconciliation**: pending rows are matched to newly posted rows by (account, amount, date proximity); on match the pending row is replaced and any categorization is carried forward via a `reconciliation` event (D4). Unmatched stale pending rows are removed. *Alternative*: discard-and-refetch all pending rows each sync — simpler but loses manual categorization applied to pending transactions, which contradicts the provenance guarantees. + +Because normalization is pure over archived payloads, it is testable against fixtures and **replayable**: if we later want a field we didn't normalize, we re-run over `raw_syncs` history. + +### D4. Categorization: append-only event log with denormalized cache + +`categorization_events(id, transaction_id, category_id, source, rule_id, actor_did, created_at)` is append-only; `source ∈ {rule, manual, reconciliation}`. The transaction's current category is the latest event, cached on `transactions.category_id` and updated **in the same DB transaction** as the event insert. *Alternative*: bare `category_id` column — cannot answer "what led to this?", and the deferred request-partner-to-categorize feature would need retrofitting; with the event log it becomes one future table whose requests resolve when a `manual` event by the requestee DID appears. + +- **Categories** are flat: `categories(id, name, kind)` with `kind ∈ {income, expense, transfer}`. A built-in, non-deletable **Transfer** category exists from migration 001; transfer-kind categories are excluded from all income/expense totals (kills the credit-card-payment double-count). Grouping is deferred but migration-safe because transactions only ever reference categories — a future `category_groups` table + nullable `group_id` on categories is purely additive. +- **Rules**: `rules(id, match_type ∈ {exact, contains}, pattern, category_id, created_by_did, created_at, active)`. Matching is case-insensitive against the **raw** description (payee normalization deferred; matching raw keeps provenance truthful). Deterministic precedence, no ordering UI: exact beats contains → longer pattern beats shorter → newer beats older. +- **Invariant: rules never overwrite human decisions.** Rules fire only on uncategorized transactions at sync time. Creating a rule offers retroactive application to existing *uncategorized* transactions only. + +### D5. Account lifecycle: discovery, not creation + +Accounts are never added in-app; they appear in the sync feed (state `NEW`), the user classifies them once (type — SimpleFIN provides none — and optional display name) to reach `ACTIVE`, and accounts that vanish from the feed become `INACTIVE` with all history kept. `HIDDEN` excludes an account from reporting without deleting anything. Connection-level errors from sync responses and per-account staleness (`last_successful_data_at`) surface as dashboard banners linking out to SimpleFIN Bridge — the app can point at broken connections but never fix them. + +### D6. Transport-agnostic core for future native clients + +Native desktop (Deno desktop) and mobile frontends are planned later; a future frontend must be able to point at a standalone Quantum backend rather than shipping embedded. We do **not** build a public API in v1 — we prevent fusion: + +- **Service layer**: all domain operations (ledger queries, categorization, rules, reports, sync trigger, account classification) live in transport-agnostic server modules with typed inputs/outputs. SvelteKit load functions and form actions are thin adapters over these services — no SQL or domain logic in routes. When native clients arrive, `/api/*` JSON routes become a second thin adapter over the same services. +- **Opaque session tokens**: application sessions are rows keyed by an opaque token; the web frontend delivers it via HTTP-only cookie. Accepting the same token as an `Authorization: Bearer` credential later is additive. *Alternative*: JWT sessions — needless for two users and harder to revoke. +- **Backend stays the sole OAuth client**: future native apps will not register their own ATProto client metadata. The intended pattern is system-browser login through the backend's existing web OAuth flow, with the resulting session token handed to the native app via deep-link/loopback redirect. One client identity, one DID allowlist, one identity authority. +- Deferred until a native client exists: `/api/*` routes, CORS policy, bearer-token parsing, deep-link handoff. + +### D7. Reporting: SQL over normalized tables + +Monthly income vs. expense per category = GROUP BY over posted, non-transfer, non-hidden transactions. Net worth over time = sum of each account's latest snapshot per day, split by balance sign (assets vs. liabilities). No cube/warehouse layer; SQLite over household-scale data is instant. + +## Risks / Trade-offs + +- [Access URL is a permanent bank-data credential in the DB] → the SQLite file is secret-grade: document backup + file-permission expectations; app runs as a dedicated user; nothing financial ever goes in logs or URLs. Encryption-at-rest is deliberately out of scope for v1 (the threat model is a home server the owner controls). +- [Pending→posted matching is heuristic; amounts/dates can shift] → conservative matcher (same account, exact amount, small date window); unmatched pending rows are dropped rather than guessed; reconciliation events make every carry-forward auditable. +- [SimpleFIN Bridge outage or bank connection rot] → raw archive means no data loss for periods the Bridge did serve; staleness surfacing makes rot visible within a day; sync failures are recorded on `raw_syncs` rows. +- [`@atproto/oauth-client-node` under Deno npm-compat is less traveled than Node] → validate in the first implementation task (walking skeleton includes a full OAuth round-trip); fallback is running the SvelteKit adapter under Node in a container, which changes nothing else in the design. +- [Bank backfill at bootstrap is shallow (~90 days typical)] → accepted; documented so expectations are set. Balance history starts at day one regardless. +- [Single-process app: cron, web, and DB in one] → fine at household scale; WAL mode + the same-transaction cache invariant keep concurrent request/sync writes safe. + +## Migration Plan + +Greenfield — no data migration. Deployment order: (1) DNS + Caddy route for `APP_URL`, (2) generate OAuth signing key, set env (`APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, key), (3) start app (migrations auto-apply), (4) both users log in, (5) paste SimpleFIN setup token, (6) first sync backfills and snapshots begin. Rollback = stop the process; the SQLite file is the only state. + +## Open Questions + +None blocking. Deliberately deferred to later changes: category groups UI/rollups, request-partner-to-categorize, payee normalization, budgets, CSV export. diff --git a/openspec/changes/bootstrap-finance-app/proposal.md b/openspec/changes/bootstrap-finance-app/proposal.md new file mode 100644 index 0000000..9a02f42 --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/proposal.md @@ -0,0 +1,35 @@ +## Why + +We (a two-person household) want a Mint-style personal finance app — categorize transactions and visualize income vs. expense per category plus net worth over time — self-hosted on our own hardware, fed by bank data from the SimpleFIN protocol. No existing tool combines self-hosting, SimpleFIN ingestion, categorization provenance, and AT Protocol identity; this change bootstraps the entire greenfield application. + +## What Changes + +- Scaffold a SvelteKit application running on Deno 2.9 with SQLite persistence (`node:sqlite`) and numbered SQL migrations from day one. +- Add AT Protocol OAuth login (via `@atproto/oauth-client-node`) with a two-DID allowlist; both users share a single household ledger. Public HTTPS origin (behind existing Caddy) is configured entirely via `APP_URL`. +- Add SimpleFIN ingestion: one-time setup-token claim flow, daily `Deno.cron` sync, verbatim raw-response archive, idempotent normalization into accounts/transactions, per-sync balance snapshots, and pending→posted reconciliation. +- Add account lifecycle management: accounts are discovered from the sync feed (never created in-app), flow through NEW → ACTIVE → INACTIVE states, require one-time user classification (type + display name), and surface connection errors/staleness with pointers to SimpleFIN Bridge. +- Add categorization: flat categories (income | expense | transfer), exact/contains matching rules, and an append-only categorization event log recording provenance (which rule fired, or which user acted) for every assignment. +- Add reporting: monthly income vs. expense totals per category (transfers excluded) and net worth over time from balance snapshots. + +Explicitly deferred (but kept cheap by this design): category groups, request-partner-to-categorize, payee normalization, and native desktop/mobile clients (e.g., Deno desktop) — the backend/frontend boundary is kept separable from day one so a future native frontend can point at a Quantum backend instead of shipping with it embedded. + +## Capabilities + +### New Capabilities +- `auth`: AT Protocol OAuth login, DID allowlist authorization, session management, and env-driven client identity (client metadata, JWKS, redirect URI derived from `APP_URL`). +- `simplefin-sync`: SimpleFIN connection bootstrap (setup token → Access URL), scheduled sync, raw response archival, idempotent normalization (accounts, transactions, balance snapshots), and pending→posted reconciliation. +- `account-management`: account discovery from sync data, lifecycle states (NEW/ACTIVE/INACTIVE/HIDDEN), user classification of new accounts, and connection error/staleness surfacing. +- `categorization`: category CRUD, rule-based auto-categorization (exact + contains, deterministic precedence), manual categorization with actor attribution, and the append-only provenance event log. +- `reporting`: monthly income/expense-by-category totals and net-worth-over-time views. + +### Modified Capabilities + +None — greenfield project, no existing specs. + +## Impact + +- **Code**: entire new SvelteKit/Deno codebase (routes, server-only modules for sync/auth/rules, SQLite schema + migrations). +- **Dependencies**: `@atproto/oauth-client-node` (npm), SvelteKit/Vite toolchain under Deno 2.9; no other external services beyond SimpleFIN Bridge. +- **Systems**: requires a publicly reachable HTTPS origin proxied by existing Caddy (ATProto authorization servers must fetch `client-metadata.json`); SimpleFIN Bridge account with bank connections managed there. +- **Data**: new SQLite database holding financial data and the SimpleFIN Access URL — must be treated as secret-grade and backed up; balance history is unrecoverable if lost. +- **Env surface**: `APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, OAuth client private key. diff --git a/openspec/changes/bootstrap-finance-app/specs/account-management/spec.md b/openspec/changes/bootstrap-finance-app/specs/account-management/spec.md new file mode 100644 index 0000000..7e8eed4 --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/specs/account-management/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Account discovery from sync feed +The system SHALL create account records only from sync data, never via in-app creation. An account id appearing in a sync for the first time SHALL be registered in state `NEW` with its SimpleFIN-provided organization, name, and currency. + +#### Scenario: Unknown account appears in sync +- **WHEN** a sync payload contains an account id not present in the database +- **THEN** an account row is created in state `NEW` and its transactions and balance snapshots are ingested normally + +### Requirement: Account classification +The system SHALL require one-time user classification of each `NEW` account before it is treated as `ACTIVE`: the user assigns an account type (e.g., checking, savings, credit card, investment) and may set a friendly display name. SimpleFIN provides no account type, so classification is user-supplied. The UI SHALL surface accounts awaiting classification. + +#### Scenario: User classifies a new account +- **WHEN** a user assigns a type (and optional display name) to a `NEW` account +- **THEN** the account transitions to `ACTIVE` and appears in reporting + +#### Scenario: Unclassified account visibility +- **WHEN** any account is in state `NEW` +- **THEN** the dashboard shows a prompt to classify it + +### Requirement: Account lifecycle states +The system SHALL track account states `NEW`, `ACTIVE`, `INACTIVE`, and `HIDDEN`. An account that stops appearing in sync feeds SHALL transition to `INACTIVE` automatically; its transactions, categorizations, and snapshots SHALL be retained. A user MAY mark an account `HIDDEN` to exclude it from all reporting without deleting data, and MAY unhide it later. The system SHALL never delete account history. + +#### Scenario: Account vanishes from feed +- **WHEN** an `ACTIVE` account is absent from a successful sync of its connection +- **THEN** the account transitions to `INACTIVE` and all historical data remains queryable + +#### Scenario: Inactive account reappears +- **WHEN** an `INACTIVE` account id appears in a sync again +- **THEN** the account returns to `ACTIVE` (or `NEW` if it was never classified) and ingestion resumes + +#### Scenario: User hides an account +- **WHEN** a user marks an account `HIDDEN` +- **THEN** the account and its transactions are excluded from reports until unhidden, and no data is deleted + +### Requirement: Connection health surfacing +The system SHALL surface connection errors returned in sync responses and per-account staleness. Each account SHALL track `last_successful_data_at`. When a sync reports connection-level errors or an account's data is stale beyond a threshold, the dashboard SHALL display a banner identifying the institution and directing the user to resolve it at SimpleFIN Bridge (the app cannot repair connections). + +#### Scenario: Sync response contains a connection error +- **WHEN** a sync response includes an error for an institution +- **THEN** the dashboard shows a banner naming the institution with a link out to SimpleFIN Bridge + +#### Scenario: Account data goes stale +- **WHEN** an `ACTIVE` account's `last_successful_data_at` exceeds the staleness threshold +- **THEN** the dashboard indicates the account has not updated since that time diff --git a/openspec/changes/bootstrap-finance-app/specs/auth/spec.md b/openspec/changes/bootstrap-finance-app/specs/auth/spec.md new file mode 100644 index 0000000..16a702c --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/specs/auth/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: ATProto OAuth login +The system SHALL authenticate users via AT Protocol OAuth using handle-based login. The user enters their handle (or DID); the system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization server, and establishes an application session on success. The system SHALL request only the `atproto` scope and SHALL NOT access the user's PDS data after authentication. + +#### Scenario: Successful login with allowlisted handle +- **WHEN** a user whose DID is in the allowlist completes the OAuth flow +- **THEN** the system creates an application session and sets an HTTP-only, Secure session cookie +- **AND** the user is redirected to the dashboard + +#### Scenario: Login with unknown handle +- **WHEN** a user submits a handle that cannot be resolved to a DID +- **THEN** the system shows an error on the login page without starting the OAuth flow + +### Requirement: DID allowlist authorization +The system SHALL authorize users solely by membership in the `ALLOWED_DIDS` environment variable. A successful OAuth authentication with a DID not in the allowlist SHALL be rejected and SHALL NOT create a session or a user record. + +#### Scenario: Non-allowlisted DID completes OAuth +- **WHEN** the OAuth callback resolves to a DID not present in `ALLOWED_DIDS` +- **THEN** the system responds with 403 and a message that the account is not authorized +- **AND** no session or user record is created + +#### Scenario: Allowlisted DID first login +- **WHEN** an allowlisted DID logs in for the first time +- **THEN** the system creates a user record storing the DID and current handle + +### Requirement: Env-derived client identity +The system SHALL derive its OAuth client identity entirely from the `APP_URL` environment variable: the client metadata document SHALL be served at `{APP_URL}/client-metadata.json`, the JWKS at `{APP_URL}/jwks.json`, and the redirect URI SHALL be `{APP_URL}/oauth/callback`. Both documents SHALL be generated dynamically at request time from live configuration, not served as static files. The client SHALL be a confidential client using `private_key_jwt` with DPoP-bound tokens. + +#### Scenario: Client metadata reflects APP_URL +- **WHEN** `GET /client-metadata.json` is requested +- **THEN** the response is `application/json` with `client_id` equal to `{APP_URL}/client-metadata.json`, `redirect_uris` containing `{APP_URL}/oauth/callback`, `token_endpoint_auth_method` of `private_key_jwt`, `dpop_bound_access_tokens` true, and scope including `atproto` + +#### Scenario: JWKS served for client authentication +- **WHEN** `GET /jwks.json` is requested +- **THEN** the response contains the public JWK(s) corresponding to the configured OAuth signing key + +### Requirement: Session management +The system SHALL persist application sessions and OAuth client state (state store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT assume cookie transport, so future native clients can present the same token as a bearer credential. Every route except login, the OAuth callback, client metadata, and JWKS SHALL require a valid session. Users SHALL be able to log out, which destroys the application session. + +#### Scenario: Unauthenticated access to a protected route +- **WHEN** a request without a valid session cookie targets any protected route +- **THEN** the system redirects to the login page + +#### Scenario: Logout +- **WHEN** an authenticated user triggers logout +- **THEN** the session record is deleted, the cookie is cleared, and subsequent requests are treated as unauthenticated + +#### Scenario: Session survives server restart +- **WHEN** the server process restarts and a user presents a previously issued valid session cookie +- **THEN** the session is honored because it is persisted in SQLite diff --git a/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md b/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md new file mode 100644 index 0000000..c6989bc --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Category management +The system SHALL provide flat categories with a name and a kind of `income`, `expense`, or `transfer`. Users SHALL be able to create, rename, and deactivate categories. A built-in `Transfer` category (kind `transfer`) SHALL exist from initial migration and SHALL NOT be deletable. Transactions SHALL reference categories directly (never any grouping construct), keeping future category grouping purely additive. + +#### Scenario: Create a category +- **WHEN** a user creates a category with a name and kind +- **THEN** the category is available for rules and manual assignment + +#### Scenario: Built-in Transfer category is protected +- **WHEN** a user attempts to delete the built-in Transfer category +- **THEN** the system refuses + +### Requirement: Append-only categorization event log +The system SHALL record every category assignment as an immutable event: transaction id, category id, source (`rule`, `manual`, or `reconciliation`), the rule id for rule events, the acting user's DID for manual events, and a timestamp. A transaction's current category SHALL be the latest event, denormalized onto the transaction row in the same database transaction as the event insert. Events SHALL never be updated or deleted. + +#### Scenario: Manual categorization records actor +- **WHEN** an authenticated user assigns a category to a transaction +- **THEN** a `manual` event is appended with that user's DID and the transaction's cached category is updated atomically with it + +#### Scenario: Provenance is visible +- **WHEN** a user views a categorized transaction's history +- **THEN** the UI shows every event in order: what assigned it (rule pattern or person), to which category, and when + +#### Scenario: Recategorization preserves history +- **WHEN** a user changes an already-categorized transaction to a different category +- **THEN** a new event is appended and prior events remain queryable + +### Requirement: Rule-based auto-categorization +The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description. Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. + +#### Scenario: Rule fires on new transaction at sync +- **WHEN** a sync ingests an uncategorized transaction whose description matches an active rule +- **THEN** the winning rule's category is applied via a `rule` event referencing that rule + +#### Scenario: Precedence between overlapping rules +- **WHEN** a description matches both `contains "AMAZON"` and `contains "AMAZON PRIME"` +- **THEN** the longer pattern's rule wins and the fired rule id is recorded on the event + +### Requirement: Manual decisions outrank rules +The system SHALL never allow a rule to overwrite a categorization whose latest event is `manual`. Rules fire only on transactions with no current category. When a user creates a rule, the system SHALL offer to retroactively apply it to existing matching transactions that are currently uncategorized, and SHALL NOT touch categorized ones. + +#### Scenario: Rule does not overwrite manual choice +- **WHEN** a rule matching a transaction is created or runs, and that transaction's latest event is `manual` +- **THEN** the transaction's category is unchanged + +#### Scenario: Retroactive application on rule creation +- **WHEN** a user creates a rule and accepts the retroactive-apply offer +- **THEN** the rule is applied to all matching currently-uncategorized transactions, each receiving a `rule` event diff --git a/openspec/changes/bootstrap-finance-app/specs/reporting/spec.md b/openspec/changes/bootstrap-finance-app/specs/reporting/spec.md new file mode 100644 index 0000000..8750fc9 --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/specs/reporting/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Monthly income and expense by category +The system SHALL display, for a selected month, total income and total expenses broken down by category, computed from posted transactions of non-hidden accounts. Transactions in transfer-kind categories SHALL be excluded from all totals. Uncategorized transactions SHALL be shown as their own line with a count, so gaps in categorization are visible rather than silently distorting totals. + +#### Scenario: Category totals for a month +- **WHEN** a user views the report for a month +- **THEN** each category shows its total for that month (integer-cent arithmetic), grouped into income and expense sections, with an overall income, expense, and net figure + +#### Scenario: Transfers excluded +- **WHEN** a credit-card payment produced equal-and-opposite transactions categorized as Transfer +- **THEN** neither side appears in income or expense totals + +#### Scenario: Uncategorized surfaced +- **WHEN** the selected month contains uncategorized transactions +- **THEN** the report shows an "Uncategorized" line with their total and count, linking to the ledger filtered to them + +### Requirement: Net worth over time +The system SHALL display net worth over time computed from balance snapshots: for each day with data, the sum of every non-hidden account's most recent snapshot on or before that day, with assets (positive balances) and liabilities (negative balances) distinguishable. History SHALL extend back to the earliest snapshot. + +#### Scenario: Net worth chart +- **WHEN** a user views the net worth report +- **THEN** a time series shows total net worth per day derived from latest-snapshot-per-account, including asset/liability breakdown + +#### Scenario: Hidden accounts excluded +- **WHEN** an account is marked `HIDDEN` +- **THEN** its balances are excluded from the net worth series + +### Requirement: Transaction ledger +The system SHALL provide a ledger view of transactions filterable by account, category (including uncategorized), month, and pending status, showing date, account, description, amount, category, and a provenance indicator (rule vs. person). The ledger is the surface for manual categorization. + +#### Scenario: Filter to uncategorized +- **WHEN** a user filters the ledger to uncategorized transactions +- **THEN** only transactions with no current category are listed, ready for manual assignment + +#### Scenario: Provenance indicator +- **WHEN** a categorized transaction is displayed +- **THEN** the row indicates whether the category came from a rule, a person (with their identity), or reconciliation carry-forward diff --git a/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md b/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md new file mode 100644 index 0000000..b84a96c --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/specs/simplefin-sync/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Connection bootstrap via setup token +The system SHALL provide a settings flow where an authenticated user pastes a one-time SimpleFIN setup token. The system SHALL decode the token, POST to the claim URL, and store the resulting Access URL in the `connections` table. The Access URL SHALL be stored only in the database (never in environment variables or logs). The data model SHALL support multiple connections even though one is expected initially. + +#### Scenario: Valid setup token claimed +- **WHEN** a user submits a valid, unused setup token +- **THEN** the system claims it, stores a connection row with the Access URL and claim timestamp, and triggers an initial sync + +#### Scenario: Already-used setup token +- **WHEN** the claim request returns 403 +- **THEN** the system shows a message that the token was already claimed and a fresh one must be generated at SimpleFIN Bridge +- **AND** no connection row is created + +### Requirement: Scheduled and manual sync +The system SHALL sync each connection once daily via a scheduled job and SHALL provide a manual "sync now" action in the UI. A sync fetches `GET {access_url}/accounts` including pending transactions and a start date that safely overlaps previously fetched data. + +#### Scenario: Daily scheduled sync +- **WHEN** the daily schedule fires +- **THEN** the system performs a sync for every connection and records the outcome + +#### Scenario: Manual sync +- **WHEN** an authenticated user triggers "sync now" +- **THEN** a sync runs immediately and the UI reflects the result + +#### Scenario: Sync failure +- **WHEN** the SimpleFIN request fails (network error or non-2xx) +- **THEN** the system records a failed sync with the error detail and leaves all previously normalized data untouched + +### Requirement: Raw response archival +The system SHALL store the verbatim response body of every sync attempt in a `raw_syncs` table (fetch timestamp, payload, success flag, error detail) before any normalization occurs. Raw payloads SHALL never be mutated or deleted by the application. + +#### Scenario: Successful sync archived +- **WHEN** a sync response is received +- **THEN** a `raw_syncs` row with the exact response body is committed before normalization begins + +#### Scenario: Normalization can be replayed +- **WHEN** normalization logic is re-run over an archived payload +- **THEN** it produces the same normalized state as the original run (idempotent, pure function of the payload) + +### Requirement: Idempotent normalization +The system SHALL normalize archived payloads into `accounts`, `transactions`, and `balance_snapshots` via upserts keyed on SimpleFIN identifiers. Monetary amounts SHALL be converted from SimpleFIN's numeric strings to integer cents exactly once at normalization. Each transaction row SHALL retain the verbatim SimpleFIN `extra` payload in a JSON column. Running normalization twice over the same payload SHALL produce no duplicate rows. + +#### Scenario: New transaction ingested +- **WHEN** a payload contains a transaction id not yet in the database +- **THEN** a transaction row is inserted with amount as integer cents, raw description, timestamps, pending flag, and verbatim extra JSON + +#### Scenario: Repeated payload is a no-op +- **WHEN** the same payload is normalized a second time +- **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. + +#### Scenario: Snapshot captured on sync +- **WHEN** a successful sync reports an account balance +- **THEN** a snapshot row is inserted for that account with the reported balance and timestamp + +### Requirement: Pending-to-posted reconciliation +The system SHALL reconcile pending transactions when they post. A newly posted transaction SHALL be matched to an existing pending row by same account, identical amount, and date proximity within a small window. On match, the pending row is replaced by the posted transaction and any existing categorization is carried forward via a categorization event with source `reconciliation`. Pending rows absent from the feed and unmatched by any posted transaction SHALL be removed. + +#### Scenario: Categorized pending transaction posts under a new id +- **WHEN** a posted transaction matches a pending row that has a category +- **THEN** the posted transaction replaces the pending row, receives the same category, and a `reconciliation` categorization event records the carry-forward + +#### Scenario: Stale pending transaction disappears +- **WHEN** a pending row no longer appears in the feed and no posted transaction matches it +- **THEN** the pending row is removed diff --git a/openspec/changes/bootstrap-finance-app/tasks.md b/openspec/changes/bootstrap-finance-app/tasks.md new file mode 100644 index 0000000..7f952d9 --- /dev/null +++ b/openspec/changes/bootstrap-finance-app/tasks.md @@ -0,0 +1,49 @@ +## 1. Foundation + +- [ ] 1.1 Scaffold SvelteKit project running under Deno 2.9 (deno.json tasks for dev/build/start, adapter choice per design D1) with a health-check route +- [ ] 1.2 Implement SQLite bootstrap via `node:sqlite`: open `DB_PATH`, enable WAL, numbered-migration runner with `schema_version` table applied at startup +- [ ] 1.3 Write migration 001: users, sessions, oauth state/session stores, connections, accounts, transactions, balance_snapshots, raw_syncs, categories (seed built-in Transfer), rules, categorization_events +- [ ] 1.4 Add config module reading and validating `APP_URL`, `ALLOWED_DIDS`, `DB_PATH`, and OAuth signing key; fail fast with clear errors on missing config +- [ ] 1.5 Establish the service-layer convention (design D6): domain operations as transport-agnostic modules under `src/lib/server/`, SvelteKit loads/actions as thin adapters only — no SQL or domain logic in routes + +## 2. Authentication (specs/auth) + +- [ ] 2.1 Dynamic routes for `/client-metadata.json` and `/jwks.json` generated from `APP_URL` and the signing key +- [ ] 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) +- [ ] 2.3 DID allowlist check on callback: create/update user record for allowlisted DIDs, 403 otherwise +- [ ] 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) + +## 3. SimpleFIN ingestion (specs/simplefin-sync) + +- [ ] 3.1 Settings page: setup-token paste → decode → claim → store connection; handle already-claimed 403 with clear message; trigger initial sync +- [ ] 3.2 Sync engine: fetch `/accounts` (pending included, overlapping start-date), archive verbatim payload to raw_syncs (success and failure rows) before any processing +- [ ] 3.3 Idempotent normalization: upsert accounts and transactions (integer cents, verbatim extra JSON) keyed on SimpleFIN ids; pure function over payload with fixture-based tests including double-run idempotency +- [ ] 3.4 Balance snapshots: insert one row per account per successful sync +- [ ] 3.5 Pending→posted reconciliation: conservative matcher (account + exact amount + date window), carry categorization forward via `reconciliation` event, remove stale pending rows; tests for the categorized-pending-reposts case +- [ ] 3.6 Schedule daily sync with `Deno.cron` and add manual "sync now" action; record outcomes and update `last_successful_data_at` per account + +## 4. Account lifecycle (specs/account-management) + +- [ ] 4.1 Discovery: register unknown account ids from sync as `NEW`; auto-transition vanished accounts to `INACTIVE` and reappearing ones back +- [ ] 4.2 Classification UI: dashboard prompt for `NEW` accounts, assign type + optional display name → `ACTIVE`; hide/unhide action +- [ ] 4.3 Connection health: parse sync-response errors, dashboard banners naming the institution with SimpleFIN Bridge link-out, staleness indicator from `last_successful_data_at` + +## 5. Categorization (specs/categorization) + +- [ ] 5.1 Category management UI/API: create, rename, deactivate; enforce non-deletable built-in Transfer +- [ ] 5.2 Event log core: append event + update denormalized `transactions.category_id` in one DB transaction; manual events record actor DID +- [ ] 5.3 Rules engine: exact/contains case-insensitive matching on raw description, deterministic precedence (exact > contains, longer > shorter, newer > older), fire only on uncategorized transactions during sync; unit tests for precedence and the manual-outranks-rule invariant +- [ ] 5.4 Rules UI: create/deactivate rules, retroactive-apply offer scoped to currently-uncategorized matches with result count +- [ ] 5.5 Provenance UI: per-transaction history view showing every event (rule pattern / person / reconciliation, category, timestamp) + +## 6. Reporting (specs/reporting) + +- [ ] 6.1 Transaction ledger: filters (account, category incl. uncategorized, month, pending), inline manual categorization, provenance indicator per row +- [ ] 6.2 Monthly report: income and expense totals per category (posted, non-hidden, transfer-excluded), net figure, Uncategorized line linking to filtered ledger +- [ ] 6.3 Net worth over time: latest-snapshot-per-account-per-day series with asset/liability split, excluding hidden accounts + +## 7. Deployment & verification + +- [ ] 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 -- 2.51.2