Files
kvitt/PLAN-K1.md
T
jacobh2 b5114adc06 SPEC v0.35: card purchases via Enable Banking (D-158 to D-176), plans K0 to K3
Owner request and answers of 2026-10-24: each user connects their own SJ Prio
Mastercard through Enable Banking (restricted production, BankID); only the
card holder sees the purchases; a fourth bottom destination "Kortköp"
(revises D-048); "Anslut kort" in Settings with the card artwork; one- and
two-tap linking to household expenses; web as a later plan.

FR-22, §9.12 (CP-1 to CP-14), server schema 4 (migration 0004), Room 2,
E-36 to E-44, S-23 to S-27, W-21 to W-23, OQ-7, OQ-8. PLAN-K0 describes the
test app of 2ce317f and the remaining run and report; K1 server, K2 Android,
K3 web. HANDOFF and prompts/README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:49:14 +02:00

11 KiB
Raw Blame History

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-pi-address>: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).