8b620c8c6a
Build Kvitt images / build (push) Failing after 3h10m35s
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 <noreply@anthropic.com>
73 lines
15 KiB
Markdown
73 lines
15 KiB
Markdown
# 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-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. 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).
|
||
- 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 `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 (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`); 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).
|
||
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. No owner step: the key already exists on the Mac (K0), at `docs/spikes/k0/.local/<application id>.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-pi-address>: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).
|
||
- 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 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).
|