K0 report, SPEC v0.36: K0 results, windowed history fetch (D-177 to D-180), card artwork cropped
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>
This commit is contained in:
2026-10-24 12:31:06 +02:00
parent 8fa91986f0
commit 8b620c8c6a
11 changed files with 224 additions and 78 deletions
+12 -14
View File
@@ -3,8 +3,8 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- State as of 2026-10-24. `main` at `2ce317f` (the K0 card test app), after `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups, pushed to `origin` 2026-10-24); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones (verified 2026-10-24). Next: the Pi image build of the pushed `main` with the complete web app (the Gitea workflow on the Pi runner; this pass's `server/Dockerfile` and workflow change triggers another run when pushed), then the owner deploys it (§3 step 3), logs in from the laptop browser (the web client's first use, D-156) and runs the browser checks (§3 step 4). This docs pass (`agent-docs-v34`) records L-10 as implemented and W0b (SPEC v0.34, no new decision) and passes the web's version code into the image build (`ARG KVITT_VERSION_CODE` in `server/Dockerfile`, `--build-arg KVITT_VERSION_CODE=<git rev-list --count HEAD>` in the workflow). Gates on `main`: server 300 tests, web 609 tests, contracts-check 136.
- **Card purchases programme** (owner request and answers 2026-10-24, SPEC v0.35, D-158 to D-176, FR-22, §9.12): each user connects their own SJ Prio Mastercard (SEB Kort) through Enable Banking's restricted production mode with BankID; the server alone holds the key and sessions, fetches purchases up to 4 times a day in the background plus "Uppdatera", and only the card holder sees them (not in `GET /sync`, audit, push or analytics); a fourth bottom destination "Kortköp" (Översikt, Kortköp, Statistik, Inställningar, revising D-048), "Anslut kort" in Settings with the SJ Prio artwork, "Skapa utgift" prefilled into S-7, one-tap links to matching expenses (same amount, ±3 days), "Från kortköp" on account detail; server schema 4 (migration 0004), Room 2, endpoints E-36 to E-44, screens S-23 to S-27, web W-21 to W-23. Plans `PLAN-K0.md` to `PLAN-K3.md`, prompts `prompts/K0.md` to `prompts/K3.md`, written by `agent-a3b7abf183f883261`. **Next: K0** — the test app landed `2ce317f` (`docs/spikes/k0/app.py`, one command, key and session in the git-ignored `docs/spikes/k0/.local/`); what remains is running it with the owner (and the partner) and the report `docs/spikes/K0-enable-banking.md`, whose answers to the K0 `TODO(spec)` items of §9.12 go into SPEC v0.36 before K1 starts. Then K1 (server, contract) lands first, K2 (Android) after it, K3 (web) after K2; they may run in parallel. K0 waits on the owner steps of §3 (Enable Banking account and ToS, the application, the key, linking both cards).
- State as of 2026-10-24. `main` at `8fa9198` (`infra/build-images.sh` Podman detection) and `f15fa23` (`infra/build-images.sh` passes `KVITT_VERSION_CODE`), after `4339d01` (the owner's SJ Prio card artwork), `b5114ad` (SPEC v0.35, card purchases), `2ce317f` (the K0 card test app), `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups, pushed to `origin` 2026-10-24); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones (verified 2026-10-24). Next: the Pi image build of the pushed `main` with the complete web app (the Gitea workflow on the Pi runner; this pass's `server/Dockerfile` and workflow change triggers another run when pushed), then the owner deploys it (§3 step 3), logs in from the laptop browser (the web client's first use, D-156) and runs the browser checks (§3 step 4). SPEC v0.34 (`5d948f9`) recorded L-10 as implemented and W0b and passes the web's version code into the image build (`ARG KVITT_VERSION_CODE` in `server/Dockerfile`, `--build-arg KVITT_VERSION_CODE=<git rev-list --count HEAD>` in the workflow). Gates on `main`: server 300 tests, web 609 tests, contracts-check 136.
- **Card purchases programme** (owner request and answers 2026-10-24, SPEC v0.35, D-158 to D-176, FR-22, §9.12): each user connects their own SJ Prio Mastercard (SEB Kort) through Enable Banking's restricted production mode with BankID; the server alone holds the key and sessions, fetches purchases up to 4 times a day in the background plus "Uppdatera", and only the card holder sees them (not in `GET /sync`, audit, push or analytics); a fourth bottom destination "Kortköp" (Översikt, Kortköp, Statistik, Inställningar, revising D-048), "Anslut kort" in Settings with the SJ Prio artwork, "Skapa utgift" prefilled into S-7, one-tap links to matching expenses (same amount, ±3 days), "Från kortköp" on account detail; server schema 4 (migration 0004), Room 2, endpoints E-36 to E-44, screens S-23 to S-27, web W-21 to W-23. Plans `PLAN-K0.md` to `PLAN-K3.md`, prompts `prompts/K0.md` to `prompts/K3.md`, written by `agent-a3b7abf183f883261`, landed `b5114ad`. **K0 done** (2026-10-24): the test app (`2ce317f`) ran with the owner on his card; report [`docs/spikes/K0-enable-banking.md`](spikes/K0-enable-banking.md); its answers are SPEC v0.36 (D-177 to D-180). Main findings: the card returns at most 100 transactions per response and never a continuation key, so history is fetched in windows of at most 30 days, split in half at 100 rows (D-178); merchant text from `remittance_information`, the date `booking_date` else `value_date`, everything in SEK, `entry_reference` on pending rows too (D-179); consent 180 days; background fetch accepted; at least 150 days of history right after BankID. **Owner's assumptions** (2026-10-24, "let's just assume it all works now given your test", D-177), verified by K1's owner steps (PLAN-K1 §8 steps 5 to 7): the partner's card links to the owner's application (fallback: her own application, D-175), a pending purchase keeps its `entry_reference` when booked (fallback: CP-8's amount-and-date rule), the session is reused the next day without BankID. The Enable Banking application exists (restricted production, redirect URL `https://kvitt.suorra.duckdns.org/cards/callback`, the owner's card linked); its key file lives only in the main checkout's git-ignored `docs/spikes/k0/.local/<application id>.pem` and reaches the Pi through K1's owner steps (`~/kvitt/secrets/cards/enable-banking.pem`, D-175), never downloaded again. The artwork is committed for Android and the web, cropped to the card (D-180). **Next: K1** (server, contract) with **K2** (Android) and **K3** (web) starting in parallel on the spec and fakes; K1 lands first, K2 after it, K3 after K2; their proposed spec text goes into v0.37.
- **Verified 2026-10-24** (owner, D-156): Samsung Auto Blocker was the cause of the silent update failure; turning it off let the update install, and the owner turned it back on, so every in-app update on the Samsung phones needs it off (the D-155 blocked text names it; SPEC AC-16.3, `infra/README.md`). The editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) works on the device. The first backup on the Kvitt Pi ran and a snapshot pair is in the Drive folder (AC-18.1). The runner Pi's disk reached 11 of 15 GB during image builds; the owner runs `docker builder prune -af` between builds (SPEC §10.3).
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
@@ -67,7 +67,7 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 answered 2026-10-24: `not_found` for the other user's device (D-156), fixed in `07c36d2`. Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
- In flight:
- This docs pass (`agent-docs-v34`: SPEC v0.34, `docs/decisions.md` D-156, the security review's L-10 note, `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, this file).
- This docs pass (K0 close-out: SPEC v0.36, `docs/decisions.md` D-177 to D-180, `docs/spikes/K0-enable-banking.md`, the cropped artwork in `android/feature/cards/` and `web/src/features/cards/assets/`, `PLAN-K0.md` to `PLAN-K3.md`, `prompts/README.md`, this file).
- The Pi image build of the pushed `main`, then the owner's deploy and browser checks (§3 steps 3 and 4).
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
@@ -79,13 +79,12 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
## 2. Open work (one line each, with who owns it: agent or owner)
- Land the card-purchases docs pass (orchestrator): SPEC v0.35, D-158 to D-176, `PLAN-K0.md` to `PLAN-K3.md`, `prompts/K0.md` to `K3.md`, `prompts/README.md`, this file; gate `python3 tools/contracts-check.py` (no contract file changed).
- K0 run and report (Opus agent with the owner, `prompts/K0.md`): after the owner steps of §3 item 5; then a docs agent records K0's answers as SPEC v0.36 before K1.
- K1, K2, K3 (Opus agents, after SPEC v0.36; disjoint decision ranges): K1 lands first, K2 after K1, K3 after K2. K2's card artwork follow-up if the owner's PNG arrives after K2 (one mapping line and re-recorded goldens).
- Land the K0 close-out pass (orchestrator): SPEC v0.36, D-177 to D-180, the K0 report, the cropped artwork (two PNGs, referenced by no build yet), the K plans, `prompts/README.md`, this file; gate `python3 tools/contracts-check.py` (no contract file changed).
- K1, K2, K3 (Opus agents, once SPEC v0.36 is on `main`; decisions from D-181 in disjoint ranges, spec version v0.37 for their proposed text): K1 lands first, K2 after K1, K3 after K2. After deploy, K1's owner steps 5 to 7 verify K0's three assumptions (D-177).
- K0 test app session in `docs/spikes/k0/.local/state.json` (owner): keep it or press "9 Koppla från" in the test app (PLAN-K0 §6 step 7); K1 does not use it. The key file stays in `.local/` either way.
- Card design-spec sections S-23 to S-27 for `docs/ux/design-spec.md` (docs agent, from K2's report).
- Land `agent-docs-v34` (orchestrator): SPEC v0.34, D-156 implemented, L-10 note, the version-code ARG in `server/Dockerfile` and the workflow, HANDOFF; gate `python3 tools/contracts-check.py`.
- `infra/build-images.sh` (agent, next infra pass): pass `--build-arg KVITT_VERSION_CODE=$(git rev-list --count HEAD)` to the server build as the workflow does (v0.34); without it the Mac-built image's web reports version code 1.
- Pi image with the complete web app (built from the push of `5305f37`, or of `agent-docs-v34` once landed and pushed): watch the workflow run, then the owner deploys it (§3 step 3) and checks the web client from the laptop browser (§3 step 4); HUMAN step 2 if the NPM block is not yet current. Release 140 (W6 Insights, D-155) runs on both phones; its Insights need this image.
- Closed by `f15fa23` and `8fa9198`: `infra/build-images.sh` passes `--build-arg KVITT_VERSION_CODE` to the server build as the workflow does (v0.34), and detects Podman without a pipe.
- Pi image with the complete web app (built from the push of `5305f37`, or of a later push of `main`): watch the workflow run, then the owner deploys it (§3 step 3) and checks the web client from the laptop browser (§3 step 4); HUMAN step 2 if the NPM block is not yet current. Release 140 (W6 Insights, D-155) runs on both phones; its Insights need this image.
- Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min).
- Pushed: `5305f37` (orchestrator, 2026-10-24).
- Closed by `2acfc34` (`agent-gate-docs`): `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered).
@@ -108,7 +107,7 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
### Web follow-ups
- Done in W0b (`5305f37`, SPEC §15.3 v0.34): shared pieces once in `components`, `lib`, `api`, `app`; `AmountField` per page language with `hideLabel` and `placeholder`; `ids.ts` typing; Vitest cache off; `/analytics` in the dev proxy and Recharts in its own chunk (vendor 516 → 132 kB); `routes.ts` builders (`links.ts` files deleted); `spend_series` and `resolve_range` in `vectors.test.ts`; W5's recurring editor on `components/editor`; a click wording for the paid-by toggle; the version code from the commit count. Testing Library's wait limit is 5 s (`web/src/test/setup.ts`, load on the Mac during parallel agents).
- Kept: `/app/settings/invites` as an alias of `/app/settings/invite` (the spec names it, D-153).
- The image's version code: `ARG KVITT_VERSION_CODE` in `server/Dockerfile` and the workflow's build argument (`agent-docs-v34`); `infra/build-images.sh` still to follow (§2).
- The image's version code: `ARG KVITT_VERSION_CODE` in `server/Dockerfile` and the workflow's build argument (`5d948f9`); `infra/build-images.sh` since `f15fa23`.
## 3. HUMAN steps pending (relay in chat)
Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy), the public health check through NPM, bootstrap, release pairing of both phones, the Splitwise import. Done 2026-10-24: both phones on release 140 (keyed, so the old step 1 is done), the first backup on Drive (AC-18.1).
@@ -118,8 +117,7 @@ Standing note for every in-app update on the Samsung phones: turn Auto Blocker o
3. Deploy the new image (the complete web app and E-34/E-35, SPEC §10.10), once the Gitea workflow run of the pushed `main` has succeeded: on the Kvitt Pi in `~/kvitt`, set `KVITT_TAG` in `.env` to the tag the run printed, then `docker compose pull && docker compose up -d`; `server` and `backup` healthy (yes/no). Release 140 runs on both phones; S-11's Insights work once this image runs.
4. Web client in a browser on the laptop (after step 3, D-156): on the phone, Inställningar › Para ihop en enhet › För mig, create a code; on the laptop open `https://kvitt.suorra.duckdns.org/app/`, "Logga in i Kvitt", type the code, log in: Home shows (yes/no). Then, each yes/no: Home and one account open; add, edit and delete an expense; Sök, export a CSV and open it in Numbers (columns and amounts right); Gör upp: record a payment and delete it; Statistik, open a category row (line chart), Insikter; each Settings page opens; on the phone Inställningar › Enheter shows `Web · <browser>`, "Ta bort åtkomst" on it, and the browser shows the login page with the removed-device message.
5. Card purchases, K0 (PLAN-K0 §8; relay one at a time, skip what is done; the application with the redirect URL `https://kvitt.suorra.duckdns.org/cards/callback` may already exist): (a) sign up at `https://enablebanking.com` (Control Panel), confirm the email, accept the Terms of Service (licence step, yes/no); (b) Control Panel › API applications › "Add a new application": Production, name "Kvitt", redirect URL exactly `https://kvitt.suorra.duckdns.org/cards/callback`, key generated in the browser, register: a `.pem` downloads (yes/no); (c) move the `.pem` into `/Users/jhag/projects/kvitt/docs/spikes/k0/.local/`, keeping its name; (d) recommended: add the `location = /cards/callback { … }` block of SPEC §10.5.2 to NPM's Advanced tab, Save, row Online (yes/no); (e) the application › "Activate by linking accounts": Sweden, SJ Prio Mastercard, BankID: the card is linked (yes/no); (f) with the partner: link her card with her BankID (yes/no, or the exact message); (g) the test app with the agent: connect, paste the address, "Uppdatera", "Bakgrundshämtning" (yes/no each); (h) the next day the same without BankID (yes/no).
6. Card artwork (when K2 runs): the SJ Prio Mastercard PNG (at least 1012 × 638 px) at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png`, and the same file at `web/src/features/cards/assets/card-sj-prio-mastercard.png` (owner cleared its use, D-173).
5. Card purchases: K0's owner steps are done (2026-10-24: Enable Banking account and Terms of Service, the application, the key in `docs/spikes/k0/.local/`, the owner's card linked, the test app run); the artwork is committed (D-180). The partner's card and the next-day check moved to K1's owner steps (PLAN-K1 §8 steps 5 to 7, after K1 and a client with the card screens are deployed); K1's steps 2 to 4 (key to the Pi, `.env`, NPM block, deploy) come first.
## 4. Owner questions (asked, unanswered)
- OQ-7 (SPEC §12): a notification for new card purchases? Default: none in this version (D-171).
@@ -133,8 +131,8 @@ Standing note for every in-app update on the Samsung phones: turn Auto Blocker o
- Nothing.
## 6. Numbering
- Decisions: D-001 to D-176 used, except D-008, D-009 and D-150 (never used); D-158 to D-176 are the card purchases (v0.35). Next free: **D-177**.
- Spec version: v0.35 written (card purchases, D-158 to D-176). Next **v0.36** (K0's answers, before K1).
- Decisions: D-001 to D-180 used, except D-008, D-009 and D-150 (never used); D-158 to D-176 are the card purchases (v0.35), D-177 to D-180 K0's results and the artwork (v0.36). Next free: **D-181**.
- Spec version: v0.36 written (K0's results, D-177 to D-180). Next **v0.37** (the proposed spec text of K1, K2 and K3, recorded by a docs agent as they land).
- Server schema version **3** in code (migration 0003); **4** specified (migration 0004, §14.1.3) for K1. Room schema version **1** in code; **2** specified (§14.2.3) for K2.
- New ids in v0.35: FR-22 (AC-22.1 to AC-22.15), US-18, US-19, CP-1 to CP-14 (§9.12), S-23 to S-27, W-21 to W-23, E-36 to E-44, OQ-7, OQ-8; plans K0 to K3.
- Sync protocol **1**, push payload **1**.
+13 -9
View File
@@ -164,20 +164,24 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-157 | 2026-10-24 | Small edits are made by a Haiku or Sonnet agent directly in the main checkout, not in a new worktree, and the orchestrator commits them on `main`. Owner's words: "be smart about it: Spin up smaller agents (like haiku or sonnet) for these super small things, and do not do it in a new worktree, instead simply do it directly here." Real work stays with Opus agents in their own worktrees. Refines the working rules that every change goes through an Opus agent in a worktree and that Haiku or Sonnet are only for owner-requested read-only scans. | A worktree, a full brief and an Opus agent cost far more than a one-line fix is worth. | `CLAUDE.md` › Orchestration (edited separately) |
| D-158 | 2026-10-24 | Card purchases are in scope (revises §2's "Any card, bank, … integration" non-goal, struck through with a pointer). Owner's request: "In the settings, there should be a 'Connect to credit card' option and in there, a list of supported cards. At the start, only the SJ Creditcard should be available, with a nice image of the SJ creditcard (the green card) … When clicking it, it should start the connection process … All transactions should then be read and visible in a separate tab in the app … There should be an option to say how many days back (history) to fetch … From both the list of expenses made by the card, and from each account, it should be possible to link that to an account expense with very few clicks … if it can auto-update the list that would be best. If it always need a fresh e.g. Bank ID session then that is fine too with a simple 'refresh' button." Both users connect their own card (each their own SJ Prio Mastercard). Read only (PSD2 account information): no payments, no other bank accounts, no other provider than the catalogue's. New FR-22 (AC-22.1 to AC-22.15), US-18, US-19, §9.12 (CP-1 to CP-14), a line in §1. | The owner wants his and his partner's card purchases in Kvitt without typing them, and turned into expenses with very few taps. | SPEC §1, §2, §3.2, FR-22, §9.12 |
| D-159 | 2026-10-24 | Provider: Enable Banking (enablebanking.com) in its restricted production mode (free, for evaluation and individual non-commercial use, data only from accounts linked to the application in its control panel with the bank's own BankID authorisation). The SJ Prio Mastercard is issued by SEB Kort Bank AB and reachable through PSD2 AIS; Enable Banking's changelog (August 2024) lists "SJ Prio Mastercard (SE)" (Tink and GoCardless list it too; GoCardless Bank Account Data is closed to new sign-ups). G-7 now allows Enable Banking as an external service, for card purchases only. PSD2 facts the rules rest on: at most 4 accesses per 24 hours without the user present (RTS Art. 36(5)(b)); renewed BankID at most every 180 days (2023 RTS amendment; the bank's `maximum_consent_validity` may be lower); history beyond about 90 days usually only right after authentication. The owner accepts Enable Banking's Terms of Service before an application is created (the "Licences first" step of `CLAUDE.md`, recorded as an owner step in PLAN-K0). What only the real card can show is a `TODO(spec)` resolved by K0 with a stated default. | Restricted production fits a two-person private app at no cost and needs no licence as a provider; the alternatives are closed to new users or paid. | SPEC §1 G-7, §9.12 (facts), CP-13; PLAN-K0 |
| D-160 | 2026-10-24 | Architecture: the Enable Banking application's private key, its JWTs and the session ids live on the server only; the phones and the browser never talk to Enable Banking. Vendor code sits in one adapter, `kvitt-server` `src/cards/enable_banking/`, behind a `CardSource` trait, mapping the vendor's JSON into Kvitt's own records; nothing else names the vendor. A provider registry (`src/cards/providers.rs`) holds the card catalogue with open-string ids, served by E-36 `GET /cards/providers`: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name and country (K0 confirms), image key `sj_prio_mastercard`, `available`. The clients map the image key to their bundled artwork. No new crate: reqwest with rustls and jsonwebtoken are already in the stack. | `CLAUDE.md`'s vendor-in-one-adapter and open-string-id rules; a key on the phones would put a credential for both users' cards on two lost-able devices. | SPEC §6.1, §6.3, §7, §9.12 CP-1, §14.3.4 E-36 |
| D-160 | 2026-10-24 | Architecture: the Enable Banking application's private key, its JWTs and the session ids live on the server only; the phones and the browser never talk to Enable Banking. Vendor code sits in one adapter, `kvitt-server` `src/cards/enable_banking/`, behind a `CardSource` trait, mapping the vendor's JSON into Kvitt's own records; nothing else names the vendor. A provider registry (`src/cards/providers.rs`) holds the card catalogue with open-string ids, served by E-36 `GET /cards/providers`: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name and country (K0 confirms; answered by D-177: `SJ Prio Mastercard`, `SE`), image key `sj_prio_mastercard`, `available`. The clients map the image key to their bundled artwork. No new crate: reqwest with rustls and jsonwebtoken are already in the stack. | `CLAUDE.md`'s vendor-in-one-adapter and open-string-id rules; a key on the phones would put a credential for both users' cards on two lost-able devices. | SPEC §6.1, §6.3, §7, §9.12 CP-1, §14.3.4 E-36 |
| D-161 | 2026-10-24 | Privacy: only the card holder sees their card purchases. Owner's answer: "only the card holder". Card connections and purchases are read (E-40, own cursor `card_version`) and written (E-44) only by the holder's devices; any other caller gets `not_found` for their ids (cf. D-156). Not in `GET /sync`, the audit log, any push, S-11, S-12 or the D-133 endpoints; the partner's phone stores none. An expense made from a purchase is an ordinary household expense. Server logs carry no merchant text, amount, purchase date, session id, account uid, `code` or `state` (one aggregate `card_fetch` line per fetch). Backups include the card tables (the whole database is snapshot). The session id is stored in plain text and protected by separation: it is usable only with the application's private key, which lives in `secrets/cards/`, never in the database or a backup; it is never logged and never sent to a client. | The owner's answer; the separation gives at-rest protection without a key-management scheme the Pi does not have, and keeps a leaked backup (unencrypted, accepted risk M-1) from exposing the cards. | SPEC §8.1, §8.4, §8.7, §9.12 CP-11, §10.9, §14.4.1, §14.6, AC-22.3, AC-22.14 |
| D-162 | 2026-10-24 | Connect flow: E-37 `POST /cards/auth` creates a single-use attempt (32-byte `state`, stored as its SHA-256 hash, bound to user and device, 15-minute expiry, a newer attempt expires an older pending one) and returns Enable Banking's URL; the app opens it in a Custom Tab, the web in the same tab; BankID app-to-app on the phone, QR on a computer; the bank redirects to the new path `https://<KVITT_PUBLIC_HOST>/cards/callback` (E-39), where the server exchanges the code (`POST /sessions`), picks the card account (K0 confirms the rule when a session has several), stores the connection, starts the first fetch in the background and shows a small bilingual page "Kortet är anslutet" with the link "Tillbaka till Kvitt": `kvitt://cards/connected?attempt=<id>` (app) or `/app/cards?attempt=<id>` (web). The page never jumps by itself; the client learns the result by polling E-38 every 2 seconds while its connect screen is open and from the return link. Renewal is the same flow on the existing connection. | A custom-scheme link from a user's tap opens the app without App Links verification, which would need `/.well-known/assetlinks.json` served through the app gate and `autoVerify` (the app uses `autoVerify="false"`, AC-2.6); browsers may block a scripted jump to `kvitt://`, so polling makes the result independent of whether the user taps the link or just closes the tab. 15 minutes covers a BankID signing with margin. | SPEC §6.1, §7, §8.6, §9.12 CP-2, §13.3 S-25 and "Card callback page", §14.1.3, §14.3.4 E-37 to E-39 |
| D-162 | 2026-10-24 | Connect flow: E-37 `POST /cards/auth` creates a single-use attempt (32-byte `state`, stored as its SHA-256 hash, bound to user and device, 15-minute expiry, a newer attempt expires an older pending one) and returns Enable Banking's URL; the app opens it in a Custom Tab, the web in the same tab; BankID app-to-app on the phone, QR on a computer; the bank redirects to the new path `https://<KVITT_PUBLIC_HOST>/cards/callback` (E-39), where the server exchanges the code (`POST /sessions`), picks the card account (K0 confirms the rule when a session has several; D-177: the SJ Prio session holds one `CARD` account, the rule for several stays as written), stores the connection, starts the first fetch in the background and shows a small bilingual page "Kortet är anslutet" with the link "Tillbaka till Kvitt": `kvitt://cards/connected?attempt=<id>` (app) or `/app/cards?attempt=<id>` (web). The page never jumps by itself; the client learns the result by polling E-38 every 2 seconds while its connect screen is open and from the return link. Renewal is the same flow on the existing connection. | A custom-scheme link from a user's tap opens the app without App Links verification, which would need `/.well-known/assetlinks.json` served through the app gate and `autoVerify` (the app uses `autoVerify="false"`, AC-2.6); browsers may block a scripted jump to `kvitt://`, so polling makes the result independent of whether the user taps the link or just closes the tab. 15 minutes covers a BankID signing with margin. | SPEC §6.1, §7, §8.6, §9.12 CP-2, §13.3 S-25 and "Card callback page", §14.1.3, §14.3.4 E-37 to E-39 |
| D-163 | 2026-10-24 | `/cards/callback` passes NPM's app gate from outside (the bank's redirect comes from the user's browser, a Custom Tab on mobile data included, without the app key) and is never logged: a gate line `if ($uri = "/cards/callback")` and `location = /cards/callback` with `access_log off` and `error_log /dev/null crit`, as `/i/` (§10.5.2); the server logs it under its route template only. It is an unauthenticated endpoint for the rate limits (60 per minute per IP). K1 updates `infra/npm/kvitt.conf`, `infra/README.md` step 4 and repeats the nginx check. | The query holds a one-time authorisation code and the attempt's `state`; the gate must let the redirect through or the connection can only be made on the home Wi-Fi. | SPEC §8.6, §8.7, §10.5.2, AC-22.14; `infra/npm/kvitt.conf`, `infra/README.md` (K1) |
| D-164 | 2026-10-24 | History: the connect screen offers 7, 30, 90 and 180 days, default 30; the first fetch after an authorisation goes back that far. If K0 shows the bank gives less than 180 days, the choices are trimmed to what was reached (`TODO(spec)` K0). The setting can be shortened later (E-41, affects later renewals only, stored purchases stay); a longer one goes through "Förnya med BankID", and the UI says "En längre period kräver BankID igen". | The owner: "probably we do not have to fetch the entire history"; 30 days covers the purchases not yet entered; deeper history is available only right after BankID, so lengthening needs a renewal. | SPEC §9.6, §9.12 CP-3, §13.3 S-25, S-26, §14.3.4 E-41 |
| D-165 | 2026-10-24 | Refresh: the server fetches each active connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm, never more than 4 starts per connection per 24 hours, skipping a slot after a successful fetch less than 2 hours old; "Uppdatera" and pull to refresh call E-42, a user-present fetch carrying the user's IP and user agent in Enable Banking's PSU headers, at most once per connection per 60 seconds (a later tap within 60 seconds answers the current state), waiting up to 25 seconds. Each fetch is incremental from the latest stored booking date minus 10 days (late bookings and pending→booked), retried once with `strategy=longest` on `WRONG_TRANSACTIONS_PERIOD`. PSU headers per Enable Banking's documentation: "either all required PSU headers (in case of online data fetching) or none", the required set from the ASPSP's `required_psu_headers`; Kvitt sends `Psu-Ip-Address` and `Psu-User-Agent` always and the other required ones from the app's request, never `Psu-Geo-Location`. Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". Phones pull card rows on every household sync trigger, so background fetches reach the phone without a tap. Errors are kept on the connection (`bank_unavailable`, `rate_limited`, `consent_expired`, `failed`) and shown as "Kunde inte hämta köp. Senast uppdaterad <time>". New error code `card_bank_unavailable` (502). | PSD2's 4-per-24-hours limit for background access; slots spread over the day and away from the 04:00 and 06:00 jobs; the user-present path is not limited by PSD2 but banks throttle; 10 days covers card bookings, which settle within a few business days. "Auto-update" is therefore possible, as the owner hoped. | SPEC §8.6, §9.12 CP-4, CP-5, CP-7, CP-14, §13.3 S-23, §14.3.2, §14.3.4 E-42 |
| D-166 | 2026-10-24 | Consent: `access.valid_until` asks for the smaller of 180 days and the bank's `maximum_consent_validity` (seconds, from `GET /aspsps`; K0 reads the SJ Prio value), less 5 minutes; the stored `valid_until` is the session's. From 14 days before it, S-23 and S-26 show "Kortanslutningen upphör <date>" with "Förnya med BankID"; after it (or when Enable Banking reports the session expired or revoked) the status is `expired`, fetching stops, stored purchases keep working, and renewal keeps the connection and every purchase. New error code `card_consent_expired` (409). | BankID is needed only to connect and then every ≤180 days; two weeks' notice spans two weekends, enough to renew at a convenient moment. | SPEC §9.12 CP-6, AC-22.11, §13.3 S-23, S-26, §14.3.2 |
| D-164 | 2026-10-24 | History: the connect screen offers 7, 30, 90 and 180 days, default 30; the first fetch after an authorisation goes back that far. If K0 shows the bank gives less than 180 days, the choices are trimmed to what was reached (`TODO(spec)` K0; answered by D-177: at least 150 days right after BankID, so 180 stays). The setting can be shortened later (E-41, affects later renewals only, stored purchases stay); a longer one goes through "Förnya med BankID", and the UI says "En längre period kräver BankID igen". | The owner: "probably we do not have to fetch the entire history"; 30 days covers the purchases not yet entered; deeper history is available only right after BankID, so lengthening needs a renewal. | SPEC §9.6, §9.12 CP-3, §13.3 S-25, S-26, §14.3.4 E-41 |
| D-165 | 2026-10-24 | Refresh: the server fetches each active connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm, never more than 4 starts per connection per 24 hours, skipping a slot after a successful fetch less than 2 hours old; "Uppdatera" and pull to refresh call E-42, a user-present fetch carrying the user's IP and user agent in Enable Banking's PSU headers, at most once per connection per 60 seconds (a later tap within 60 seconds answers the current state), waiting up to 25 seconds. Each fetch is incremental from the latest stored booking date minus 10 days (late bookings and pending→booked), ~~retried once with `strategy=longest` on `WRONG_TRANSACTIONS_PERIOD`~~ (revised by D-178: fetched in windows of at most 30 days, `strategy` never sent, `WRONG_TRANSACTIONS_PERIOD` ends the walk back). PSU headers per Enable Banking's documentation: "either all required PSU headers (in case of online data fetching) or none", the required set from the ASPSP's `required_psu_headers`; Kvitt sends `Psu-Ip-Address` and `Psu-User-Agent` always and the other required ones from the app's request, never `Psu-Geo-Location`. Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". Phones pull card rows on every household sync trigger, so background fetches reach the phone without a tap. Errors are kept on the connection (`bank_unavailable`, `rate_limited`, `consent_expired`, `failed`) and shown as "Kunde inte hämta köp. Senast uppdaterad <time>". New error code `card_bank_unavailable` (502). | PSD2's 4-per-24-hours limit for background access; slots spread over the day and away from the 04:00 and 06:00 jobs; the user-present path is not limited by PSD2 but banks throttle; 10 days covers card bookings, which settle within a few business days. "Auto-update" is therefore possible, as the owner hoped. | SPEC §8.6, §9.12 CP-4, CP-5, CP-7, CP-14, §13.3 S-23, §14.3.2, §14.3.4 E-42 |
| D-166 | 2026-10-24 | Consent: `access.valid_until` asks for the smaller of 180 days and the bank's `maximum_consent_validity` (seconds, from `GET /aspsps`; K0 reads the SJ Prio value; D-177: 15552000 s, 180 days), less 5 minutes; the stored `valid_until` is the session's. From 14 days before it, S-23 and S-26 show "Kortanslutningen upphör <date>" with "Förnya med BankID"; after it (or when Enable Banking reports the session expired or revoked) the status is `expired`, fetching stops, stored purchases keep working, and renewal keeps the connection and every purchase. New error code `card_consent_expired` (409). | BankID is needed only to connect and then every ≤180 days; two weeks' notice spans two weekends, enough to renew at a convenient moment. | SPEC §9.12 CP-6, AC-22.11, §13.3 S-23, S-26, §14.3.2 |
| D-167 | 2026-10-24 | Server data: migration 0004, server schema 4 (§14.1.3): `card_sequence` (own counter `card_version`, so `server_version` and `GET /sync` are untouched), `card_connections` (one per user and provider; session id, account uid, `valid_until`, status `active`/`expired`/`disconnected`, `history_days`, last fetch and error, the last four background-fetch start times), `card_auth_attempts` (pruned after 7 days by the 04:00 job) and `card_purchases` (provider reference, status, direction, dates, `amount_ore` or the original amount and currency, merchant, `expense_id`, `ignored`, tombstone). A deleted purchase is a tombstone with its content erased. Disconnect (E-43, "Koppla från") revokes the session at Enable Banking (best effort), erases the session id and account uid, deletes the purchases neither linked nor ignored, and keeps the linked and ignored ones unless `erase_handled` is set; expenses stay; reconnecting reuses the connection. A migration test upgrades a v3 database in use. | One card each makes one connection per user and provider enough; a separate sequence keeps private rows out of the household stream; tombstones let a phone learn of deletions; keeping the user's decisions on disconnect stops a reconnect from offering purchases already handled. | SPEC §5.1, §9.12 CP-12, §14.1, §14.1.3, §14.3.3, §14.3.4 E-40, E-43, AC-22.12 |
| D-168 | 2026-10-24 | Transaction mapping: `provider_ref` = `entry_reference`, documented as "unique and immutable" but "in most cases … provided only for booked transactions" (else a hash of date, amount, indicator, text and occurrence; `transaction_id` "may change" and is not used), `DBIT`/`CRDT` → `debit`/`credit`, `PDNG`/`BOOK` → `pending`/`booked`, `purchase_date` = transaction date, else value date, else booking date; the decimal amount parsed to öre exactly; merchant from `creditor.name`, else the remittance lines, else "Kortköp", at most 140 code points. Pending→booked: a booked transaction with an unknown reference takes over a pending purchase with the same amount and direction, dates at most 3 days apart and the same CS-4 first token of the merchant; an unhandled pending purchase the bank stops reporting is deleted. A non-SEK transaction is stored with its original amount and currency, shown with "Utländsk valuta", gets no matches, and opens S-7 with the amount empty. Credits (refunds, deposits, the bill's payment) are listed only under "Alla", marked "Insättning", with no action, since §9.6 has no negative expense. Every field choice is a `TODO(spec)` K0 with this default. | The SJ Prio card's real field use is unknown until K0; these defaults follow the PSD2 Berlin Group shapes Enable Banking returns and keep the household's money rules (SEK, positive amounts) intact. | SPEC §9.12 CP-7, CP-8, §14.1.3, §14.3.3 |
| D-168 | 2026-10-24 | Transaction mapping: `provider_ref` = `entry_reference`, documented as "unique and immutable" but "in most cases … provided only for booked transactions" (else a hash of date, amount, indicator, text and occurrence; `transaction_id` "may change" and is not used), `DBIT`/`CRDT` → `debit`/`credit`, `PDNG`/`BOOK` → `pending`/`booked`, ~~`purchase_date` = transaction date, else value date, else booking date~~ (revised by D-179: booking date, else value date, else transaction date); the decimal amount parsed to öre exactly; merchant ~~from `creditor.name`, else the remittance lines~~ (revised by D-179: from the remittance lines, else `creditor.name`), else "Kortköp", at most 140 code points. Pending→booked (D-177: on this card pending transactions carry `entry_reference`, assumed kept at booking, so this is the secondary rule): a booked transaction with an unknown reference takes over a pending purchase with the same amount and direction, dates at most 3 days apart and the same CS-4 first token of the merchant; an unhandled pending purchase the bank stops reporting is deleted. A non-SEK transaction is stored with its original amount and currency, shown with "Utländsk valuta", gets no matches, and opens S-7 with the amount empty. Credits (refunds, deposits, the bill's payment) are listed only under "Alla", marked "Insättning", with no action, since §9.6 has no negative expense. Every field choice is a `TODO(spec)` K0 with this default (answered by D-177 and D-179). | The SJ Prio card's real field use is unknown until K0; these defaults follow the PSD2 Berlin Group shapes Enable Banking returns and keep the household's money rules (SEK, positive amounts) intact. | SPEC §9.12 CP-7, CP-8, §14.1.3, §14.3.3 |
| D-169 | 2026-10-24 | Linking: "Skapa utgift" opens S-7 as a new expense prefilled with the amount, the merchant text as description (trimmed to 100 code points, case unchanged), the purchase date, the card holder as payer, the account (from S-27 the account it came from, else the AC-8.6 default), the §9.11 category suggestion and the account's default split; "Spara" saves it and links the purchase (tap the row, "Skapa utgift", "Spara"). A purchase links to one expense and an expense is linked from at most one purchase per user (`expense_already_linked`, 409). "Ta bort koppling" (sheet, S-8) unlinks; deleting a linked expense, by either user, unlinks it on the server in the same savepoint (§14.4.4 step 7). On the phone link, unlink and ignore are local first and sent with E-44 after the next household push (offline-capable, like expenses); the web sends at once. Account detail (S-5, W-3) has "Från kortköp" (S-27); S-8 and W-6 show the holder "Från kortköp: <merchant>, <date>". Links are not audited or pushed. | Very few taps, offline like the expense it creates; sending after the push makes the expense exist on the server before the link names it; holder-only display keeps D-161. | SPEC §9.12 CP-9, AC-22.6, AC-22.8 to AC-22.10, AC-22.13, §13.3 S-5, S-7, S-8, S-27, §14.2.1, §14.2.4, §14.4.4 |
| D-170 | 2026-10-24 | Suggested matches: the household's non-deleted expenses (either user's) with the same `amount_ore`, dated within ±3 days, not linked from another purchase of the viewer, ordered by day difference, then `created_at` descending, then id, at most 3; a row with exactly one match shows "Koppla till <description>" (one tap links), with two or three "<n> möjliga utgifter". "Välj en annan utgift" lists any amount within ±14 days. Computed on the phone and in the browser by the pure function `matchCardPurchase` (`:domain`, `web/src/lib/domain/`), with the new vector file `contracts/test-vectors/match_card_purchase.json` (K1 writes it with a Python reference). | Equal amount and a few days' slack catch the partner's entry of the same purchase (pending dates and the entry date differ by a day or two); one rule in two implementations needs shared vectors (G-1's method). | SPEC §9.12 CP-10, AC-22.7, §14.5.5, §14.5.8 |
| D-171 | 2026-10-24 | Not in this version: bulk "Lägg till i konto…" (several purchases into one account at once; OQ-8), a notification for new card purchases (OQ-7, default none), automatic linking without a tap, and any change to an expense from the card side. | Each purchase usually needs a glance at its description and category, which S-7's prefill gives in two taps; bulk creation with default splits risks a batch of wrong categories the partner is notified about one by one. A push would need either content in FCM (D-161 forbids it) or a local notification design. | SPEC §12 OQ-7, OQ-8, §14.6 |
| D-172 | 2026-10-24 | Navigation: four bottom destinations, Översikt, Kortköp, Statistik, Inställningar (revises D-048's three; `KvittDestination.Cards` between `Home` and `Analytics`). Owner's answer: the tab is "Kortköp" / "Card purchases"; the settings entry is "Anslut kort" / "Connect card" (S-13 row, opening S-24). Both users always see the tab; without a connection it shows the empty state with "Anslut kort". | Turning purchases into expenses is daily work next to the add flow, Statistik is visited now and then, and Inställningar stays last by convention. | SPEC §13.2, §13.3 S-13, S-23, S-24; design-spec navigation (K2's proposal) |
| D-173 | 2026-10-24 | Android: a new module `:feature:cards` (S-23 Kortköp with the purchase sheet, S-24 Anslut kort, S-25 connect, S-26 the connection, S-27 Från kortköp); the UI contract `model/Cards.kt` with `CardRepository` (§14.2.4); `matchCardPurchase` in `:domain`; Room version 2 (`card_connections`, `card_purchases`, `card_sync_state`, `MIGRATION_1_2` with its test); the card cycle after each household sync; a Custom Tab (`androidx.browser`) for the bank; the deep link `kvitt://cards/connected`; fakes with scenarios for every state; goldens per state. Card artwork: the real SJ Prio Mastercard picture (owner's answer: "the real SJ Prio Mastercard artwork"), supplied by the owner, who cleared its use as a trademark for this private, non-distributed app (the "Licences first" step, cleared 2026-10-24); the owner drops it at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` (PNG, at least 1012 × 638 px, card aspect 1.586:1). Until the file exists `CardArt` draws a rounded card (corner 16 dp, aspect 1.586:1) filled with the `green` category token's light value in both themes, with the card's name in white (4.95:1), built from design tokens; no placeholder text. | One module per feature (§6.2); the image key keeps the catalogue open; a drawn card means the build and the goldens never depend on a file not yet supplied. | SPEC §6.2, §13.3 S-23 to S-27, §14.2, §14.2.3, §14.2.4, §14.7, §14.8 |
| D-174 | 2026-10-24 | Web: the same card features as a later plan K3, after Android's design (owner's answer: "yes, same card features, as a later plan"): W-21 Kortköp (`/app/cards`), W-22 Anslut kort (`/app/settings/cards`, BankID by QR in the browser, the bank's page in the same tab, the callback page's link back to `/app/cards?attempt=<id>`), W-23 the connection (`/app/settings/cards/:id`), a sidebar entry after Home, W-5's prefill (`/app/expenses/new?cardPurchase=<id>`), W-6's card line, W-3's "Från kortköp"; the artwork at `web/src/features/cards/assets/card-sj-prio-mastercard.png` (the owner drops the same file), with the same drawn fallback; `matchCardPurchase` in TypeScript passing the vectors. | Parity with the app (§15.1); same-tab navigation needs no pop-up permission; the image is served from the server's own origin, within the CSP. | SPEC §15.1, §15.3, §15.6, §15.9, §14.7 |
| D-175 | 2026-10-24 | Server configuration (the production control panel accepts only `https` redirect URLs, so the application's one registered redirect URL is `https://<KVITT_PUBLIC_HOST>/cards/callback`; the application id stays out of the repository's text): `KVITT_EB_APP_ID` with `KVITT_EB_KEY_FILE` (default `/secrets/cards/enable-banking.pem`) serves the admin and, unless `KVITT_EB_MEMBER_APP_ID` with `KVITT_EB_MEMBER_KEY_FILE` (default `/secrets/cards/enable-banking-member.pem`) is set, the member too; `KVITT_EB_API_URL` (default `https://api.enablebanking.com`) for tests. Missing id or key: card features off for that user with one start-up warning, as push. Compose mounts `./secrets/cards` read-only at `/secrets/cards` (`create_host_path: false`; the owner creates the folder, mode 0700, keys 0600) and passes the two ids from `.env`, which gains two optional keys (nine in all). Outbound HTTPS from the Kvitt Pi to `api.enablebanking.com`. New error code `cards_unavailable` (409). | Whether the partner's card can be linked to the owner's application is unknown until K0, so the configuration supports one application per user or one shared without code change; roles name the users, so no user id has to be looked up for the configuration; a folder mount lets the server start before any key exists. | SPEC §8.9, §9.12 CP-13, §10.1, §10.2, §10.4, §10.10, §14.3.2 |
| D-173 | 2026-10-24 | Android: a new module `:feature:cards` (S-23 Kortköp with the purchase sheet, S-24 Anslut kort, S-25 connect, S-26 the connection, S-27 Från kortköp); the UI contract `model/Cards.kt` with `CardRepository` (§14.2.4); `matchCardPurchase` in `:domain`; Room version 2 (`card_connections`, `card_purchases`, `card_sync_state`, `MIGRATION_1_2` with its test); the card cycle after each household sync; a Custom Tab (`androidx.browser`) for the bank; the deep link `kvitt://cards/connected`; fakes with scenarios for every state; goldens per state. Card artwork: the real SJ Prio Mastercard picture (owner's answer: "the real SJ Prio Mastercard artwork"), supplied by the owner, who cleared its use as a trademark for this private, non-distributed app (the "Licences first" step, cleared 2026-10-24); ~~the owner drops it at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` (PNG, at least 1012 × 638 px, card aspect 1.586:1). Until the file exists~~ (revised by D-180: committed at that path, cropped to the card, 1313 × 829, mapped from K2's first commit; the drawn card is the fallback for an unknown image key only) `CardArt` draws a rounded card (corner 16 dp, aspect 1.586:1) filled with the `green` category token's light value in both themes, with the card's name in white (4.95:1), built from design tokens; no placeholder text. | One module per feature (§6.2); the image key keeps the catalogue open; a drawn card means the build and the goldens never depend on a file not yet supplied. | SPEC §6.2, §13.3 S-23 to S-27, §14.2, §14.2.3, §14.2.4, §14.7, §14.8 |
| D-174 | 2026-10-24 | Web: the same card features as a later plan K3, after Android's design (owner's answer: "yes, same card features, as a later plan"): W-21 Kortköp (`/app/cards`), W-22 Anslut kort (`/app/settings/cards`, BankID by QR in the browser, the bank's page in the same tab, the callback page's link back to `/app/cards?attempt=<id>`), W-23 the connection (`/app/settings/cards/:id`), a sidebar entry after Home, W-5's prefill (`/app/expenses/new?cardPurchase=<id>`), W-6's card line, W-3's "Från kortköp"; the artwork at `web/src/features/cards/assets/card-sj-prio-mastercard.png` ~~(the owner drops the same file)~~ (revised by D-180: committed, the same cropped file as Android's, mapped from K3's first commit), with the same drawn fallback; `matchCardPurchase` in TypeScript passing the vectors. | Parity with the app (§15.1); same-tab navigation needs no pop-up permission; the image is served from the server's own origin, within the CSP. | SPEC §15.1, §15.3, §15.6, §15.9, §14.7 |
| D-175 | 2026-10-24 | Server configuration (the production control panel accepts only `https` redirect URLs, so the application's one registered redirect URL is `https://<KVITT_PUBLIC_HOST>/cards/callback`; the application id stays out of the repository's text): `KVITT_EB_APP_ID` with `KVITT_EB_KEY_FILE` (default `/secrets/cards/enable-banking.pem`) serves the admin and, unless `KVITT_EB_MEMBER_APP_ID` with `KVITT_EB_MEMBER_KEY_FILE` (default `/secrets/cards/enable-banking-member.pem`) is set, the member too; `KVITT_EB_API_URL` (default `https://api.enablebanking.com`) for tests. Missing id or key: card features off for that user with one start-up warning, as push. Compose mounts `./secrets/cards` read-only at `/secrets/cards` (`create_host_path: false`; the owner creates the folder, mode 0700, keys 0600) and passes the two ids from `.env`, which gains two optional keys (nine in all). Outbound HTTPS from the Kvitt Pi to `api.enablebanking.com`. New error code `cards_unavailable` (409). | Whether the partner's card can be linked to the owner's application is unknown until K0 (D-177: not tried; assumed yes by the owner, verified by K1's owner steps; this configuration is the fallback), so the configuration supports one application per user or one shared without code change; roles name the users, so no user id has to be looked up for the configuration; a folder mount lets the server start before any key exists. | SPEC §8.9, §9.12 CP-13, §10.1, §10.2, §10.4, §10.10, §14.3.2 |
| D-176 | 2026-10-24 | Plans K0 to K3 (K for kortköp). K0 is a super simple local test app on the MacBook for fast feedback loops before any Android work (owner's refinement, 2026-10-24): one command (`python3 docs/spikes/k0/app.py`, the standard library plus at most one JWT library), a tiny page on `localhost` with "Anslut SJ Prio (BankID)" (BankID by QR on the Mac), a history-days choice, "Uppdatera", "Bakgrundshämtning" (a fetch without the user headers, to test the 4-per-day path) and a plain list of the card's purchases (date, merchant, amount, pending or booked), so the owner can try the real flow and see real data quickly and the agent can iterate in minutes. Real purchase data may show on the owner's own screen but is never written to git, logs or reports; the key and the session live in the git-ignored folder, so a re-run reuses the session without a new BankID. As built (`2ce317f`): standard library only, the JWT signed with `openssl`; key, config and state in `docs/spikes/k0/.local/`; since the production control panel refuses `http://localhost` redirect URLs, the bank redirects to the registered `https://<KVITT_PUBLIC_HOST>/cards/callback` and the owner pastes the address bar into the app; K0's remaining work is running it with the owner and the partner and writing the report. Its report (`docs/spikes/K0-enable-banking.md`, aggregates only) answers every K0 `TODO(spec)`, recorded in SPEC v0.36 before K1 starts. Then K1 (server and contract), K2 (Android) and K3 (web) run in parallel; K1 lands first, then K2, then K3 (it imports the card strings K2 adds to `docs/ux/strings.csv`); K2 and K3 rebase on K1 before their gates. | Fast feedback on the real bank before building three clients; the unknowns (pending behaviour, history depth, consent length, the partner's card, background access) decide details of the spec, so they are measured before the contract is fixed. | SPEC §11.1, §11.2, §11.3, §14.7; `PLAN-K0.md` to `PLAN-K3.md`, `prompts/K0.md` to `prompts/K3.md` |
| D-177 | 2026-10-24 | K0's outcome (the spike run on 2026-10-24 on the owner's SJ Prio Mastercard with `docs/spikes/k0/app.py`; report `docs/spikes/K0-enable-banking.md`, aggregates only). Measured: the ASPSP is listed in `GET /aspsps?country=SE` (126 Swedish ASPSPs) as exactly `SJ Prio Mastercard`, `SE`, `beta` false, `maximum_consent_validity` 15552000 s (180 days), `psu_types` [`personal`], auth method `REDIRECT`, `required_psu_headers` [`psu-ip-address`]; activating the application by linking the owner's card in the control panel worked (restricted mode); production refuses `http://localhost` redirect URLs and the registered `https://kvitt.suorra.duckdns.org/cards/callback` with the pasted-back address worked; the session holds one account, SEK, `cash_account_type` `CARD`, valid until 2027-04-22; a user-present fetch (`Psu-Ip-Address`, `Psu-User-Agent`) and a background fetch (no PSU header) both answered HTTP 200; at least 150 days of history were reachable right after BankID; every transaction was in SEK, foreign merchants included; the two credits were payments of the card bill. Spec answers: the registry entry's ASPSP (CP-1); the account rule for several accounts stays as written, untested (CP-2); 7/30/90/180 days stay, default 30 (CP-3, §9.6); the 4-per-24-hours background rule stays (CP-4); PSU header names compared case-insensitively (CP-5); `valid_until` = now + 180 days − 5 minutes (CP-6); the non-SEK path stays as a safeguard (CP-7). Not measured, by the owner's decision of 2026-10-24 ("let's just assume it all works now given your test"): (a) the partner's card links to the owner's application (the per-user application of D-175 stays as the fallback); (b) a pending transaction keeps its `entry_reference` when booked (it is present on pending rows and stable between fetches), with CP-8's amount-and-date rule kept as the secondary rule; (c) the session is reused on later days without BankID (the consent runs to 2027-04-22). K1's owner steps verify (a) to (c) on the real server, with `card_fetch` counters for (b). | The owner preferred starting K1 to K3 now over waiting a day and involving the partner; each assumption has a fallback already in the spec (D-175's member application, CP-8's secondary rule, CP-6's renewal), so a wrong assumption costs a configuration step, not a redesign. | SPEC §9.6, §9.12 (intro, CP-1 to CP-6, CP-8, CP-13), §11.2, §11.3; `docs/spikes/K0-enable-banking.md`; PLAN-K0, PLAN-K1 §8 |
| D-178 | 2026-10-24 | Windowed history fetch (revises D-165's `strategy=longest` retry and CP-7's paging). K0: every transactions response of the SJ Prio card holds at most 100 transactions and never a `continuation_key` (90 days: 100 rows covering only the newest 60 days; 180 days with `strategy=longest`: 100 rows, a mixed selection; 30 days: 52; 7 days: 12; five consecutive 30-day windows: 67, 85, 55, 49, 52, 308 distinct). Rule: a fetch's range is split into consecutive windows of at most 30 days, newest first, each asked with `date_from` and `date_to`; a window that returns 100 or more transactions is split in half (the newer half takes the odd day) and both halves are asked again, down to one day; a one-day window still at 100 is kept and counted as truncated; a `continuation_key`, if ever returned, is still followed; `strategy` is never sent; `WRONG_TRANSACTIONS_PERIOD` for a window skips it and every older one and the fetch succeeds; rows are kept as returned (they may be dated one or two days outside their window) and de-duplicated across windows by `provider_ref`, the newer window's copy winning; any other error ends the fetch with CP-14's error after applying the windows already fetched; only a complete fetch (every window answered, none truncated) deletes vanished pending purchases. The `card_fetch` log line gains the counts of requests, split and truncated windows, and `booked_by_ref` / `booked_by_match` (pending purchases booked under their own reference or taken over by CP-8's secondary rule). | 30 days stays under the cap with margin on this card (the busiest measured month was 85 rows) and costs 6 requests for the 180-day first fetch and one for an incremental fetch, so a background fetch stays one bank access; halving handles a busier month without a new constant; a cut response is otherwise silent, so the count is the only signal; deleting a pending purchase on an incomplete picture would lose a real purchase. | SPEC §8.7, §9.12 CP-3, CP-4, CP-7, CP-8, §11.2 K1; PLAN-K1 |
| D-179 | 2026-10-24 | Transaction mapping from K0's field survey (100 transactions; revises D-168's merchant and date order). Merchant: the `remittance_information` lines joined with a space (K0: exactly one line on every row, the merchant text), else `creditor.name` (K0: never present), else "Kortköp". `purchase_date`: `booking_date`, else `value_date` (pending rows have no booking date), else `transaction_date` (K0: never present); a row with none is skipped; `booking_date` stored when booked. `provider_ref` stays `entry_reference` (K0: on every row, pending included, unique, stable); the fallback hash uses `purchase_date`. `bank_transaction_code` (K0: `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2, `FEE` 1) is not used: the direction alone decides, so a debit `FEE` (a card fee) or `PAYMENT` is a purchase to handle that the holder turns into an expense or ignores, and a credit is listed under "Alla" as before. Never stored, logged or sent to a client: `debtor.name` (the card holder), `debtor_account` (a masked card number, scheme `CPAN`), `creditor.postal_address` (the merchant's country and town), `transaction_id`, `bank_transaction_code`. No schema change: the fields Kvitt keeps are those of migration 0004. | The fields the card actually fills; the booking date is the bank's settled date, and a pending purchase shows its value date until it is booked; a fee is money the holder paid and may share or ignore like a purchase, while a code-based rule would hide fees and payments made with the card; the holder's name and card number add nothing for the holder and are personal data the server need not keep. | SPEC §9.12 CP-7, CP-8; PLAN-K1 |
| D-180 | 2026-10-24 | Card artwork present from the start (revises D-173 and D-174, whose fallback stays for unknown image keys). The owner's SJ Prio Mastercard picture (committed `4339d01`, 1600 × 1094 with transparent margins) is cropped to the card's opaque bounding box (x 143 to 1455, y 134 to 962): 1313 × 829, aspect 1.584, transparent only at the rounded corners and in the semicircular notch on the right edge, as on the physical card; losslessly recompressed. The same file is committed at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` and `web/src/features/cards/assets/card-sj-prio-mastercard.png`. K2 and K3 map `sj_prio_mastercard` to it from their first commit and draw it at its own aspect ratio without clipping (the picture carries its own corners and notch); the goldens use it; the owner's artwork steps are removed from PLAN-K2, PLAN-K3 and HANDOFF. The picture shows the SJ and Mastercard logos; the owner cleared that use for this private, non-distributed app on 2026-10-24 ("Licences first"). | The file exists, so neither plan needs a follow-up to re-record goldens; cropping removes margins that would shrink the card inside `CardArt`'s box. | SPEC §9.12 CP-1, §11.2 K2, K3; PLAN-K2, PLAN-K3 |
+128
View File
@@ -0,0 +1,128 @@
# K0: Enable Banking and the SJ Prio Mastercard
Date: 2026-10-24. Plan `PLAN-K0.md` (D-176). Tool: the local test app `docs/spikes/k0/app.py` as committed in `2ce317f`, run on the owner's MacBook against Enable Banking's production API in restricted mode, with the owner's own SJ Prio Mastercard and his BankID. The application's key, the session and every fetched transaction stayed in the git-ignored `docs/spikes/k0/.local/`. This report holds aggregates only: no names, merchant texts, amounts, purchase dates, identifiers, the application id or the card number. Recorded in SPEC v0.36 (D-177 to D-180).
## 1. What was tested
| Step | What | Result |
|---|---|---|
| Account and licence | Enable Banking sign-up, Terms of Service accepted by the owner (the "Licences first" step) | done |
| Application | Restricted production application, redirect URL `https://kvitt.suorra.duckdns.org/cards/callback` | registered; the key file downloaded and moved to `.local/` |
| Redirect URL | `http://localhost:8765/callback` in the production control panel | refused: "uses unsupported scheme" |
| Activation | Control panel › "Activate by linking accounts", the owner's card, BankID | worked; the application runs in restricted mode |
| Bank list | `GET /aspsps?country=SE` | 126 Swedish ASPSPs, SJ Prio among them (§2) |
| Connect | `POST /auth`, BankID by QR, the redirect address pasted back into the app, `POST /sessions` | worked (the callback page does not load yet, as expected; the address bar kept `code` and `state`) |
| Session | `GET /sessions/{id}` | 1 account (§3) |
| User-present fetch | transactions with `Psu-Ip-Address` and `Psu-User-Agent` | HTTP 200 |
| Background fetch | transactions without any PSU header | HTTP 200 |
| History depth | 7, 30, 90 and 180 days back; five consecutive 30-day windows | §4 |
| Fields | presence and shape over a 100-row fetch | §5 |
Not run (owner's decision, 2026-10-24: "let's just assume it all works now given your test"): linking the partner's card and fetching it (PLAN-K0 §8 steps 6 and 10), and the next-day repeat (step 9). §7 lists what is assumed instead and where it is verified.
## 2. The ASPSP
| Field | Value |
|---|---|
| `name` | `SJ Prio Mastercard` (exactly) |
| `country` | `SE` |
| `beta` | false |
| `maximum_consent_validity` | 15552000 s (180 days) |
| `psu_types` | [`personal`] |
| auth method | `REDIRECT` |
| `required_psu_headers` | [`psu-ip-address`] (lower case as listed) |
## 3. Session and consent
* One account in the session, currency SEK, `cash_account_type` `CARD`.
* `access.valid_until` asked for now + 180 days − 5 minutes (the ASPSP's maximum is 180 days, equal to the cap); the session returned `valid_until` 2027-04-22.
* User-present fetch (`Psu-Ip-Address`, `Psu-User-Agent`) and background fetch (no PSU header): both HTTP 200. Only one background fetch was made, so the number accepted per 24 hours is not measured.
## 4. History and the 100-row cap
Every transactions response held at most 100 rows and never a `continuation_key`.
| Request | Rows | Note |
|---|---|---|
| 7 days, default strategy | 12 | |
| 30 days, default strategy | 52 | |
| 90 days, default strategy | 100 | covers only the newest 60 days: older rows are cut |
| 180 days, `strategy=longest` | 100 | neither the newest 100 nor a contiguous range: a mixed selection |
| five consecutive 30-day windows, `date_from` and `date_to`, 150 days back | 67, 85, 55, 49, 52 | all HTTP 200, no continuation key, 308 distinct rows in total |
The five-window series asked with both `date_from` and `date_to`; the committed app's form sends only `date_from`.
Findings:
* A range longer than the card's 100-row cap loses rows silently: no error, no continuation key. The fix is fetching in windows (D-178): at most 30 days each, and a window that returns 100 rows is split in half and fetched again.
* At least 150 days of history were reachable right after BankID (the oldest window answered HTTP 200 with rows). The 180-day choice stays.
* No `WRONG_TRANSACTIONS_PERIOD` was seen.
* Rows returned for a window can have booking dates one or two days outside the requested window (the bank seems to filter on another date), so windows overlap in what they return. De-duplication across windows by `entry_reference` gave the 308 distinct rows.
## 5. Transaction fields (100-row sample)
| Field | Presence | Notes |
|---|---|---|
| `entry_reference` | 100 % | also on both pending rows; all unique; unchanged between two fetches |
| `transaction_id` | 100 % | not used (Enable Banking: "may change") |
| `booking_date` | 98 % | missing exactly on the pending rows |
| `value_date` | 100 % | |
| `transaction_date` | 0 % | |
| `remittance_information` | 100 % | exactly one line on every row; it is the merchant text |
| `creditor.name` | 0 % | |
| `creditor.postal_address.country` | 100 % | SE 79, DE 14, IE 5, EE 2 |
| `creditor.postal_address.town_name` | 97 % | |
| `debtor.name` | 96 % | one distinct value: the card holder |
| `debtor_account.other.identification` | 97 % | `scheme_name` `CPAN`; one distinct value, a masked card number |
| `bank_transaction_code.code` | 100 % | `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2, `FEE` 1 |
| `merchant_category_code`, `exchange_rate`, `balance_after_transaction`, `note`, `reference_number` | 0 % | |
* Amounts: positive decimal strings with a point; the sign comes from `credit_debit_indicator`.
* Currency: every row `SEK`, purchases at German, Irish and Estonian merchants included (`exchange_rate` empty). The card account reports foreign purchases already converted to SEK.
* Status: `BOOK` 98, `PDNG` 2. Direction: `DBIT` 98, `CRDT` 2.
* Credits: both `CRDT` rows were `PAYMENT` rows, payments of the card bill. No refund in the sample.
## 6. Answers to the K0 `TODO(spec)` items (SPEC v0.35 §9.12)
| Item | Answer | SPEC v0.36 |
|---|---|---|
| CP-1 ASPSP name and country | `SJ Prio Mastercard`, `SE` (measured) | the registry entry uses them |
| CP-2 account pick with several accounts | the session has 1 account (`CARD`), so the rule is untested | the default rule stays: the first `CARD` account, else the first |
| CP-3 history depth right after BankID | at least 150 days (measured with windows) | 7, 30, 90, 180 stay; default 30 |
| CP-4 background fetch without PSU headers | accepted, HTTP 200 (one fetch) | the 4-per-24-hours rule stays as written |
| CP-5 `required_psu_headers` and acceptance | [`psu-ip-address`]; a fetch with `Psu-Ip-Address` and `Psu-User-Agent` was accepted (from the MacBook) | send those two, compare names case-insensitively; from the Pi it is checked by K1's owner steps |
| CP-6 `maximum_consent_validity` | 15552000 s = 180 days; the session ran to 2027-04-22 | `valid_until` = now + 180 days − 5 min |
| CP-7 `strategy=longest` | 100 rows, a mixed selection; the cap is 100 rows per response with no continuation key | windowed fetch (D-178); `strategy` is never sent |
| CP-7 dates | `booking_date` on booked rows, `value_date` on all, `transaction_date` never | `purchase_date` = `booking_date`, else `value_date` |
| CP-7 foreign purchases in SEK | yes | the non-SEK path stays as a safeguard |
| CP-7 merchant field | `remittance_information` (one line); `creditor.name` never present | merchant = `remittance_information`, else `creditor.name`, else "Kortköp" |
| CP-8 pending → booked | `entry_reference` is present on pending rows; the change to booked was not observed | owner's assumption: the reference is kept; the amount-and-date rule stays as the secondary rule |
| CP-8 credits | 2 of 100, both card-bill payments | stored, shown only under "Alla", no action (unchanged) |
| CP-13 partner's card on the owner's application | not tried | owner's assumption: yes; the per-user application (D-175) stays as the fallback |
Further rules from the field survey (D-179): `bank_transaction_code` is not used and the direction alone decides, so a debit `FEE` row is a purchase to handle (the holder turns it into an expense or ignores it). The holder's name, the masked card number (`CPAN`) and the merchant's address are never stored, logged or shown.
## 7. Assumed, not measured (owner's decision, 2026-10-24)
| Assumption | Why it is reasonable | Verified by |
|---|---|---|
| The partner's card can be linked to the owner's application and fetched | restricted mode serves the accounts linked in the application's control panel; linking is per account with the account holder's BankID | PLAN-K1 §8 step 5 (with the fallback of D-175 if refused) |
| A pending row keeps its `entry_reference` when it is booked | the reference is present on pending rows and stable between fetches | PLAN-K1 §8 step 7 (the `card_fetch` counters and the owner's yes/no) |
| The session is reused the next day without BankID | the consent runs to 2027-04-22 | PLAN-K1 §8 step 6 |
## 8. Results (owner's run)
| Owner step (PLAN-K0 §8) | Result |
|---|---|
| 1 Sign up, accept the Terms of Service | yes |
| 2 Production application with the callback URL; the key downloaded | yes |
| 3 Key moved into `docs/spikes/k0/.local/` | yes |
| 4 NPM `/cards/callback` block (optional) | not recorded; it is part of K1's NPM step |
| 5 "Activate by linking accounts" with the owner's card | yes |
| 6 The partner's card | not run (assumed, §7) |
| 7 Connect with BankID, paste the address, the card account shown | yes, 1 account |
| 8 "Uppdatera" and "Bakgrundshämtning" | both fetched (HTTP 200) |
| 9 The next day without BankID | not run (assumed, §7) |
| 10 The partner's run | not run (assumed, §7) |
The key file stays only in the main checkout's git-ignored `docs/spikes/k0/.local/`; K1's owner steps copy it to the Pi (`~/kvitt/secrets/cards/`, D-175).