diff --git a/PLAN-K0.md b/PLAN-K0.md new file mode 100644 index 0000000..d0bb48a --- /dev/null +++ b/PLAN-K0.md @@ -0,0 +1,78 @@ +# PLAN-K0: Card purchases spike (local Enable Banking test app) + +Status: the test app landed `2ce317f` (`docs/spikes/k0/app.py`, `docs/spikes/k0/README.md`). What is left is running it with the owner (and the partner) against the real SJ Prio Mastercard and writing the report. This plan describes the app as built and the remaining work (D-176). + +## 1. Goal +Fast feedback on the real bank before any Android, server or web work: try the Enable Banking flow on the MacBook with the owner's own SJ Prio Mastercard, see real data on the owner's own screen, and answer every K0 `TODO(spec)` of SPEC §9.12 in a report with aggregates only. The answers go into SPEC v0.36 (a docs agent) before K1 starts. + +## 2. Read first +1. SPEC §9.12 (every `TODO(spec)` marked K0, CP-1 to CP-14), FR-22, §8.7 (no personal content in logs), §10.5.2 (the `/cards/callback` block), D-158 to D-176. +2. `docs/spikes/k0/README.md` and `docs/spikes/k0/app.py` (the app as built). +3. Enable Banking's official documentation (API reference, quick start, FAQ), read at run time (`CLAUDE.md` "Fast-moving APIs"). +4. `CLAUDE.md` (personal data, reports are deliverables, stay inside the repository). + +## 3. Already established +- The app (as built, `2ce317f`): one command, `python3 docs/spikes/k0/app.py` (standard library only; the RS256 JWT is signed by shelling out to `openssl`), serves `http://localhost:8765/` and opens the browser. Options `--port`, `--no-browser`, `--local-dir`, `--api-base`. Sections: 1 Status; 2 Banker (`GET /aspsps?country=SE`, SJ/SEB Kort/Eurocard first, `maximum_consent_validity`, `auth_methods`, `required_psu_headers`); 3 Anslut (`POST /auth`, `valid_until` = now + the ASPSP's maximum capped at 180 days less 5 minutes, `psu_type` `personal`, a random `state`, the bank in a new tab, BankID by QR); 4 Klistra in (the redirect address pasted back, `state` checked, `POST /sessions`); 5 Konton (`GET /sessions/{id}`, identifiers masked to 4 characters); 6 Hämta: "Uppdatera" (PSU headers, `date_from`, optional `strategy=longest`, `continuation_key` followed), "Bakgrundshämtning" (no PSU headers: the 4-per-day path), "Saldo"; 7 Köp (dates, text, amount, currency, direction, status, `entry_reference`, compared with the previous fetch); 8 Analys (aggregates only); 9 Koppla från (`DELETE /sessions/{id}`). History choices 7, 30, 90, 180, 365, 730 days. +- Key, config and state live in `docs/spikes/k0/.local/` (git-ignored by the root `.gitignore`): `.pem`, optional `config.json`, `state.json` (settings, bank list, session id, account, the last two fetches, the aggregate fetch log). A re-run reuses the session until `valid_until`, so no new BankID. stdout shows method, path template and status only. +- Enable Banking's production control panel refuses `http://localhost` redirect URLs ("uses unsupported scheme"). The registered redirect URL is `https://kvitt.suorra.duckdns.org/cards/callback`; it does not load yet (no K1 server), but the address bar keeps `code` and `state`, which the owner pastes into section 4. +- Documented facts already in the spec (§9.12): the PSU header names and "either all required PSU headers … or none"; "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online"; `maximum_consent_validity` in seconds; `entry_reference` "unique and immutable", "in most cases … provided only for booked transactions"; `transaction_id` "may change"; `WRONG_TRANSACTIONS_PERIOD` and `strategy=longest`. +- Real purchase data may show on the owner's screen. It never goes into git, a log, a report, a chat message or a file outside `.local/`. The application id is not a secret but stays out of every committed file. + +## 4. Owned paths +`docs/spikes/k0/**` (the app; `.local/` stays git-ignored and uncommitted) and `docs/spikes/K0-enable-banking.md` (new). Nothing else: not `SPEC.md`, `docs/decisions.md`, `server/**`, `infra/**`. + +## 5. Deliverables +- `docs/spikes/K0-enable-banking.md`, aggregates only, recording the commit it was taken at, with one answer per K0 item of §9.12 (each: what was measured, the answer, the proposed spec text): + 1. The SJ Prio ASPSP's exact `name` and `country`, `auth_methods`, `psu_types`, `beta`, `required_psu_headers` (CP-1, CP-5). + 2. `maximum_consent_validity` (seconds and days) and the `valid_until` the session returned (CP-6). + 3. History depth: the earliest date reached for 90, 180 and 365 days asked right after BankID, and for the same asks a day later (CP-3); what `strategy=longest` returned and whether `WRONG_TRANSACTIONS_PERIOD` appeared (CP-7). + 4. Background fetch without PSU headers: accepted or refused (status and error code), and how many in 24 hours before a refusal, if any (CP-4). + 5. User-present fetch from the Mac's address: accepted with which headers (CP-5). + 6. Accounts per session, their `cash_account_type` values, and the account-pick rule (CP-2); whether the account uid is the same after a second BankID (renewal). + 7. Field presence over the fetched transactions: `entry_reference` (booked and pending), `transaction_date`, `value_date`, `booking_date`, `creditor.name`, `remittance_information`, other text fields; which field holds the shop name (CP-7 merchant rule; describe without quoting any text). + 8. Pending behaviour: counts of `PDNG`, whether a pending row reappears as `BOOK` with or without the same `entry_reference`, the days between, whether amounts or texts change (CP-8); whether the ±3-day amount and first-token rule would have matched every case. + 9. Currency: any non-SEK `transaction_amount.currency`; for a known foreign purchase, what the card reports (CP-7). + 10. Credit rows: counts and kinds (refund, bill payment, other), described without amounts (CP-8). + 11. The partner's card: whether it could be linked to the owner's application in the control panel and fetched (CP-13); if not, the exact refusal. + 12. Amount format (decimal string, separator, sign) (CP-7). + 13. Anything that contradicts §9.12, with the proposed revision. +- A short "Results (owner's phone)" section is not needed (nothing runs on the phone), but a "Results (owner's run)" section lists each owner step with its yes/no. +- Fixes to `app.py` found while running (iterate in minutes; stdlib only, at most one JWT library if openssl fails), committed with the report. + +## 6. Steps +1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-k0 -b agent-k0 main`; in it `git reset --hard main`; `tools/install-git-hooks.sh`. Copy `docs/spikes/k0/.local/` from the main checkout if the owner already put the key there (never commit it). +2. Read §2. Check the app starts: `python3 docs/spikes/k0/app.py --no-browser` and `curl -s localhost:8765/ | head` (no key yet: section 1 says so). +3. Relay the owner steps of §8 one at a time through the orchestrator; run the app for the owner (it opens the browser on the Mac). +4. After each owner step, read the aggregates only, never `state.json` itself: `python3 -c "import sys; sys.path.insert(0,'docs/spikes/k0'); import app; print(app.analysis_text(app.load_state()))"` from the worktree root (checked on an empty state, 2026-10-24). Nothing else is printed from `.local/`; where the analysis lacks a K0 item of §5, extend `analysis_text` (aggregates only) rather than reading the state. +5. A day later (owner step 9): repeat "Uppdatera" and "Bakgrundshämtning" without a new BankID to see the reused session, the history depth later and the background path over 24 hours. +6. Write the report; `git status` must list nothing under `docs/spikes/k0/.local/`. Commit on `agent-k0`, `git rebase main`. Never merge, push or remove the worktree. +7. At the end ask the owner (through the orchestrator) whether to keep the session for K1's tests or to press "9 Koppla från" (which deletes the stored transactions from `state.json`); the key stays in `.local/` for K1, which copies it to `infra/secrets/cards/` (PLAN-K1). + +## 7. Gate +None: K0 is not part of any gate. Before committing: `python3 -m py_compile docs/spikes/k0/app.py`; `git status --porcelain` shows no file under `.local/`; `grep` the report for anything that looks like an amount with `kr`, a date of a purchase or the application id (none may appear). Report what was run. + +## 8. HUMAN steps (the orchestrator relays them in chat, one at a time, exact labels as on screen; steps already done are confirmed with the owner and skipped) +1. In a browser on the Mac open `https://enablebanking.com`, open the Control Panel and sign up with your email address; confirm the email. When Enable Banking shows its Terms of Service, read and accept them: this is the licence step Kvitt needs before using the service (answer yes or no in chat). +2. Control Panel › API applications › "Add a new application": environment **Production** (restricted production: your own accounts only), application name **Kvitt**, redirect URL exactly `https://kvitt.suorra.duckdns.org/cards/callback` (only this one; `http://localhost` is refused), keep the private key generated in the browser, fill the required contact fields, then register. Expected: the browser downloads a file `.pem` (yes/no). +3. Move that `.pem` file from Downloads into the folder `/Users/jhag/projects/kvitt/docs/spikes/k0/.local/` (create the folder `.local` if it is missing: in Finder press Cmd+Shift+. to show hidden folders). Keep the file name. Expected: the test app's section 1 shows "key found" (the agent checks). +4. Recommended before the first connection, so the one-time code in the bank's redirect never reaches NPM's log: NPM › Hosts › Proxy Hosts › the Kvitt row › Edit › Advanced, add at the end of Custom Nginx Configuration the `location = /cards/callback { … }` block of SPEC §10.5.2 (the agent pastes it in chat), Save. Expected: the row is still Online (yes/no). +5. Control Panel › the Kvitt application › "Activate by linking accounts": country Sweden, the bank listed as SJ Prio Mastercard, sign with BankID (scan the QR with the BankID app on your phone). Expected: your card account is listed as linked (yes/no). +6. With your partner: on the same page link her SJ Prio Mastercard, she signs with her own BankID. Expected: a second linked account (yes/no); if it fails, say the exact message. +7. Test app (the agent starts it, the browser opens `http://localhost:8765/`): "2 Banker" › "Lista banker", keep the highlighted SJ Prio entry, "Välj bank". "3 Anslut": Historik 90 dagar, "Anslut med BankID"; a bank tab opens, scan the QR with BankID. Expected: the tab ends on a page that does not load (yes/no). Copy the whole address from that tab's address bar, paste it in "4 Klistra in", "Använd adressen". Expected: "5 Konton" shows your card account (yes/no; how many accounts). +8. "6 Hämta" › "Uppdatera". Expected: "7 Köp" lists your recent purchases and the newest ones look right (yes/no). Then "Bakgrundshämtning". Expected: it fetches, or shows an error (say which: fetched or error). +9. The next day: open `http://localhost:8765/` again (the agent starts the app), "Uppdatera" and "Bakgrundshämtning" without a new BankID. Expected: both work without BankID (yes/no each). +10. If step 6 worked: repeat steps 7 and 8 with your partner's BankID for her card (yes/no each). + +## 9. Out of scope +- Anything in `server/**`, `android/**`, `web/**`, `contracts/**`, `infra/**` (K1 to K3), and NPM changes beyond the optional step 4. +- Writing real data anywhere outside `docs/spikes/k0/.local/`; reading `state.json` other than through `analysis_text`. +- Editing `SPEC.md` or `docs/decisions.md` (the report proposes the text). + +## 10. Report +- Branch `agent-k0`, head commit after rebasing; what was run (py_compile, the `git status` and grep checks). +- One line per K0 item of §5 with the answer, and the proposed SPEC v0.36 text for every `TODO(spec)` of §9.12 (CP-1 to CP-8, CP-13), plus proposed decision entries numbered from the brief. +- The owner steps done, with their yes/no; anything still to do, worded for relay. +- Whether the session is kept for K1 and where the key is. + +## 11. Numbering +Proposed decisions from the brief's range; the spec version the findings go into is v0.36. diff --git a/PLAN-K1.md b/PLAN-K1.md new file mode 100644 index 0000000..1ca3f93 --- /dev/null +++ b/PLAN-K1.md @@ -0,0 +1,65 @@ +# PLAN-K1: Card purchases, server + +## 1. Goal +The server side of card purchases (FR-22, SPEC §9.12, D-158 to D-175): migration 0004 (server schema 4), the provider registry and the Enable Banking adapter, the connect flow with the callback page, fetching with de-duplication, the background poller, the endpoints E-36 to E-44, the link clearing on expense delete, the configuration, and the contract (OpenAPI, examples, migration text, the `match_card_purchase` vectors). It is a contract change (§14.8) and owns the contract the Android (K2) and web (K3) plans build on; it lands first. + +## 2. Read first +1. SPEC §9.12 (all of it, CP-1 to CP-14) with the K0 answers recorded in v0.36, FR-22, §5.1, §6.1, §6.3, §7, §8 (§8.1, §8.4, §8.6, §8.7, §8.9), §9.6, §10.1, §10.2, §10.4, §10.5.2, §10.9, §10.10, §14.1 (§14.1.3 is the migration), §14.3 (error codes, wire objects, E-36 to E-44), §14.4.1, §14.4.4 step 7, §14.5.5, §14.5.8, §14.6, §14.7, §14.8; D-158 to D-176. +2. `docs/spikes/K0-enable-banking.md` (K0's findings) and `docs/spikes/k0/app.py` (a working client of the same API: JWT, `/auth`, `/sessions`, transactions with PSU headers and `continuation_key`). +3. Enable Banking's official API reference at implementation time (`CLAUDE.md` "Fast-moving APIs"): JWT claims, `/aspsps`, `/auth`, `/sessions`, `/accounts/{uid}/transactions`, `DELETE /sessions/{id}`, error codes (`WRONG_TRANSACTIONS_PERIOD`, rate-limit and expired-session errors), PSU headers. +4. `server/README.md`, `src/router.rs`, `src/api/rate_limit.rs`, `src/tasks/`, `src/push/` (the FCM JWT and the reqwest client to reuse), `src/api/landing.rs` (the model for a bilingual HTML page), `src/sync/` (where step 7 of §14.4.4 lives), `tests/common/`; `contracts/openapi.yaml`, `tools/contracts-check.py`; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md`. +5. `CLAUDE.md` (server gate, no `unwrap`/`expect`, vendor adapters, contracts, personal data). + +## 3. Already established +- Everything in §9.12 and §14 for cards is binding; where K0 left a `TODO(spec)` with a default, the default holds and the report says so. +- Module layout (§14.7, D-160): `src/cards/{mod.rs, providers.rs, api.rs, fetch.rs, poller.rs, callback.rs}` and `src/cards/enable_banking/` (the only code naming the vendor or parsing its JSON, behind a `CardSource` trait; mapped into Kvitt's own records before anything else sees them). Provider ids are open strings. No new crate unless the report justifies it. +- Credentials (D-161, D-175): the private key is read from `KVITT_EB_KEY_FILE` (and the member's), never logged, never in `data/`; JWT RS256 with `kid` = the application id, `iss` `enablebanking.com`, `aud` `api.enablebanking.com`, short expiry, cached like the FCM token. Session ids are never logged or sent to a client. +- The attempt's `state` is stored only as its SHA-256 hash; E-39's response does not reveal whether a `state` existed (CP-2). +- Card rows use `card_sequence` and never touch `server_version` (§14.1.3). E-40 mirrors §14.4.2's paging and `cursor_ahead`. +- Logging (§8.7): one aggregate `card_fetch` info line per fetch; no merchant, amount, purchase date, session id, account uid, `code` or `state`, no vendor response body. A test asserts the log output of a fetch and of a callback holds none of the fixture's merchant texts, amounts or the `state`. +- The poller (CP-4) runs from `tasks::start` and reads `AppState.clock` like the scheduler (§9.5), so tests drive it with a scripted clock. +- The vectors `contracts/test-vectors/match_card_purchase.json` and their stdlib Python reference in `tools/contracts-check.py` are this plan's (§14.5.8: the cases listed there at least); Rust implements no matching. +- `web/src/api/schema.d.ts` is regenerated in the same landing (`npm run api:types` in `web/`, §15.3); it is the one file under `web/` this plan writes. + +## 4. Owned paths +From SPEC §14.7 (row K1): `server/**`, `contracts/**`, `tools/contracts-check.py`, `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4's block and a new "Card purchases" runbook section only), and `web/src/api/schema.d.ts` for its regeneration. Not `SPEC.md`, `docs/**`, `android/**`, the rest of `web/**`, `.gitea/**`. + +## 5. Deliverables +- Migration `0004_card_purchases.sql` in `contracts/schema/migrations/` and `server/crates/kvitt-server/migrations/`, byte-identical (identity test), `SCHEMA_VERSION` 4, the importer's check follows; the migration test of §14.1.3; pruning of `card_auth_attempts` in the 04:00 job. +- The registry with the one entry of CP-1 (ASPSP name and country from K0), the adapter, the fetch and mapping of CP-7, the de-duplication of CP-8, the consent rules of CP-6, the errors of CP-14, the disconnect of CP-12. +- E-36 to E-44 in the server and in `contracts/openapi.yaml` (the OpenAPI equality test passes), the four new error codes of §14.3.2, examples per §14.3.5, `tools/contracts-check.py` following them (operations E-1 to E-44). +- E-39's page with the strings of §13.3 "Card callback page", in both languages, with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. +- The link clearing in the sync push's delete step (§14.4.4 step 7) with a test (a linked expense deleted by the other user moves the purchase back, bumps `card_version`, writes no audit entry and no push). +- Rate limits of §8.6 for E-39, E-37 and E-42; authorization of §8.4 (the other user's ids are `not_found`) tested per endpoint. +- Integration tests against a local Enable Banking stub (an axum server in the test harness, pointed at by `KVITT_EB_API_URL`), fixtures built from K0's field-presence findings with invented merchants and amounts: connect success, cancel, unknown and replayed `state`, expired attempt, several accounts, first fetch with paging, `WRONG_TRANSACTIONS_PERIOD` then `strategy=longest`, pending then booked with and without `entry_reference`, a vanished pending row, a credit, a non-SEK row, a zero row, background quota (4 per 24 hours, the 2-hour skip, no backlog after downtime), user-present PSU headers (the ASPSP's required set, all or none), the 60-second rule, consent expiry and renewal keeping purchases, disconnect with and without `erase_handled`, bank errors recorded per CP-14, E-40 paging and `cursor_ahead`, E-44's rules and `expense_already_linked`. +- Configuration (§10.4): `KVITT_EB_APP_ID`, `KVITT_EB_KEY_FILE`, `KVITT_EB_MEMBER_APP_ID`, `KVITT_EB_MEMBER_KEY_FILE`, `KVITT_EB_API_URL`; card features off with one start-up warning when missing. +- Infra: `infra/pi/compose.yaml` mounts `./secrets/cards:/secrets/cards:ro` (`create_host_path: false`) and passes the two ids; `infra/pi/.env.example` gains the two optional keys (empty); `infra/dev/up.sh` copies `infra/secrets/cards/` into `infra/dev/secrets/cards/` (only that folder and the FCM key, D-117); `infra/npm/kvitt.conf` and `infra/README.md` step 4 carry the §10.5.2 block with the gate line and `location = /cards/callback`, checked with `nginx -t` and a request from a non-LAN address that reaches a stub upstream and leaves no access-log line; a "Card purchases" section in `infra/README.md` (key files, mode 0600, the folder 0700, `.env` ids, renewing every ≤180 days in the app, the `card_fetch` log line). + +## 6. Steps +1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-k1 -b agent-k1 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`. Copy from the main checkout only what a dev-stack check needs (`infra/secrets/fcm-service-account.json`; `infra/secrets/cards/` if the owner has put the key there). Confirm SPEC v0.36 (K0's answers) is on `main`; if not, stop and report. +2. `cargo` dependency check (`cargo outdated` is not installed; read `Cargo.lock` against crates.io for the crates you touch) and `python3 tools/dependency-report/latest.py` for the record. +3. Contract first: the migration text, the OpenAPI operations and examples, the vectors and the checker; then the server; then infra. +4. Never call the real Enable Banking API from a test. A manual end-to-end check with the real card is the owner's (§8), against the development stack on the Mac only if the orchestrator's brief allows it (it needs the Kvitt callback URL to reach the Mac, which it does not; so the real flow is checked after the Pi deploy). +5. Gates (§7). Commit on `agent-k1`, `git rebase main`, rerun the gates. Never merge, push or remove the worktree. + +## 7. Gate +The server gate, from `server/`: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test`; `python3 tools/contracts-check.py`; the web gate (`npm run check` in `web/`, for the regenerated types); `docker compose -f infra/pi/compose.yaml --env-file infra/pi/.env.example config` and `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config`; `shellcheck` on changed scripts; the nginx check of §5. Report the real result lines. + +## 8. HUMAN steps (relayed by the orchestrator in chat, after K1 has landed and its image is built) +1. On the Mac: create the folder `infra/secrets/cards/` in the main checkout and put the key there as `enable-banking.pem` (the agent copies it from `docs/spikes/k0/.local/` if the owner says yes; never committed). If the partner has her own application (K0's answer), also `enable-banking-member.pem`. +2. On the Kvitt Pi in `~/kvitt`: `mkdir -p secrets/cards && chmod 700 secrets/cards`; copy the key file(s) from the Mac with `scp infra/secrets/cards/enable-banking.pem jacobhagstedt@:kvitt/secrets/cards/`, then `chmod 600 secrets/cards/*`; copy the new `compose.yaml`; in `.env` set `KVITT_EB_APP_ID=` to the application id (the name of the key file before `.pem`) and, only with a separate partner application, `KVITT_EB_MEMBER_APP_ID=`. +3. NPM › Hosts › Proxy Hosts › the Kvitt row › Edit › Advanced: replace the Custom Nginx Configuration with the current `infra/npm/kvitt.conf` content (with the key in place of `KVITT_APP_KEY`), Save. Expected: row Online (yes/no). +4. Deploy the new image (§10.10: backup first, then `KVITT_TAG`, `docker compose pull && docker compose up -d`). Expected: `server` healthy (yes/no); `curl -s localhost:8080/health` shows `"schema_version":4` (yes/no). + +## 9. Out of scope +- The Android app (K2) and the web pages (K3); `docs/ux/strings.csv` (K2). +- `SPEC.md`, `docs/decisions.md` (proposed in the report); `.gitea/**` (the workflow needs no change: `server/**` and `contracts/**` already trigger it). +- Push for card purchases, bulk add, automatic linking (D-171). + +## 10. Report +- Branch `agent-k1`, head commit after rebasing; gate result lines (server tests, contracts-check count, web gate, compose config, nginx check). +- Every K0 default that was kept, and every deviation from §9.12 or §14 with the proposed SPEC text and decision entry (numbered from the brief): in particular the exact OpenAPI shapes if they differ from §14.3.3, the vendor error codes mapped to CP-14, the PSU header handling. +- Contract files changed; `web/src/api/schema.d.ts` regenerated; the owner steps worded for relay; open questions with SPEC section numbers. + +## 11. Numbering +Server schema 4 (migration 0004), sync protocol and push payload unchanged; decisions and spec version from the start prompt (`TODO(spec)` if none was given). diff --git a/PLAN-K2.md b/PLAN-K2.md new file mode 100644 index 0000000..8916fea --- /dev/null +++ b/PLAN-K2.md @@ -0,0 +1,69 @@ +# PLAN-K2: Card purchases, Android + +## 1. Goal +Card purchases in the Android app (FR-22, SPEC §9.12, D-169, D-170, D-172, D-173): the Kortköp tab as the second bottom destination, Anslut kort in Settings with the SJ Prio artwork, the BankID connect flow in a Custom Tab, the purchase sheet with "Skapa utgift", suggested matches and "Ignorera", "Från kortköp" on account detail, the card line on expense detail, offline-capable link changes, Room version 2. A UI-contract change (`:core:model` is a contract file, §14.8) with data layer, fakes, features and goldens. + +## 2. Read first +1. SPEC §9.12 (CP-1 to CP-14, with K0's answers in v0.36), FR-22, §9.9, §9.11 (the suggestion on the prefilled description), §13.2 (four destinations), §13.3 rows S-5, S-7, S-8, S-13, S-23 to S-27 and the "Card purchase strings" table, §14.2 (§14.2.1 card entities, §14.2.3 version 2, §14.2.4 "Changes in v0.35"), §14.3.3 and §14.3.4 E-36 to E-44, §14.4.1, §14.5.8, §6.2 (module table, layering, feature convention), §14.7, §14.8 (compatibility); D-169, D-170, D-172, D-173. +2. K1's `contracts/openapi.yaml` operations E-36 to E-44, `contracts/examples/E-36-*` to `E-44-*`, `contracts/test-vectors/match_card_purchase.json` (on `main` once K1 lands; until then the spec). +3. `docs/ux/design-spec.md` §2 (tokens), §3.4, §3.5 (ledger rows), §3.6 (banners), §3.11 (scaffolding), S-4, S-5, S-8, S-13, S-16 (for list and banner patterns); `docs/ux/strings.csv`. +4. Files: `android/core/model/**`, `android/domain/**`, `android/data/**` (`KvittDatabase`, sync engine, `KvittApi`, `DataBindingsModule`), `android/core/fakes/**`, `android/core/design/**` (`KvittNavigationBar`, `KvittDestination`), `android/feature/{account,expense,settings}/**`, `android/app/**` (`nav/KvittApp.kt`, the manifest's intent filters), `android/README.md`. +5. `CLAUDE.md` (Android gate, layering, anti-patterns, screenshots, the phone, personal data). + +## 3. Already established +- The UI contract of §14.2.4 "Changes in v0.35" is binding (names and members); additions need a decision, proposed in the report. +- New module `:feature:cards` (`android/feature/cards/`, package `se.hagstedt.kvitt.feature.cards`), included in `android/settings.gradle.kts`, following the feature convention (D-017): one entry composable per screen, navigation as lambdas, ViewModels on `:core:model` interfaces only, strings prefixed `cards_`. +- `:core:design`: `KvittDestination.Cards` between `Home` and `Analytics`, icon `credit_card` (Material Symbols, outlined like the others), label `nav_cards`; every top-level screen's bar shows four items, so the S-3, S-4, S-11, S-13 goldens change (re-record and open each). +- Card artwork (D-173): `CardArt(imageKey, name, modifier)` in `:feature:cards` maps `sj_prio_mastercard` to `R.drawable.card_sj_prio_mastercard` when the owner's file exists at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png`; otherwise, and for any unknown key, it draws the fallback card: aspect ratio 1.586:1, corner radius 16 dp, fill the `green` category token's light value in both themes, the card name in white (contrast 4.95:1), no other text. The goldens use whatever is committed at recording time; when the owner's file arrives later the mapping line is added and the goldens re-recorded (a follow-up, not a blocker). The image's use was cleared by the owner (2026-10-24, the "Licences first" step). +- Custom Tab: `androidx.browser` (catalog entry, latest stable at implementation time, documented usage confirmed from the official docs). The deep link `kvitt://cards/connected?attempt=` is an intent filter on `MainActivity` (exported already); `:app` routes it. +- Room version 2 with `MIGRATION_1_2` and its test (§14.2.3), the exported `2.json` committed. The revoke wipe includes the card tables. +- The card cycle (§14.2.4 `:data`) runs after every household sync cycle and after `refresh`; pending link changes go out after the household push (CP-9). +- `matchCardPurchase` in `:domain` passes `match_card_purchase.json` in the vector test. +- No card content in any log, debug builds included; no notification for card purchases (D-171). +- Strings: the keys of §13.3's table are added to `docs/ux/strings.csv` (en and sv) by this plan, with any TalkBack keys it needs (listed in the report); then to the modules' `strings.xml`. + +## 4. Owned paths +From SPEC §14.7 (row K2): `android/feature/cards/**` (new), and as a contract-change plan `android/core/model/**`, `android/domain/**`, `android/data/**` (with `android/data/schemas/**`), `android/core/fakes/**`; `android/core/design/**` (the destination and `nav_cards` only), `android/feature/account/**` (S-5's action), `android/feature/expense/**` (S-7's prefill and link, S-8's line), `android/feature/settings/**` (S-13's row), `android/app/**` (navigation, deep link, bindings), `android/settings.gradle.kts`, `android/gradle/libs.versions.toml`, `docs/ux/strings.csv` (the card keys). Nothing in `server/**`, `contracts/**`, `web/**`, `SPEC.md`, `docs/decisions.md`. + +## 5. Deliverables +- `:core:model` `model/Cards.kt` and the members of §14.2.4; `ContractTest` updated. +- `:domain` `matchCardPurchase` with its vector test. +- `:data`: DTOs for E-36 to E-44 with round-trip tests over K1's examples; `CardRepositoryImpl`; the card cycle with `MockEngine` tests (paging, tombstones, pending rows skipped on pull, the E-44 outcomes of §14.2.1, a 404 from E-40 as "no card support", `cursor_ahead`); `prefill` (CP-9) tests; the local unlink when an expense is deleted locally; Room 2 and the migration test. +- `:core:fakes`: `FakeCardRepository` and `fakes/cards/` scenarios for every state of S-23 to S-27 (§14.2.4 list). +- `:feature:cards`: S-23 (with the sheet and the second sheet "Välj en annan utgift"), S-24, S-25 (polling E-38 every 2 seconds while open and resumed, the Custom Tab, the return link), S-26 (with the disconnect dialog), S-27; screenshot tests of every state in the D-098 variants (sv light, sv dark, en light, plus `_sv_light_200` for the long states); state-holder unit tests. +- `:feature:expense`: S-7 with the optional `cardPurchaseId` argument (prefill, then `link` after a successful save; a foreign-currency purchase leaves the amount empty with the S-23 note), S-8's line "Från kortköp: , " with "Ta bort koppling"; goldens for the new states. +- `:feature:account`: S-5's overflow item "Från kortköp" while `hasConnection`; `:feature:settings`: S-13's row "Anslut kort" with its subtitle; goldens re-recorded where they change. +- `:app`: the fourth destination, routes for S-23 to S-27, the S-7 argument, the deep link, the binding in `DataBindingsModule`. + +## 6. Steps +1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-k2 -b agent-k2 main`; in it `git reset --hard main`; copy `android/local.properties` and `android/app/google-services.json` from the main checkout (never commit them); `tools/install-git-hooks.sh`. +2. Confirm SPEC v0.36 (K0's answers) is on `main`; if not, stop and report. K2 may start before K1 lands, on the spec and fakes; before its gate it rebases on K1 (DTO tests need K1's examples, the vectors file is K1's). If K1 has not landed when everything else is done, stop and report rather than inventing examples. +3. `python3 tools/dependency-report/latest.py`; record the lines. +4. Model, domain, data (Room 2 first, with its test), fakes, `:feature:cards`, the edits in the other features and `:app`, strings, goldens (open every changed PNG). +5. Phone (only if the brief names this agent for it; one agent on the phone at a time): confirm the ids (`adb shell pm list packages | grep se.hagstedt.kvitt`), `installDebug` of the debug app against the development stack for the screens and offline behaviour; the real BankID flow needs the release app against the Pi with K1 deployed (§8). Never clear the real app's data. +6. Gate (§7). Commit on `agent-k2`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree. + +## 7. Gate +The Android gate, from `android/`: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check`. Green: `BUILD SUCCESSFUL` in this session, test counts from fresh XML, screenshot verification passing. + +## 8. HUMAN steps (relayed by the orchestrator in chat) +1. The card artwork: put the SJ Prio Mastercard picture (PNG, at least 1012 × 638 px, the card's own aspect ratio) at `/Users/jhag/projects/kvitt/android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` and say done; until then the app draws a green card with the name. +2. After K1 is deployed on the Pi and the K2 release APK is installed (Auto Blocker off for the update, back on afterwards): Inställningar › Anslut kort › SJ Prio Mastercard › 30 dagar › "Anslut med BankID"; BankID opens, sign; back in Kvitt "Kortet är anslutet" and the Kortköp tab lists your purchases (yes/no each). +3. Kortköp › a purchase › "Skapa utgift" › "Spara": the expense is in its account and the purchase is under "Kopplade" (yes/no). +4. On a purchase your partner already entered: the row shows "Koppla till …"; tap it: under "Kopplade" (yes/no). +5. Flight mode on: ignore a purchase, then flight mode off: it stays ignored after a sync (yes/no). +6. An account › menu › "Från kortköp": a purchase › "Spara": the expense is in that account (yes/no). +7. Inställningar › Anslut kort › SJ Prio Mastercard › "Koppla från" › "Koppla från": the Kortköp tab shows "Anslut ditt kort" and the linked purchases are kept under "Kopplade" after reconnecting (yes/no). +8. On the partner's phone (after she connects her card): Kortköp shows only her purchases, and none of yours anywhere (yes/no). + +## 9. Out of scope +- The server, the contract files, the web (K1, K3). A card notification (OQ-7), bulk add (OQ-8), automatic linking (D-171). +- Changes to `docs/ux/design-spec.md` (the report proposes the S-23 to S-27 design-spec sections for a docs agent). + +## 10. Report +- Branch `agent-k2`, head commit after rebasing; gate lines; tests; goldens added and re-recorded (names). +- Proposed decision entries and spec text for anything beyond §14.2.4 (numbered from the brief); the strings added to `strings.csv` (keys); proposed design-spec sections for S-23 to S-27. +- Dependency-report lines; whether the owner's artwork was present; HUMAN steps outstanding worded for relay; open questions with SPEC section numbers. + +## 11. Numbering +Room version 2; decisions and spec version from the start prompt (`TODO(spec)` if none was given). diff --git a/PLAN-K3.md b/PLAN-K3.md new file mode 100644 index 0000000..7df0307 --- /dev/null +++ b/PLAN-K3.md @@ -0,0 +1,51 @@ +# PLAN-K3: Card purchases, web + +## 1. Goal +The card features of FR-22 in the web client (D-174): W-21 Kortköp, W-22 Anslut kort with BankID by QR in the browser, W-23 the connection's settings, the sidebar entry, "Skapa utgift" through W-5 with the prefill of CP-9, the card line on W-6, "Från kortköp" on W-3, and `matchCardPurchase` in TypeScript passing its vectors. + +## 2. Read first +1. SPEC §15 (§15.3 strings and layout, §15.5 data, §15.6 rows W-3, W-5, W-6, W-21 to W-23, the navigation paragraph), §9.12 (CP-1 to CP-14, with K0's answers in v0.36), FR-22, §13.3 rows S-23 to S-27, the "Card callback page" and "Card purchase strings" tables, §14.3.3, §14.3.4 E-36 to E-44, §14.5.8, §14.7; D-174, D-169, D-170, D-172. +2. K1's `contracts/openapi.yaml` and `web/src/api/schema.d.ts` (regenerated by K1), `contracts/examples/E-36-*` to `E-44-*`, `contracts/test-vectors/match_card_purchase.json`. +3. `web/README.md`, `web/src/app/` (routes, layout, sidebar), `web/src/api/` (client, snapshot), `web/src/lib/domain/`, `web/src/components/`, `web/src/features/{expenses,accounts,settings}/`; `docs/ux/design-spec.md` §2 and the S-23 to S-27 design (K2's report, if landed). +4. `CLAUDE.md` (web gate, web layering). + +## 3. Already established +- Card data is read through E-40 into an in-memory card snapshot next to the household snapshot (§15.5: pulled from 0 on load, from its cursor on focus and after every card write); nothing persisted across a reload; writes (E-44) go at once, no queue. +- Connect: E-37 with `client` `web`; the browser goes to the returned URL in the same tab; the bank shows its QR for BankID on the phone; the callback page's link returns to `/app/cards?attempt=`, where W-21 polls E-38 every 2 seconds until the attempt ends, then drops the parameter (CP-2). +- "Skapa utgift" opens W-5 at `/app/expenses/new?cardPurchase=` (plus `&account=` from W-3's "Från kortköp", `/app/cards?account=`); W-5 prefills per CP-9, runs the §9.11 suggestion as for any new expense, and after an accepted save sends E-44 with the new expense id. +- Artwork (D-174): `web/src/features/cards/assets/card-sj-prio-mastercard.png` when the owner has put it there, mapped from the image key `sj_prio_mastercard`; otherwise, and for any unknown key, the drawn fallback of D-173 (CSS: aspect ratio 1.586, radius 16 px, the `green` category token's light value, the name in white). Served from the server's own origin (CSP `img-src 'self'`). +- Strings: the keys of §13.3's table (K2 adds them to `docs/ux/strings.csv`; `npm run i18n:import` regenerates the dictionaries); web-only strings get `web_cards_` keys in `web/src/features/cards/i18n/{sv,en}.json`. +- The sidebar and bottom bar gain "Kortköp" after Home (D-172). + +## 4. Owned paths +From SPEC §14.7 (row K3): `web/src/features/cards/**` (new); in shared code the route in `web/src/app/`, the sidebar entry and `web/src/lib/domain/` (`matchCardPurchase` and its vector test); `web/src/features/expenses/**` (W-5's prefill and link, W-6's card line), `web/src/features/accounts/**` (W-3's "Från kortköp"), `web/src/features/settings/**` (W-13's row); the regenerated i18n dictionaries. Not `web/src/api/schema.d.ts` (K1), `docs/ux/**`, `server/**`, `contracts/**`, `android/**`. + +## 5. Deliverables +- The card snapshot and API calls; W-21 (header, filters, day groups, the purchase panel or dialog with every action of S-23's sheet, match chips, the `?attempt=` and `?account=` handling), W-22 (catalogue with artwork, history choice, "Anslut med BankID"), W-23 (as S-26 with the disconnect dialog); W-5, W-6, W-3 and W-13 changes; the sidebar entry. +- `matchCardPurchase` passing `match_card_purchase.json` in `web/src/lib/vectors.test.ts` (or the domain's vector test), listed as ported. +- Tests: pages against the fake server with every state of §13.3 (no connection, first fetch, loaded, empty filter, expiring, expired, fetch failed, long content), the attempt polling, E-44 outcomes (`expense_already_linked`, `not_found`), the prefill rules. + +## 6. Steps +1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-k3 -b agent-k3 main`; in it `git reset --hard main`; `npm ci` in `web/`; `tools/install-git-hooks.sh`. +2. Confirm SPEC v0.36 is on `main`. K3 may start before K1 lands, against the spec and the fake server; before its gate it rebases on K1 (the generated types and the vectors are K1's) and on K2 (the card strings in `docs/ux/strings.csv`; then `npm run i18n:import`). If K1 or K2 has not landed when the rest is done, stop and report. +3. Build; check manually against a throwaway server (`cargo run -p kvitt-server -- serve` with a temporary `KVITT_DATA_DIR` and `KVITT_EB_API_URL` pointed at a stub, as K1's tests do), never the owner's development or Pi data. +4. Gate (§7). Commit on `agent-k3`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree. + +## 7. Gate +The web gate, from `web/`: `npm run check`. Report the real result lines (test files, tests). + +## 8. HUMAN steps (relayed by the orchestrator in chat, after K1 is deployed and an image with K3 is deployed) +1. The web copy of the artwork: put the same SJ Prio Mastercard PNG at `/Users/jhag/projects/kvitt/web/src/features/cards/assets/card-sj-prio-mastercard.png` and say done. +2. On the laptop, `https://kvitt.suorra.duckdns.org/app/` › Inställningar › Anslut kort: the SJ Prio card shows "Ansluten" if the phone connected it (yes/no). Kortköp lists the same purchases as on the phone (yes/no). +3. Kortköp › a purchase › "Skapa utgift" › "Spara": the purchase is under "Kopplade" on the laptop and on the phone after a sync (yes/no). +4. If the card is not connected yet or after "Koppla från": Anslut kort › SJ Prio Mastercard › "Anslut med BankID": the bank's page shows a QR; scan it with BankID on the phone; "Tillbaka till Kvitt" opens Kortköp with your purchases (yes/no each). + +## 9. Out of scope +- The server and the contract (K1), the app (K2); `docs/ux/strings.csv` (K2); a card notification and bulk add (D-171). + +## 10. Report +- Branch `agent-k3`, head commit after rebasing; gate lines. +- `web_cards_` strings added; proposed decision entries and spec text for anything beyond §15.6 and §9.12 (numbered from the brief); HUMAN steps outstanding worded for relay; open questions with SPEC section numbers. + +## 11. Numbering +From the start prompt (`TODO(spec)` if none was given). diff --git a/SPEC.md b/SPEC.md index 4361a94..a9a1749 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.34** (2026-10-24). Schema version **3**. Sync protocol version **1**. Push payload version **1**. +Version **0.35** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. @@ -26,6 +26,7 @@ It consists of: * A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements. * A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://` (the default HTTPS port 443, D-124), where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates. * A web client (D-129, §15): a React single-page app built into the server image and served by the server under `/app/`, for a browser on the owner's or the partner's computer. It has the app's features except the phone-only ones (pairing flow, push, offline use, in-app update), plus search, CSV export and keyboard shortcuts. It is online only. +* Card purchases (D-158, §9.12, FR-22): each user may connect their own payment card (at first the SJ Prio Mastercard) through the account-information service Enable Banking; the server reads that card's purchases and the card holder, and only the card holder, turns them into ordinary household expenses with a few taps. Read only: Kvitt never pays or moves money. Goals for v1: @@ -35,7 +36,7 @@ Goals for v1: 4. **G-4 Clear settling.** One global balance, a per-account breakdown, and a settle-up flow that proposes how a payment is spread over accounts. 5. **G-5 Insight.** Monthly and weekly spending by category and by account, computed on the phone; averages, trends and a two-month forecast computed on the server (D-133). 6. **G-6 Trust.** Every change to an expense or settlement is visible as a timeline of who did what. -7. **G-7 Low-maintenance hosting.** One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging and Google Drive for backups. +7. **G-7 Low-maintenance hosting.** One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging, Google Drive for backups and, for card purchases only, the account-information service Enable Banking (D-159). 8. **G-8 Bilingual.** Every user-visible string exists in Swedish and English. The phone's locale decides: Swedish when the device language is Swedish, English otherwise. 9. **G-9 History.** Six years of Splitwise history can be imported once, repeatably without duplicates. @@ -46,7 +47,7 @@ Goals for v1: Not in v1. Agents must not build any of these, even partially: * Receipt photos or receipt scanning. Profile pictures are the only file upload. -* Any card, bank, Spendwise or other financial-institution integration. +* ~~Any card, bank, Spendwise or other financial-institution integration.~~ Revised by D-158: reading the purchases of the users' own payment cards through Enable Banking (account information only, §9.12, FR-22) is in scope. Still out: payments or transfers of any kind, bank accounts other than the connected cards, Spendwise, and any other provider than those in the card catalogue (E-36). * Multiple currencies or exchange rates. All amounts are SEK. (The importer has a one-off conversion option, §4 FR-17.) * Joint accounts, shared cards or "paid from the shared pot" as a payer. The payer is always one of the two users. * More than two users, more than one household, user deletion. @@ -96,6 +97,8 @@ A user has one or more devices. v1 expects one phone each, but the model allows * **US-15** As the owner I import our Splitwise history once. * **US-16** As the owner I can restore everything from a backup if the Pi dies. * **US-17** As the owner I can remove a lost phone's access. +* **US-18** As a user I connect my own payment card with BankID once and then see my card purchases in Kvitt, updated by themselves, without typing them (D-158). +* **US-19** As a user I turn a card purchase into a household expense, or link it to the expense my partner already entered, in one or two taps, from the Kortköp tab or from an account (D-158). --- @@ -330,6 +333,26 @@ The web client of §15 (D-129 to D-134). Its acceptance criteria: * AC-21.11 Accessibility as §13.1, for the web: every action reachable by keyboard with a visible focus, a label for every icon-only control, text contrast at least 4.5:1, nothing conveyed by colour alone, semantic headings and landmarks. * AC-21.12 No service worker and no web app manifest; nothing is loaded from another origin at run time (§15.2). +### FR-22 Card purchases + +Each user may connect their own payment card; the server reads its purchases through Enable Banking and the card holder turns them into expenses (D-158 to D-176). Rules in §9.12 (CP-1 to CP-14); screens S-23 to S-27 (§13.3) and W-21 to W-23 (§15.6); endpoints E-36 to E-44 (§14.3.4). Its acceptance criteria: + +* AC-22.1 Settings (S-13) has the row "Anslut kort" ("Connect card"), which opens S-24: the server's card catalogue (E-36), each card with its artwork. v0.35 has exactly one entry, `sj_prio_mastercard` (SJ Prio Mastercard, SEB Kort). A provider the server has no Enable Banking application for (CP-13) is listed as "Inte konfigurerat på servern" and cannot be tapped. +* AC-22.2 Tapping a card that is not connected opens S-25: the history choice (CP-3) and "Anslut med BankID". The app creates an authorisation attempt (E-37), opens the returned bank URL in a Custom Tab, and polls the attempt (E-38) while S-25 is open; the bank (BankID: app-to-app on the phone, QR on a computer) redirects to `/cards/callback` (E-39), whose page says the card is connected and links back to the app. Within 5 seconds of returning, S-25 shows "Kortet är anslutet" and opens S-23 (CP-2). +* AC-22.3 Only the card holder sees their connection and purchases. A device of the other user that names a connection or purchase id gets `not_found` (CP-11). Nothing about a card purchase is in `GET /sync`, the audit log, a push, S-11, S-12, E-34 or E-35 until it is turned into an expense, which is then an ordinary household expense in all of them. +* AC-22.4 The Kortköp tab (S-23, the second bottom destination, §13.2) lists the holder's purchases newest first, grouped by day, with the filters "Att hantera" (default), "Kopplade", "Ignorerade", "Alla"; pending purchases carry "Preliminär"; deposits and refunds (`credit`) appear only under "Alla" (CP-8). The list works offline from the phone's copy (§14.2.1). +* AC-22.5 The list updates by itself: the server fetches each active connection in the background at most 4 times per 24 hours (CP-4); "Uppdatera" and pull to refresh on S-23 ask the server for a fetch with the user present (CP-5); S-23 shows "Uppdaterad " of the last successful fetch. +* AC-22.6 "Skapa utgift" on a purchase opens S-7 prefilled (CP-9): amount, description from the merchant text, the purchase date, the card holder as payer, the account, the category of §9.11 and the account's default split; "Spara" saves the expense and links the purchase to it. From the Kortköp list that is: tap the purchase, tap "Skapa utgift", tap "Spara". +* AC-22.7 The purchase sheet lists the household's existing expenses that match the purchase (CP-10, same amount, within ±3 days, not linked yet, entered by either user); one tap links. A row in "Att hantera" with exactly one match shows the chip "Koppla till ", which links in one tap. +* AC-22.8 "Ignorera" moves a purchase to "Ignorerade" (private, undoable with "Ångra" and "Sluta ignorera"); "Ta bort koppling" moves a linked purchase back to "Att hantera"; deleting a linked expense, by either user, moves its purchase back to "Att hantera" (CP-9). +* AC-22.9 Account detail (S-5) offers "Från kortköp" while the viewer has a connection: S-27 lists the viewer's purchases to handle, and one tap opens S-7 prefilled with that account. +* AC-22.10 The expense detail (S-8) shows the card holder, and only the card holder, the line "Från kortköp: , " with "Ta bort koppling". +* AC-22.11 From 14 days before the consent ends (`valid_until`, CP-6) S-23 shows "Kortanslutningen upphör " with "Förnya med BankID"; after it ends, S-23 shows "Kortanslutningen har upphört", keeps the stored purchases, disables "Uppdatera" and offers "Förnya med BankID". Renewing keeps every purchase and its state. +* AC-22.12 "Koppla från" (S-26, with confirmation) revokes the session at Enable Banking, deletes the purchases neither linked nor ignored, and keeps the linked and ignored ones unless "Radera även kopplade och ignorerade köp" is ticked; expenses already created stay (CP-12). +* AC-22.13 Linking, unlinking, ignoring and "Skapa utgift" work offline on the phone; the link changes are kept on the phone and sent after the next successful sync push (CP-9). Connecting, renewing, "Uppdatera", the history setting and "Koppla från" need the server (§9.9). +* AC-22.14 No merchant text, amount, session id, authorisation code or `state` reaches a server log, an NPM log or a phone log (§8.7, D-163). +* AC-22.15 The web client (K3) has the same features: W-21 Kortköp, W-22 Anslut kort with BankID by QR in the browser, W-23 the connection's settings (§15.6). + --- ## 5. Data model @@ -355,8 +378,12 @@ This section explains the entities and their meaning. The exact server DDL is § | Audit entry | server | yes (pull) | never | Append-only | | Sync mutation record | server | no | never | Idempotency of pushes | | Sync sequence, meta | server | no (epoch is sent in pull) | never | Global `server_version` counter, `sync_epoch` | +| Card connection (D-167) | server | the holder's phones only, through `GET /cards/sync` (E-40), never `GET /sync` | REST only, its own user | One per user and card provider; holds the Enable Banking session (server only) | +| Card purchase (D-167) | server (from the bank's transactions) | the holder's phones only, through E-40 | REST only (`PUT /cards/purchases/{id}`, E-44): link and ignore | Private to the card holder; `card_version` from `card_sequence` | +| Card auth attempt (D-162) | server | no | never (created by E-37) | Single-use `state` of one BankID connection attempt | +| Card sequence | server | no | never | `card_version` counter of card connections and purchases, separate from `server_version` | -Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). +Local-only phone tables: `sync_state` and `sync_rejections` (§14.2), and `card_sync_state` (§14.2.2, D-173). ### 5.2 Ownership of fields @@ -418,6 +445,8 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). Firebase Cloud Messaging ──► phones owner's M4 Mac: build linux/arm64 images ──► Gitea registry ``` +For card purchases (D-160) the server, and only the server, calls the Enable Banking API (`https://api.enablebanking.com`) over outbound HTTPS from the Kvitt Pi; the phones and the browser never talk to Enable Banking. The user's browser (a Custom Tab on the phone) visits Enable Banking's and the bank's pages for the BankID step and is redirected back to `https:///cards/callback` (E-39, §10.5.2). + A browser running the web client (§15) reaches the same server the same way: HTTPS to NPM, which lets `/app/`, the invite accept and bearer-token requests through from outside the LAN (§8.5, D-130), and plain HTTP to the server, which serves the web client's files from its own image (D-132). ### 6.2 Android app @@ -458,6 +487,7 @@ Module graph (directory `android/`): | `:feature:settle` | Android library | Settle up (global and per account), settlement detail and timeline | `:core:model`, `:core:design` | | `:feature:analytics` | Android library | Analytics screen and drill-down list | `:core:model`, `:core:design` | | `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` | +| `:feature:cards` | Android library (D-173, K2) | Kortköp tab (S-23) with the purchase sheet, Anslut kort (S-24), connect (S-25), the connection's settings (S-26), "Från kortköp" (S-27), the card artwork (`CardArt`) | `:core:model`, `:core:design` | | `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything | **Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `_sv_light.png`, `_sv_dark.png` and `_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that `:feature:home` and `:feature:account` captured the previous variant, so their committed `sv_dark` (and some `_sv_light_200` and dialog) goldens were one variant stale, fixed and re-recorded in `dc7a296` and `5b2ed37`. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. Since the Android overhaul (`c582363`, D-118) the harness is shared: `:core:design`'s test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`) hold `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with the capture modes `Root`, `Screen` and `ScreenWhenIdle`, `MainDispatcherRule` and `withMain`, consumed as `testImplementation(testFixtures(project(":core:design")))`. Test fixtures are not a module, so the module graph below is unchanged; the architecture check rejects a module's test fixtures in any configuration other than a test configuration. The convention plugin `kvitt.android.screenshots` wires Roborazzi (plugin, `outputDir` `src/test/screenshots`) and the test libraries; feature build scripts carry no screenshot block. @@ -492,6 +522,7 @@ Features never call `:domain` directly: repositories return computed state (bala | HTTP client (FCM) | reqwest with rustls (crypto provider aws-lc-rs, certificates checked by the platform verifier); needs a C toolchain at build time and CA roots in the image (§10.3) | No OpenSSL on the image | | FCM auth | Service-account JWT (RS256, jsonwebtoken crate, header `kid` = the key file's `private_key_id`) exchanged at `https://oauth2.googleapis.com/token` for an access token with scope `https://www.googleapis.com/auth/firebase.messaging`, cached until 5 minutes before expiry | HTTP v1 API needs OAuth2 | | Scheduler | A tokio task inside `kvitt-server` (no extra crate) | One process, simple | +| Card purchases (D-160, K1) | `kvitt-server` module `src/cards/`: the provider registry (`providers.rs`), the `CardSource` trait and its one adapter `src/cards/enable_banking/` (the only code that names the vendor or reads its JSON; it maps it into Kvitt's own records), fetching and de-duplication (§9.12), the background poller (started from `src/tasks/`), the callback page; HTTP to Enable Banking through the existing reqwest client with rustls, JWTs signed RS256 with jsonwebtoken (both already in the stack) | Vendor isolated per `CLAUDE.md`; no new crate | Cargo workspace (directory `server/`): @@ -563,8 +594,17 @@ Base URL: `https://` (the value of `KVITT_PUBLIC_URL`, port 4 | E-33 | POST | `/settlements/proposal` | device | Server-side allocation proposal | | E-34 | GET | `/analytics/trends` | device | Spending per month, averages and trends, household and per category (D-133, D-138) | | E-35 | GET | `/analytics/forecast` | device | Forecast of the next two calendar months per account (D-133, D-138) | +| E-36 | GET | `/cards/providers` | device | The card catalogue: supported cards, artwork key, whether this server can connect them for the caller (D-160) | +| E-37 | POST | `/cards/auth` | device | Start connecting (or renewing) a card: an authorisation attempt and the bank URL (D-162) | +| E-38 | GET | `/cards/auth/{attempt_id}` | device | Status of the caller's authorisation attempt (D-162) | +| E-39 | GET | `/cards/callback` | none | The bank's redirect target: finishes the attempt, HTML page (D-162, D-163) | +| E-40 | GET | `/cards/sync` | device | The caller's card connections and purchases since a card cursor (D-167) | +| E-41 | PATCH | `/cards/connections/{connection_id}` | device | Shorten the history setting (D-164) | +| E-42 | POST | `/cards/connections/{connection_id}/refresh` | device | Fetch from the bank now, user present (D-165) | +| E-43 | DELETE | `/cards/connections/{connection_id}` | device | Disconnect (D-167) | +| E-44 | PUT | `/cards/purchases/{purchase_id}` | device | Link to an expense, unlink, ignore (D-169) | -Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`; E-34 and E-35 are computed read-only views over them (§14.5.7), not a second way to read rows. The web client's static files under `/app/` (§15.4, D-135) are not API endpoints: they have no E-number, no protocol check and no entry in `contracts/openapi.yaml`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately. +Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`; E-34 and E-35 are computed read-only views over them (§14.5.7), not a second way to read rows. Card connections and purchases are not household rows: they are read only through E-40, by their holder (D-161). The web client's static files under `/app/` (§15.4, D-135) are not API endpoints: they have no E-number, no protocol check and no entry in `contracts/openapi.yaml`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately. Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3.1 file `contracts/openapi.yaml` is written by milestone M1 from §14.3, which is binding. @@ -576,6 +616,8 @@ Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3 Two trusted users. The server is on the public internet on port 443, through NPM (D-120, D-124); outside the home LAN NPM passes only requests that carry the app key (§8.5, D-126). Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. Accepted risk (owner, 2026-10-19, security review M-1): the Drive backups stay unencrypted and the rclone token keeps the full `drive` scope, so a compromised Google account or `rclone.conf` exposes every backup (§8.7, §10.9). +Card purchases (D-161, D-163) add: the Enable Banking application's private key (in `secrets/cards/` on the Pi, never in the database or a backup) and the Enable Banking session ids (in the database) are credentials to read the connected cards' transactions; a session id alone is useless without the private key, so a leaked backup or database does not expose the cards, and a leaked key without the database names no session. Kvitt can never pay or move money: the access is PSD2 account information only. In scope also: a forged or replayed bank redirect (single-use `state` bound to user and device, short expiry, CP-2), the authorisation code in a URL reaching a log (`/cards/callback` is never logged, §10.5.2), and one user seeing the other's card purchases (holder-only authorization, §8.4). Card purchases are personal content: everything §8.7 says about descriptions and amounts applies to merchant texts and card amounts. + ### 8.2 Device tokens * On a successful invite accept the server generates 32 random bytes from the OS CSPRNG and returns the token `kvt_` + base64url without padding of those bytes (47 characters total). @@ -604,6 +646,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM | Create, list, cancel invites | yes | no (`forbidden`) | | List devices | all | own only | | Revoke a device | any except current | own except current (another user's device: `not_found`, D-156) | +| Card connections and purchases (E-37 to E-44) | own only; the other user's: `not_found` (D-161) | own only; the other user's: `not_found` (D-161) | ### 8.5 Transport @@ -621,6 +664,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM * Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without `X-Kvitt-Protocol` became accepted for good, bypassed the IP limit and was never pruned). All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4 in `15056dc`, D-032 fulfilled). * `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted. * Invite accept failures: AC-2.12. +* Card purchases (D-162, D-165): the bank's redirect `GET /cards/callback` (E-39) counts as an unauthenticated endpoint (60 per minute per client IP). E-37 keeps at most one pending attempt per user and provider (a new one expires the previous). E-42 contacts the bank at most once per connection per 60 seconds; a request within 60 seconds of the previous user-present fetch's start answers the current connection without contacting the bank (CP-5). Background fetches are capped at 4 per connection per 24 hours (CP-4). * Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. Both limits are layers in `router.rs` (§6.3). * JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed, `notes` included (D-034). Control characters other than newline are refused with `validation_failed` in `notes` only; this is intended. Names and descriptions accept newlines and are not checked for control characters. * The rate limiter is in memory (resets on restart); no table. @@ -632,6 +676,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM * Nginx Proxy Manager's access log (`proxy-host-_access.log` inside NPM) records the full `$request_uri`, query string included, on every path except `/i/`; the `location ^~ /i/` of the Custom Nginx Configuration has `access_log off` and `error_log /dev/null crit`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. NPM logs no request headers, so the `Authorization` token is never logged. The API's other query parameters (`since`, `limit`, `size`, `v`) hold no personal content (§10.5.2, D-120, revises D-117). * The push sender logs one info line `push sent` per message with aggregates only: `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line is at debug (D-113). * FCM payloads carry ids and types only (§14.6). +* Card purchases (D-161, D-163): server logs carry no merchant text, amount, date of a purchase, Enable Banking session id, account uid, authorisation `code` or `state`, and no Enable Banking response body; the card adapter logs one info line per fetch with aggregates only (`card_fetch`: provider id, kind `background` or `user`, the counts of new, updated and removed purchases, the HTTP status class of the bank call, and the last 8 characters of the connection id). `/cards/callback` is logged by the server under its route template only, and NPM does not log it at all (§10.5.2). The phone never logs card content (`CLAUDE.md`). Card data is never in a push, the audit log or `GET /sync`. Backups (§10.9) include the card tables in the SQLite snapshot, like every table; the private key is not in `data/` and is never backed up. * Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. The owner decided on 2026-10-19 not to encrypt them with an rclone `crypt` remote and to keep the token's `drive` scope (security review M-1 closed as an accepted risk, §8.1, `docs/validation/security-review.md`). ### 8.8 Phone hardening @@ -642,7 +687,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM ### 8.9 Server hardening -* The server container runs as a non-root uid/gid: on the Kvitt Pi the owner's (`KVITT_UID`/`KVITT_GID` in `.env`, 1000:1000, D-125), on the development stack and as the image's default `10001`; with a read-only root filesystem and a 16 MB tmpfs `/tmp`, `/data` as the only writable volume, its one secret file read-only, no Linux capabilities, `no-new-privileges`. +* The server container runs as a non-root uid/gid: on the Kvitt Pi the owner's (`KVITT_UID`/`KVITT_GID` in `.env`, 1000:1000, D-125), on the development stack and as the image's default `10001`; with a read-only root filesystem and a 16 MB tmpfs `/tmp`, `/data` as the only writable volume, its secrets read-only (the FCM key file and, since D-175, the folder `secrets/cards/` with the Enable Banking private keys), no Linux capabilities, `no-new-privileges`. * The TLS-terminating process is the owner's Nginx Proxy Manager on the Home Assistant Pi; its hardening is that install's, outside this repository (D-120). * Only the server publishes a port (8080 on the Kvitt Pi's LAN; the router forwards only to NPM). The `backup` sidecar publishes no port and is reachable only on the compose default network. Anyone on the home LAN can reach `http://:8080` directly; such requests count against their own address (§8.6, D-120). * SQLite `PRAGMA foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` on every connection. @@ -775,6 +820,10 @@ All string lengths are counted in Unicode code points after trimming. One table, | `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | | `GET /sync` `since` | required, integer ≥ 0 | `invalid_request` | | picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | +| card `history_days` (E-37, E-41) | one of 7, 30, 90, 180 (CP-3; `TODO(spec)` K0 may trim 180); E-41 accepts only a value ≤ the current one | `validation_failed` | +| card purchase `merchant` (stored) | 0 to 140 code points, truncated by the server (CP-7) | none (never sent by clients) | +| E-44 body | `expense_id` a non-deleted expense id or `null`; `ignored` bool; `ignored` true needs `expense_id` null; a `credit` purchase accepts only `expense_id` null and `ignored` false (CP-8) | `validation_failed`; an unknown or deleted `expense_id` is `not_found` with field `expense_id`; `expense_already_linked` | +| `GET /cards/sync` `since`, `limit` | as `GET /sync`: `since` required, integer ≥ 0; `limit` 1 to 1000, default 500 | `invalid_request` | Strings are stored as validated, that is trimmed (§8.6, D-034). @@ -824,7 +873,7 @@ The server validates icon and colour against these lists (`validation_failed`). ### 9.9 Online-only actions -When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. Pull-to-refresh on S-4 and S-5 is not disabled offline; it ends at once without a sync (D-127). +When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080); and for card purchases (D-162, D-165): connecting and renewing (S-25, "Förnya med BankID"), "Uppdatera" on S-23, the history setting and "Koppla från" on S-26. Linking, unlinking, ignoring and "Skapa utgift" are not online-only (CP-9); pull to refresh on S-23 ends at once offline, as on S-4. A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. Pull-to-refresh on S-4 and S-5 is not disabled offline; it ends at once without a sync (D-127). ### 9.10 Other edge cases @@ -888,6 +937,43 @@ When several entries match, the longest keyword (in code points, spaces included Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expenses with the fourteen categories of AC-7.2 and the §10.8 mapping put 79.7 % of rows in the right category, against 30.5 % for the step-4 default alone; with no history, the keywords cover 77 % of rows at 96 % accuracy. The replay is in `docs/ux/category-suggestion.md`. +### 9.12 Card purchases (D-158 to D-176) + +The rules of FR-22. The card holder is the user who connected the card; "purchase" is one card transaction as the bank reports it. Facts about the provider (established 2026-10-24, D-159): the SJ Prio Mastercard is issued by SEB Kort Bank AB and, as a payment account, is reachable through PSD2 account information (AIS): read only, no payments. Kvitt reaches it through Enable Banking (enablebanking.com), whose changelog (August 2024) lists an "SJ Prio Mastercard (SE)" integration, in its restricted production mode: free, for evaluation and individual non-commercial use, fetching data only from accounts linked to the application in Enable Banking's control panel ("Activate by linking accounts", done with the bank's own authorisation, BankID). PSD2 RTS Art. 36(5)(b): without the user actively requesting it, a provider may access an account at most 4 times per 24 hours. Since the 2023 RTS amendment, access through a provider needs a renewed strong customer authentication (BankID) at most every 180 days; the bank's `maximum_consent_validity` may be lower. History older than about 90 days is usually available only right after authentication. Items marked `TODO(spec)` K0 are measured by the spike (PLAN-K0, report `docs/spikes/K0-enable-banking.md`) and recorded in the next spec version before K1 starts; until then the stated default holds. + +**CP-1 Catalogue (D-160).** Supported cards are entries of a provider registry in `kvitt-server` (`src/cards/providers.rs`), served by E-36. Each entry has an open-string id, a display name, the issuer, the Enable Banking ASPSP name and country, an image key and its source adapter (`enable_banking`); nothing outside the adapter names the vendor, and ids are never enums (`CLAUDE.md`). v0.35 has one entry: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP `TODO(spec)` K0 (default name `SJ Prio Mastercard`, country `SE`), image key `sj_prio_mastercard`. The app and the web map the image key to their bundled artwork (D-173, D-174); an unknown key draws the fallback card. A user has at most one connection per provider (D-167). + +**CP-2 Connecting (D-162).** E-37 creates an authorisation attempt for the caller (user and device), the provider, the chosen history and the client (`android` or `web`): a random 32-byte `state` (base64url, stored only as its SHA-256 hash, like invite codes, §8.3), status `pending`, expiry 15 minutes after creation. It calls Enable Banking `POST /auth` with `access.valid_until` (CP-6), the ASPSP, the `state`, `redirect_url` = `/cards/callback` and `psu_type` `personal`, and returns the attempt id, the bank URL and the expiry. A new attempt for the same user and provider expires a pending older one. The client opens the URL (Android: a Custom Tab, D-173; web: the same tab, D-174); the user signs with BankID (app-to-app on the phone, QR in a desktop browser). The bank redirects the browser to E-39 with `code` and `state` (or `error`). E-39 looks the attempt up by the hash of `state`; an unknown, used or expired `state`, or an `error`, marks the attempt `failed` (when known) and shows the failure page; otherwise it calls Enable Banking `POST /sessions` with the code, picks the card account (exactly one account in the session: that one; several: `TODO(spec)` K0, default the first whose `cash_account_type` is `CARD`, else the first; none: `failed` with `no_account`), creates or updates the user's connection for that provider (status `active`, the new session id, account uid, `valid_until`, history), marks the attempt `connected`, starts the first fetch in the background (CP-4, user present with the callback request's address and user agent) and shows the success page. The attempt is single use: a second callback with the same `state` shows the failure page and changes nothing. The pages (bilingual by `Accept-Language`, copy in §13.3 "Card callback page") link back to the client: `kvitt://cards/connected?attempt=` for `android`, `/app/cards?attempt=` for `web`. The page never redirects by itself (a browser may block a scripted jump to `kvitt://`); the client learns the result by polling E-38 every 2 seconds while its connect screen is open (S-25, W-22) and when the return link opens it. Renewing an existing connection (CP-6) is the same flow; on success the connection keeps its id and every purchase, and the previous session is revoked at Enable Banking (best effort). + +**CP-3 History (D-164).** The connect screen offers "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30 days. The first fetch after an authorisation asks for transactions from today (Europe/Stockholm) minus that many days. `TODO(spec)` K0: the depth the bank gives right after authentication; default: 180 is offered; if K0 shows less, the choices are trimmed to what was reached and the default stays 30. The setting can be shortened later (E-41; it only affects later renewals, stored purchases stay); a longer value needs a fresh BankID, so S-26 offers it only through "Förnya med BankID" (E-37 with the new value), and says so ("En längre period kräver BankID igen"). + +**CP-4 Background fetch (D-165).** Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". The server fetches every `active` connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm (four slots spread over the day, away from the 04:00 pruning and the 06:00 recurring run), without the user's headers (not user present). A slot is skipped when the connection started 4 background fetches in the preceding 24 hours (`background_fetches`, the start times of the last four), when any fetch for it succeeded less than 2 hours before, or when one is running. The scheduler reads the clock as §9.5 does; after downtime it runs at most the slot that is due, never a backlog. `TODO(spec)` K0: that a background fetch without the user's headers is accepted for the SJ Prio card (default: yes, within 4 per 24 hours). + +**CP-5 User-present fetch (D-165).** "Uppdatera" and pull to refresh on S-23 (and "Uppdatera" on W-21) call E-42, which fetches with the user present, so it does not count as background access. Enable Banking's PSU headers are `Psu-Ip-Address`, `Psu-User-Agent`, `Psu-Referer`, `Psu-Accept`, `Psu-Accept-Charset`, `Psu-Accept-Encoding`, `Psu-Accept-language` and `Psu-Geo-Location`, and a fetch sends "either all required PSU headers (in case of online data fetching) or none", the required set being the ASPSP's `required_psu_headers` in `GET /aspsps`. A user-present fetch therefore sends `Psu-Ip-Address` (the client IP of §8.6) and `Psu-User-Agent` always, plus every other header the ASPSP requires, each taken from the app's own request (`User-Agent`, `Referer`, `Accept`, `Accept-Charset`, `Accept-Encoding`, `Accept-Language`); `Psu-Geo-Location` is never sent (Kvitt has no location; `TODO(spec)` K0: the SJ Prio ASPSP's `required_psu_headers` and that a fetch with them is accepted from the Pi's address, default: it does not require `Psu-Geo-Location`). A background fetch sends none. E-42 contacts the bank at most once per connection per 60 seconds (§8.6) and waits up to 25 seconds for the fetch; if it takes longer it answers with `fetching` true and the fetch completes in the background. The app then pulls E-40. On the phone the regular sync triggers (AC-15.3) also pull E-40, so purchases fetched in the background reach the phone without a tap. + +**CP-6 Consent (D-166).** `access.valid_until` asks for now plus the smaller of 180 days and the ASPSP's `maximum_consent_validity` (given in seconds in `GET /aspsps`), less 5 minutes (`TODO(spec)` K0: the SJ Prio value, default 180 days); the stored `valid_until` is the one Enable Banking's session returns. From 14 days before it, S-23 and S-26 show the expiry notice with "Förnya med BankID"; once it has passed, or Enable Banking reports the session expired or revoked, the connection's status becomes `expired`: no fetch runs, the stored purchases stay and keep working (link, ignore), and "Förnya med BankID" renews (CP-2). + +**CP-7 Incremental fetch and mapping (D-168).** Each fetch asks for transactions with `date_from` = the later of (today minus `history_days`) and (the latest stored booking date of the connection minus 10 days), so a purchase booked late and a pending purchase turning booked are seen again; the first fetch after an authorisation uses today minus `history_days`. Pages are followed until Enable Banking returns no continuation key. With the default strategy Enable Banking answers `WRONG_TRANSACTIONS_PERIOD` when the asked range is not available; the fetch then asks once more with `strategy=longest` and keeps the transactions from `date_from` on (`TODO(spec)` K0: what `strategy=longest` returns for the card right after BankID and later; default as written). Each transaction maps to a purchase: +* `provider_ref`: the transaction's `entry_reference`, which Enable Banking documents as "unique and immutable" but "in most cases … provided only for booked transactions" (`transaction_id` "may change" and is not used); when absent, as for most pending transactions, `h:` + lowercase hex SHA-256 of `||||`, `` the 0-based index of an identical earlier transaction in the same response. +* `direction`: `debit` for `DBIT`, `credit` for `CRDT`. `status`: `pending` for `PDNG`, `booked` for `BOOK`; other statuses are skipped. +* `purchase_date`: `transaction_date`, else `value_date`, else `booking_date`; `booking_date` when booked (`TODO(spec)` K0 which dates the card reports; default as written). +* Amount: `transaction_amount.amount` is a decimal string parsed to öre exactly (string parsing, never floating point, as §9.6's amount input); its sign is ignored, the direction carries it. `TODO(spec)` K0: foreign purchases arrive in SEK on the card account (default yes). A transaction whose currency is not `SEK` is stored with `amount_ore` null, its amount as the decimal string `original_amount` and `original_currency` (ISO 4217); such a purchase shows " " with "Utländsk valuta", gets no matches (CP-10) and "Skapa utgift" opens S-7 with the amount empty. A transaction of 0,00 is skipped. +* `merchant`: `TODO(spec)` K0 which field holds the shop name; default: `creditor.name` when present, else the `remittance_information` lines joined with a space, else "Kortköp" ("Card purchase"); whitespace collapsed, trimmed, at most 140 code points (truncated). + +**CP-8 Matching a fetch to stored purchases (D-168).** Within one connection: a transaction whose `provider_ref` is stored updates that purchase's status, dates, amount and merchant (link and ignore state kept). A booked transaction with no stored `provider_ref` (the usual case for a purchase seen pending first, since pending transactions mostly carry no `entry_reference`) takes over a stored pending purchase of the connection (its id, link and ignore state kept; its `provider_ref` replaced) when the amounts and directions are equal, the dates are at most 3 days apart and the CS-4 first tokens (§9.11) of the merchant texts are equal; among several, the oldest pending one (`TODO(spec)` K0: how pending rows turn booked; default this rule). Otherwise it is a new purchase. After a fetch, a stored pending purchase dated inside the fetched range that the bank no longer reports, neither linked nor ignored, is deleted (a tombstone with its content erased, D-167); a linked or ignored one stays as it is. Credit purchases (`credit`: refunds, deposits, the card bill's payment; `TODO(spec)` K0 what they are on this card) are stored but never "Att hantera": they show only under "Alla", marked "Insättning", with no actions, because §9.6 has no negative expense; the sheet says to change the original expense by hand. + +**CP-9 Turning a purchase into an expense (D-169).** "Skapa utgift" opens S-7 (W-5 on the web) as a new expense prefilled with: amount = `amount_ore`; description = the merchant text, trimmed to 100 code points (§9.6), case unchanged; date = `purchase_date`; payer = the card holder (the viewer); account = from S-27 the account it was opened from, otherwise the AC-8.6 default (the account last used on this phone, or browser, if still active, else the oldest active account); category = the §9.11 suggestion for that description and account (it is a new expense, CS-1; the user's own pick wins, CS-2); split mode `default`; notes empty. Saving creates an ordinary household expense (sync push, audited, the partner is notified as for any expense) and links the purchase to its id. A purchase links to at most one expense, and an expense is linked from at most one purchase of each user (`expense_already_linked`). "Ta bort koppling" (S-23 sheet, S-8) clears the link; the expense stays. When a linked expense is deleted, by either user, the server clears the link in the same transaction (§14.4.4 step 7), so the purchase is "Att hantera" again; the phone clears it locally at its own delete. On the phone, link, unlink and ignore are written to the local copy at once (offline too) and sent with E-44 after the household push of the next sync cycle, so the expense exists on the server first (§14.4.5, D-173); the web sends E-44 at once. The link is not audited and not pushed; editing the expense later leaves the link. + +**CP-10 Suggested matches (D-170).** For a debit purchase in SEK that is neither linked nor ignored, the matches are the household's non-deleted expenses (either user's, imported and recurring included) with `amount_ore` equal to the purchase's, `expense_date` within 3 days before or after `purchase_date`, and not linked from another purchase of the viewer; ordered by the absolute day difference, then `created_at` descending, then id; at most 3 shown. Computed on the phone from Room and in the browser from the snapshot by the pure function `matchCardPurchase` (§14.5.8, vectors `match_card_purchase.json`). A row with exactly one match shows the chip "Koppla till "; with two or three, " möjliga utgifter", which opens the sheet. "Välj en annan utgift" in the sheet lists the non-deleted expenses not linked from another purchase of the viewer, dated within 14 days of the purchase, any amount, newest first. + +**CP-11 Privacy (D-161).** Owner, 2026-10-24: only the card holder sees their card purchases. Card connections and purchases are read and written only by their holder's devices; every other caller, the other user's devices included, gets `not_found` for their ids, the same as an unknown id (cf. D-156). They are not in `GET /sync`, not audited, not pushed, not in S-11, S-12, E-34 or E-35, and the partner's phone stores none. An expense created from a purchase is an ordinary household expense; only the holder sees that it came from a card (S-8, AC-22.10). + +**CP-12 Disconnecting (D-167).** E-43 revokes the session at Enable Banking (`DELETE /sessions/{id}`, best effort: a failure is logged without the id and the disconnect goes on), sets the connection's status to `disconnected` and erases its session id and account uid, and deletes the connection's purchases that are neither linked nor ignored (tombstones with their content erased). Linked and ignored purchases stay (the user's decisions, which keep a later reconnect of the same card from offering them again) unless `erase_handled` is true, when they are deleted too. Expenses created from purchases stay. Connecting the same card again reuses the connection (one per user and provider). + +**CP-13 Server configuration (D-175).** Enable Banking's production control panel accepts only `https` redirect URLs (an `http://localhost` URL "uses unsupported scheme"); the application's registered redirect URL is `https:///cards/callback`, the path of E-39, and no other is needed. The application id is not a secret but is kept out of the repository's text (it is the configuration's value, §10.4, and the key file's name in K0's git-ignored folder). Card features need an Enable Banking application: an application id and its private key file (§10.4). One application may serve both users (the owner links both cards to it in Enable Banking's control panel) or each user has their own: `TODO(spec)` K0 whether the partner's card can be linked to the owner's application (default: yes; the fallback is the partner's own application, which `KVITT_EB_MEMBER_APP_ID` and its key file configure without code change). A user without a configured application sees the provider as not available (E-36 `available` false, AC-22.1); E-37 answers `cards_unavailable`. The owner accepts Enable Banking's Terms of Service before an application exists (the licence step of `CLAUDE.md`, PLAN-K0). + +**CP-14 Errors (D-165).** A fetch that fails records `last_fetch_error` on the connection: `bank_unavailable` (Enable Banking or the bank did not answer, or answered 5xx), `rate_limited` (the bank refused for too many requests), `consent_expired` (CP-6) or `failed` (anything else); a later successful fetch clears it. S-23 shows "Kunde inte hämta köp. Senast uppdaterad