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>
This commit is contained in:
+15
-4
@@ -3,7 +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 `5305f37` (W0b web shared follow-ups), pushed to `origin` 2026-10-24; before it `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.
|
||||
- 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).
|
||||
- **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).
|
||||
@@ -78,6 +79,10 @@ 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).
|
||||
- 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.
|
||||
@@ -113,7 +118,12 @@ 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).
|
||||
|
||||
## 4. Owner questions (asked, unanswered)
|
||||
- OQ-7 (SPEC §12): a notification for new card purchases? Default: none in this version (D-171).
|
||||
- OQ-8 (SPEC §12): turning several card purchases into expenses at once? Default: not in this version (D-171).
|
||||
- Firewall rule limiting the Kvitt Pi's port 8080 to the HA Pi: deferred (2026-10-24, D-156); ask again only if the owner raises it.
|
||||
- Image-build location: the Pi 3 runner or the Mac fallback; the owner decides after the current run's result (D-156).
|
||||
- State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built?
|
||||
@@ -123,9 +133,10 @@ Standing note for every in-app update on the Samsung phones: turn Auto Blocker o
|
||||
- Nothing.
|
||||
|
||||
## 6. Numbering
|
||||
- Decisions: D-001 to D-157 used, except D-008, D-009 and D-150 (never used). Next free: **D-158**.
|
||||
- Spec version: v0.34 written (L-10 implemented, W0b; no new decision). Next **v0.35**.
|
||||
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Unchanged by v0.27 to v0.34 (no plan since needed a migration).
|
||||
- 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).
|
||||
- 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**.
|
||||
|
||||
## 7. How the work is run
|
||||
|
||||
+20
-1
@@ -52,7 +52,7 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`50339ae`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) |
|
||||
| D-046 | 2026-10-18 | Importer CLI semantics. `--user <Splitwise name>=<user>` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`50339ae`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md |
|
||||
| D-047 | 2026-10-18 | Visual direction: U1's direction 3 "Kalas" (cards, very round shapes, a solid category-colour tile on every expense, the seesaw balance) with the muted palette of `docs/ux/directions/3-kalas/` (owner approved 2026-10-18). Typeface per D-020 (Material 3 default); Kalas's preview font Nunito is dropped. U2's `design-spec.md` carries the tokens. | Owner's pick (UX-1); muted because bright colours tire over years of daily use. | SPEC §9.7, §13.4 |
|
||||
| D-048 | 2026-10-18 | Navigation stays at three destinations (Översikt, Statistik, Inställningar); §13.2 is binding. An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth tab and not taken for v1. | Owner's answer (UX-2). | SPEC §13.2 |
|
||||
| D-048 | 2026-10-18 | ~~Navigation stays at three destinations (Översikt, Statistik, Inställningar);~~ (revised by D-172: four destinations, Översikt, Kortköp, Statistik, Inställningar) §13.2 is binding. An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth tab and not taken for v1. | Owner's answer (UX-2). | SPEC §13.2 |
|
||||
| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`aa765bb`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 |
|
||||
| D-050 | 2026-10-18 | S-1 has a required "Ditt namn" display-name field (1 to 40 code points, §9.6) on a first pairing, hidden in the "Para ihop telefonen igen" variant; a partner confirms it again on S-2 (AC-2.10). | UX-4: AC-2.4 sends a display name with the accept call, but S-1 listed no field for it. Owner OK. | SPEC AC-2.4, §13.3 (S-1) |
|
||||
| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. Fulfilled in SPEC v0.9 (2026-10-18): the §9.7 values are those of `docs/ux/design-spec.md` §2.3 (U2, landed `6da6b1b`), `slate` `#5E2D4E` / `#B0719A`, `amber` `#937D63` / `#DFCFC0`, light `brown` `#6B5244`. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 |
|
||||
@@ -162,3 +162,22 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-155 | 2026-10-21 | A failed or declined update install is no longer silent. The `PackageInstaller` session's final status reaches `UpdateRepository` by session id (`InstallResultReceiver` hands it to the waiting `AndroidApkInstaller` call; `ApkInstaller.install` returns a `PendingInstall`). `STATUS_FAILURE_BLOCKED` gives `UpdateState.Failed(info, UpdateFailure.Blocked)`: "Kunde inte installera uppdateringen" (`update_install_failed`) with the security-settings text (`update_install_blocked`); `STATUS_FAILURE_ABORTED` (the user declined the system dialog) goes back to `Available` without an error; every other failure gives `Failed(info, UpdateFailure.Install(detail))` with the installer's own status message as the body. `UpdateState.Failed` gains `reason: UpdateFailure = UpdateFailure.Download` (`sealed interface UpdateFailure { Download; Blocked; Install(detail: String?) }`, `:core:model` `Sync.kt`), so existing fakes are unchanged. The cached APK is deleted once the installer answers, so "Försök igen" downloads again. Goldens `S-17-install-blocked`, `S-17-install-failed`, `s22_about_install_blocked` new, `update_banner` re-recorded. Landed `d686ee0`. | A refused or failed install left the banner as it was, so a tap on "Uppdatera" seemed to do nothing. Showing the reason tells the user which setting to change or what the installer said; a declined dialog is the user's choice, not an error. | SPEC AC-16.7, §14.2.4; `docs/ux/design-spec.md` §3.6 `UpdateBanner`, S-17; `docs/ux/strings.csv`; `android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt`, `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt`, `android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt` |
|
||||
| D-156 | 2026-10-24 | Owner answers and phone observations of 2026-10-24. (1) OQ-6 (security review L-6): no confirmation step on S-1 for an invite link that prefills an unusual server; the default stands (S-1 shows the server row expanded, D-116). (2) Security review L-10: a member who names a device of the other user gets `not_found`, the same as an unknown id, not `forbidden` (default accepted); implemented in `07c36d2` (SPEC v0.34): `revoke_device` in `server/crates/kvitt-server/src/api/devices.rs` answers `not_found` with the unknown-id body, and `contracts/openapi.yaml` drops E-16's 403 (contract change, §14.8). (3) A host firewall rule limiting the Kvitt Pi's port 8080 to the Home Assistant Pi (`HA_HOST`): not now, deferred (open). (4) `mem_limit` in `infra/pi/compose.yaml` stays; the Pi kernel without the memory cgroup ignores it with a warning, accepted. (5) Images keep being built by the Gitea workflow on the Pi 3 runner for now; the owner waits for the result of the run in progress before deciding otherwise; `infra/build-images.sh` on the Mac stays the fallback. The runner Pi's disk reached 11 of 15 GB during builds; the owner runs `docker builder prune -af` between builds. (6) Verified on the phones: Samsung Auto Blocker was the cause of the silent update failure (turning it off let the update install; the owner turned it back on), so the in-app install needs it off each time, and the D-155 blocked text names it; the editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) confirmed on the device; both phones run release 140. (7) The first backup on the Kvitt Pi ran and a snapshot pair is in the Drive folder (AC-18.1, verified by the owner). (8) The web client is first used from a browser on the owner's laptop; the phones keep the app. | The owner answered the open questions of HANDOFF §4 and SPEC §12 and reported what the phones and the Pi showed; L-10's leak is minor between two trusted users, but `not_found` hides whose a device is at no cost. Auto Blocker is a Samsung setting the app cannot change, so the spec and the owner's checklist name it. | SPEC AC-3.2, §8.4, E-16 (§14.3.4), AC-16.3, AC-18.1, §10.2, §10.3, §11.4, §12 OQ-6, §15.2; `infra/README.md` "Pi: what the owner does"; `docs/HANDOFF.md` |
|
||||
| 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-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-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-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-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-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` |
|
||||
|
||||
Reference in New Issue
Block a user