From 8b620c8c6a6519e91d83c75a880e94b048a4e43e Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 24 Oct 2026 12:31:06 +0200 Subject: [PATCH] K0 report, SPEC v0.36: K0 results, windowed history fetch (D-177 to D-180), card artwork cropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K0 ran on the owner's SJ Prio Mastercard on 2026-10-24; the report docs/spikes/K0-enable-banking.md holds aggregates only. SPEC v0.36 answers every K0 TODO(spec) of §9.12: D-177 K0's outcome and the owner's assumptions (partner card, pending->booked, next-day session; verified by K1's owner steps), D-178 the windowed history fetch (100-row cap, no continuation key: 30-day windows split at 100 rows), D-179 the transaction mapping (merchant from remittance_information, booking date else value date, FEE debits to handle, debtor/CPAN fields never stored), D-180 the artwork cropped to the card (1313 x 829) and committed for Android and the web. K plans, prompts/README and HANDOFF follow; K1 to K3 spec text goes into v0.37. Co-Authored-By: Claude Opus 5.5 --- PLAN-K0.md | 2 +- PLAN-K1.md | 23 ++-- PLAN-K2.md | 23 ++-- PLAN-K3.md | 13 +- SPEC.md | 61 +++++---- .../card_sj_prio_mastercard.png | Bin 258475 -> 197695 bytes docs/HANDOFF.md | 26 ++-- docs/decisions.md | 22 +-- docs/spikes/K0-enable-banking.md | 128 ++++++++++++++++++ prompts/README.md | 4 +- .../cards/assets/card-sj-prio-mastercard.png | Bin 0 -> 197695 bytes 11 files changed, 224 insertions(+), 78 deletions(-) create mode 100644 docs/spikes/K0-enable-banking.md create mode 100644 web/src/features/cards/assets/card-sj-prio-mastercard.png diff --git a/PLAN-K0.md b/PLAN-K0.md index d0bb48a..547fa18 100644 --- a/PLAN-K0.md +++ b/PLAN-K0.md @@ -1,6 +1,6 @@ # 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). +Status: **done** (2026-10-24). The test app landed `2ce317f` (`docs/spikes/k0/app.py`, `docs/spikes/k0/README.md`); it was run with the owner on his SJ Prio Mastercard on 2026-10-24, and the report is `docs/spikes/K0-enable-banking.md`. Its answers are recorded in SPEC v0.36 (D-177 to D-180). Owner steps 6, 9 and 10 of §8 (the partner's card, the next day) were not run: the owner decided to assume they work (D-177), and K1's owner steps verify them on the real server (PLAN-K1 §8). The rest of this plan is kept as the record of how K0 was run (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. diff --git a/PLAN-K1.md b/PLAN-K1.md index 1ca3f93..ba3582a 100644 --- a/PLAN-K1.md +++ b/PLAN-K1.md @@ -4,14 +4,16 @@ 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. +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-180 (D-177 to D-179 are K0's results). 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. +- Everything in §9.12 and §14 for cards is binding. K0 answered every `TODO(spec)` of §9.12 (SPEC v0.36, D-177 to D-179); none is left. +- K0's facts the server is built on (report `docs/spikes/K0-enable-banking.md`): ASPSP `SJ Prio Mastercard`, `SE`; `maximum_consent_validity` 180 days; `required_psu_headers` [`psu-ip-address`] (lower case: compare case-insensitively); one `CARD` account per session; background fetches without PSU headers accepted. **At most 100 transactions per response and never a `continuation_key`**: hence the windowed fetch of CP-7 (D-178: windows of at most 30 days, newest first, `date_from` and `date_to`; a window at 100 rows split in half down to one day; `strategy` never sent; `WRONG_TRANSACTIONS_PERIOD` ends the walk back; rows de-duplicated across windows by `provider_ref`, since a window may return rows dated a day or two outside it; only a complete fetch deletes vanished pending purchases). Mapping (D-179): merchant from `remittance_information` (one line), `creditor.name` absent; `purchase_date` = `booking_date`, else `value_date`; every row in SEK; `entry_reference` on every row, pending ones included; `bank_transaction_code` unused (a debit `FEE` is a purchase to handle); `debtor.name`, `debtor_account` (`CPAN`), `creditor.postal_address`, `transaction_id` never stored, logged or served. +- Assumed by the owner's decision, not measured (D-177): the partner's card links to the owner's application; a pending row keeps its `entry_reference` when booked; the session is reused on later days without BankID. §8 steps 5 to 7 verify them after deploy; the code needs nothing extra beyond the `card_fetch` counters `booked_by_ref` and `booked_by_match` (§8.7). - 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). @@ -26,17 +28,17 @@ From SPEC §14.7 (row K1): `server/**`, `contracts/**`, `tools/contracts-check.p ## 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. +- The registry with the one entry of CP-1 (ASPSP `SJ Prio Mastercard`, `SE`), the adapter, the windowed fetch and the mapping of CP-7 (D-178, D-179), 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`. +- 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 (D-179; the stub caps every response at 100 rows and returns no continuation key unless a test asks for one) with invented merchants and amounts: connect success, cancel, unknown and replayed `state`, expired attempt, several accounts, the windowed first fetch (180 days in 6 windows, `date_from` and `date_to` per window, no `strategy` parameter), a window at the 100-row cap split and refetched down to a truncated one-day window, rows dated outside their window de-duplicated, a `continuation_key` still followed, `WRONG_TRANSACTIONS_PERIOD` ending the walk back, an incomplete fetch deleting no pending purchase, pending then booked with the same `entry_reference` (the expected case on this card, `booked_by_ref`) and with a new one (CP-8's secondary rule, `booked_by_match`), a vanished pending row, a credit, a debit `FEE` row to handle, a non-SEK row, a zero row, rows carrying `debtor.name`, a `CPAN` `debtor_account` and a creditor address that are never stored, logged or served, 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. +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`); no real Enable Banking key is needed (tests use the stub and a key generated in the test). 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). @@ -46,11 +48,16 @@ From SPEC §14.7 (row K1): `server/**`, `contracts/**`, `tools/contracts-check.p 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=`. +1. No owner step: the key already exists on the Mac (K0), at `docs/spikes/k0/.local/.pem` in the main checkout (git-ignored); it is never downloaded again. The orchestrator copies it to `infra/secrets/cards/enable-banking.pem` in the main checkout (git-ignored, mode 0600, folder 0700; never committed). No `enable-banking-member.pem`: the partner's card is assumed to link to the owner's application (D-177; step 5 checks it). +2. On the Kvitt Pi in `~/kvitt`: `mkdir -p secrets/cards && chmod 700 secrets/cards`; from the Mac copy the key with `scp infra/secrets/cards/enable-banking.pem jacobhagstedt@:kvitt/secrets/cards/`, then on the Pi `chmod 600 secrets/cards/*`; copy the new `compose.yaml`; in `.env` set `KVITT_EB_APP_ID=` to the application id (the orchestrator gives it in chat: the key file's name before `.pem`) and leave `KVITT_EB_MEMBER_APP_ID=` empty. 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). +Verification of K0's assumptions (D-177), relayed once a client with the card screens runs against the deployed server (K2's release APK or an image with K3); the owner's own card is connected through that client's steps first (PLAN-K2 §8 step 1 or PLAN-K3 §8): +5. The partner's card (D-177 a): Enable Banking Control Panel › the Kvitt application › "Activate by linking accounts": Sweden, SJ Prio Mastercard, the partner signs with her own BankID. Expected: a second linked account (yes/no; if refused, the exact message). Then on her phone: Inställningar › Anslut kort › SJ Prio Mastercard › "Anslut med BankID", she signs. Expected: "Kortet är anslutet" and Kortköp lists her purchases (yes/no). If either fails, the orchestrator plans the fallback of D-175 (her own application, `KVITT_EB_MEMBER_APP_ID`). +6. The next day, without BankID (D-177 c, and CP-5's PSU headers from the Pi): during the day (07:00 to 22:00) open Kortköp; before tapping anything, "Uppdaterad" shows a time within the last 5 hours (a background fetch ran without BankID, yes/no). Then "Uppdatera". Expected: it updates without asking for BankID (yes/no). +7. Pending to booked (D-177 b), a few days after connecting: a purchase that showed "Preliminär" now shows without it, listed once, not twice (yes/no). Then on the Pi in `~/kvitt`: `docker compose logs server | grep card_fetch | tail -n 20`, and paste the output in chat (aggregate counts only, no purchase data): the orchestrator checks that `booked_by_ref` counts the bookings and `booked_by_match` stays 0; if `booked_by_match` carries them instead, the assumption was wrong but CP-8's secondary rule handled it, and the orchestrator records that in the spec. + ## 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). @@ -62,4 +69,4 @@ The server gate, from `server/`: `cargo fmt --check && cargo clippy --all-target - 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). +Server schema 4 (migration 0004), sync protocol and push payload unchanged; decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.37** (v0.36 recorded K0's results; K1, K2 and K3 share v0.37, recorded by a docs agent when they land). diff --git a/PLAN-K2.md b/PLAN-K2.md index 8916fea..3576274 100644 --- a/PLAN-K2.md +++ b/PLAN-K2.md @@ -4,7 +4,7 @@ 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. +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, D-177 to D-180 (K0's results and the artwork). 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`. @@ -14,7 +14,7 @@ Card purchases in the Android app (FR-22, SPEC §9.12, D-169, D-170, D-172, D-17 - 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). +- Card artwork (D-173, D-180): the owner's picture is committed at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` (1313 × 829, cropped to the card, aspect 1.584; transparent only at its rounded corners and in the semicircular notch on the right edge, as on the physical card). `CardArt(imageKey, name, modifier)` in `:feature:cards` maps `sj_prio_mastercard` to `R.drawable.card_sj_prio_mastercard` from the first commit and draws it at its own aspect ratio inside the card box (`ContentScale.Fit`), without clipping or a background, so its own corners and notch show; content description the card's name. 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 the committed picture; one golden covers the fallback (a fake catalogue entry with an unknown image key). The picture shows the SJ and Mastercard logos; the owner cleared that use (2026-10-24, the "Licences first" step). Do not edit or re-encode the PNG. - 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). @@ -47,14 +47,13 @@ From SPEC §14.7 (row K2): `android/feature/cards/**` (new), and as a contract-c 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). +1. 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). +2. Kortköp › a purchase › "Skapa utgift" › "Spara": the expense is in its account and the purchase is under "Kopplade" (yes/no). +3. On a purchase your partner already entered: the row shows "Koppla till …"; tap it: under "Kopplade" (yes/no). +4. Flight mode on: ignore a purchase, then flight mode off: it stays ignored after a sync (yes/no). +5. An account › menu › "Från kortköp": a purchase › "Spara": the expense is in that account (yes/no). +6. 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). +7. On the partner's phone (after she connects her card, PLAN-K1 §8 step 5): 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). @@ -63,7 +62,7 @@ The Android gate, from `android/`: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ## 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. +- Dependency-report lines; 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). +Room version 2; decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.37** (v0.36 recorded K0's results; K1, K2 and K3 share v0.37). diff --git a/PLAN-K3.md b/PLAN-K3.md index 7df0307..6e06908 100644 --- a/PLAN-K3.md +++ b/PLAN-K3.md @@ -4,7 +4,7 @@ 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. +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, D-177 to D-180 (K0's results and the artwork). 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). @@ -13,7 +13,7 @@ The card features of FR-22 in the web client (D-174): W-21 Kortköp, W-22 Anslut - 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'`). +- Artwork (D-174, D-180): `web/src/features/cards/assets/card-sj-prio-mastercard.png` is committed (the same file as Android's, 1313 × 829, cropped to the card, transparent only at its rounded corners and in the notch on the right edge). The cards feature imports it (Vite emits it under `/app/assets/` with a hashed name) and maps the image key `sj_prio_mastercard` to it from the first commit, drawn at its own aspect ratio (`object-fit: contain`, no clipping, no background), `alt` the card's name; 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'`). Do not edit or re-encode the PNG. - 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). @@ -35,10 +35,9 @@ From SPEC §14.7 (row K3): `web/src/features/cards/**` (new); in shared code the 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). +1. 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). +2. Kortköp › a purchase › "Skapa utgift" › "Spara": the purchase is under "Kopplade" on the laptop and on the phone after a sync (yes/no). +3. 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). @@ -48,4 +47,4 @@ The web gate, from `web/`: `npm run check`. Report the real result lines (test f - `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). +Decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.37** (v0.36 recorded K0's results; K1, K2 and K3 share v0.37). diff --git a/SPEC.md b/SPEC.md index a9a1749..27716c6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.35** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**. +Version **0.36** (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. @@ -676,7 +676,7 @@ Card purchases (D-161, D-163) add: the Enable Banking application's private key * 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. +* 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; since v0.36 also the number of bank requests, of windows split and of windows truncated (CP-7), and of pending purchases booked under their own reference (`booked_by_ref`) and taken over by CP-8's secondary rule (`booked_by_match`), D-178). `/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 @@ -820,7 +820,7 @@ 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 `history_days` (E-37, E-41) | one of 7, 30, 90, 180 (CP-3; K0 reached at least 150 days right after BankID, so 180 stays, D-177); 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` | @@ -939,28 +939,38 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe ### 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. +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. The spike K0 measured the owner's card on 2026-10-24 (PLAN-K0, report `docs/spikes/K0-enable-banking.md`, D-177); its results are stated in the rules below as measured ("K0: …"). Three points were not measured and rest on the owner's decision of 2026-10-24 ("let's just assume it all works now given your test", D-177): the partner's card links to the owner's application (CP-13), a pending transaction keeps its `entry_reference` when it is booked (CP-8), and a session is reused on later days without BankID (CP-6). K1's owner steps verify all three on the real server (PLAN-K1 §8). -**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-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`). The registry has one entry: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name `SJ Prio Mastercard`, country `SE`, image key `sj_prio_mastercard` (K0: listed exactly so in `GET /aspsps?country=SE`, one of 126 Swedish ASPSPs, `beta` false, `psu_types` [`personal`], auth method `REDIRECT`). The app and the web map the image key to their bundled artwork, committed in both (D-180); 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-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: the first whose `cash_account_type` is `CARD`, else the first; none: `failed` with `no_account`; K0: the SJ Prio session holds exactly one account, currency SEK, `cash_account_type` `CARD`), 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-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, in windows (CP-7). K0: right after BankID the card gave at least 150 days (five consecutive 30-day windows back to 150 days, all answered with rows; the last 30 days to 180 were not asked by window), so 180 stays offered (D-177). 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-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. K0: a fetch without the user's headers was accepted for the SJ Prio card (HTTP 200); how many the bank accepts per 24 hours was not measured, so the 4-per-24-hours rule stands. A background fetch normally makes one bank request (its range starts 10 days before the latest stored booking date, one window of CP-7); the `card_fetch` line counts the requests (§8.7). -**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-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). K0: the SJ Prio ASPSP's `required_psu_headers` is exactly [`psu-ip-address`]; Enable Banking lists the names in lower case, so they are compared case-insensitively; a fetch with `Psu-Ip-Address` and `Psu-User-Agent` was accepted (HTTP 200) from the MacBook, and from the Pi it is checked by K1's owner steps. 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-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 (K0: the SJ Prio value is 15552000 seconds, 180 days, so the request is now plus 180 days less 5 minutes; the session made on 2026-10-24 returned `valid_until` 2027-04-22); the stored `valid_until` is the one Enable Banking's session returns. The session serves every fetch, user-present and background, until then without a new BankID (owner's assumption, D-177; K0 did not repeat a fetch on a later day, K1's owner steps check it). 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-7 Incremental fetch and mapping (D-168, D-178, D-179).** Each fetch covers the range from `date_from` = the later of (today minus `history_days`) and (the latest stored booking date of the connection minus 10 days) up to today, so a purchase booked late and a pending purchase turning booked are seen again; the first fetch after an authorisation starts at today minus `history_days`. K0: the SJ Prio card answers every transactions request with at most 100 transactions and never a `continuation_key`, so a longer range loses rows without an error (90 days gave 100 rows covering only the newest 60 days; 180 days with `strategy=longest` gave 100 rows that were neither the newest nor a contiguous range). The range is therefore fetched in windows (D-178): +* Windows: consecutive, non-overlapping, at most 30 days each, newest first, each asked with `date_from` and `date_to` (both inclusive); the oldest window takes what is left and may be shorter. A first fetch of 180 days makes 6 requests; an incremental fetch (10 to about 20 days) makes 1. A `continuation_key`, if one is ever returned, is followed within its window. +* Split: a window whose response holds 100 or more transactions (the cap, so the response may be cut) is split into two halves, the newer half taking the extra day of an odd length, and each half is asked again, newest first, down to windows of one day. A one-day window that still holds 100 is kept as returned and counts as truncated. +* `strategy` is never sent (Enable Banking's default strategy): `strategy=longest` neither lifts the cap nor returns the newest rows. +* `WRONG_TRANSACTIONS_PERIOD` for a window (not seen in K0) means the bank has no older history: that window and every older one are skipped and the fetch succeeds with the newer windows. +* A window may return transactions whose booking date lies one or two days outside it (K0: the bank filters on another date), so every transaction is kept as returned and the transactions of all windows are de-duplicated by `provider_ref` (below) before CP-8; the first occurrence, from the newer window, wins. +* Any other error ends the fetch with CP-14's error; the transactions of the windows already fetched are applied (CP-8). +* A fetch is complete when every window of its range answered and none was truncated; only a complete fetch deletes vanished pending purchases (CP-8). -**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. +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). K0: on the SJ Prio card it is on every transaction, the pending ones included, unique, and unchanged between two fetches. When absent, `h:` + lowercase hex SHA-256 of `||||`, `` the 0-based index of an identical earlier transaction in the same window's response. +* `direction`: `debit` for `DBIT`, `credit` for `CRDT`. `status`: `pending` for `PDNG`, `booked` for `BOOK`; other statuses are skipped. `bank_transaction_code` is not used: the direction alone decides (D-179). K0 saw `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2 and `FEE` 1 in 100 transactions; a debit `FEE` (a card fee) or `PAYMENT` is a debit like any purchase, so it is "Att hantera" and the holder turns it into an expense or ignores it. +* `purchase_date`: `booking_date`, else `value_date` (a pending transaction has no booking date), else `transaction_date`; a transaction with none of the three is skipped. `booking_date` is stored when the transaction is booked. K0: `value_date` on every transaction, `booking_date` on every booked one and on no pending one, `transaction_date` on none (D-179). +* 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. K0: positive decimal strings with a point, and every transaction in `SEK`, purchases at German, Irish and Estonian merchants included (`exchange_rate` empty): the card account reports foreign purchases converted to SEK. A transaction whose currency is not `SEK` (not seen in K0; kept as a safeguard) 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`: the `remittance_information` lines joined with a space, else `creditor.name`, else "Kortköp" ("Card purchase"); whitespace collapsed, trimmed, at most 140 code points (truncated). K0: `remittance_information` holds exactly one line on every transaction and it is the merchant text; `creditor.name` is never present (D-179). +* Nothing else of a transaction is stored, logged or sent to a client (D-179): in particular not `debtor.name` (K0: the card holder's name), `debtor_account` (K0: a masked card number, scheme `CPAN`), `creditor.postal_address` (K0: the merchant's country and town), `transaction_id` or `bank_transaction_code`. + +**CP-8 Matching a fetch to stored purchases (D-168, D-177).** Within one connection: a transaction whose `provider_ref` is stored updates that purchase's status, dates, amount and merchant (link and ignore state kept). On the SJ Prio card a pending transaction carries an `entry_reference` (K0), and a pending transaction is assumed to keep it when it is booked (owner's assumption, D-177; K1's owner steps check it through `booked_by_ref` and `booked_by_match`, §8.7), so the booked transaction updates the pending purchase by its reference. The secondary rule, for a booked transaction with no stored `provider_ref` (a bank that gives the booked transaction a new reference, or a pending one stored under the fallback hash), 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. Otherwise it is a new purchase. After a complete fetch (CP-7), 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; K0: 2 of 100 transactions, both payments of the card bill, no refund) 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. @@ -970,7 +980,7 @@ The rules of FR-22. The card holder is the user who connected the card; "purchas **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-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. The owner's application exists (K0: restricted production, activated by linking the owner's card, its key file in K0's git-ignored folder until K1's owner steps copy it to the Pi). The partner's card is assumed to link to the owner's application (owner's assumption, D-177; K0 did not try it, K1's owner steps do); 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