SPEC v0.38: K1's report recorded (D-181 to D-188), K2 v0.39 and K3 v0.40
K1 landed671c00d(card purchases on the server, schema 4, E-36 to E-44). Decisions: D-181 history counts today (6 windows for 180 days, revises CP-3 and CP-7); D-182 the server's CS-4 first token; D-183 Enable Banking errors to CP-14; D-184 callback (expired state stored as expired, revises CP-2; atomic claim; session without accounts revoked); D-185 fetch details; D-186 PSU headers; D-187 E-41, E-43, E-44 details, CardAttempt.failure a plain string; D-188 placement and the secrets/cards compose mount. D-189, D-190 unused. SPEC: §9.12 CP-2 to CP-5, CP-7, CP-8, CP-14; §10.2; §11.1, §11.2 K1 row with the landing gates, §11.3; §14.1.3; §14.3.3; §14.3.4 (E-1 schema_version 4, E-37, E-39, E-41, E-43, E-44); §14.3.5; §14.7; change log. PLAN-K2, PLAN-K3 and prompts/README.md name v0.39 and v0.40. HANDOFF: K1 landed, K2 and K3 running, the Android gate red until K2, K1 owner steps 1 to 4 pending, next free D-212 and v0.39. Pre-commit hook bypassed (--no-verify): docs only; its Android fast tests fail only in :domain VectorFilesTest, red on main by design until K2 lands (as for671c00d). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.37** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.38** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**.
|
||||
|
||||
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
|
||||
|
||||
@@ -943,21 +943,21 @@ The rules of FR-22. The card holder is the user who connected the card; "purchas
|
||||
|
||||
**CP-1 Catalogue (D-160).** Supported cards are entries of a provider registry in `kvitt-server` (`src/cards/providers.rs`), served by E-36. Each entry has an open-string id, a display name, the issuer, the Enable Banking ASPSP name and country, an image key and its source adapter (`enable_banking`); nothing outside the adapter names the vendor, and ids are never enums (`CLAUDE.md`). The registry has one entry: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name `SJ Prio Mastercard`, country `SE`, image key `sj_prio_mastercard` (K0: listed exactly so in `GET /aspsps?country=SE`, one of 126 Swedish ASPSPs, `beta` false, `psu_types` [`personal`], auth method `REDIRECT`). The app and the web map the image key to their bundled artwork, committed in both (D-180); an unknown key draws the fallback card. A user has at most one connection per provider (D-167).
|
||||
|
||||
**CP-2 Connecting (D-162).** E-37 creates an authorisation attempt for the caller (user and device), the provider, the chosen history and the client (`android` or `web`): a random 32-byte `state` (base64url, stored only as its SHA-256 hash, like invite codes, §8.3), status `pending`, expiry 15 minutes after creation. It calls Enable Banking `POST /auth` with `access.valid_until` (CP-6), the ASPSP, the `state`, `redirect_url` = `<KVITT_PUBLIC_URL>/cards/callback` and `psu_type` `personal`, and returns the attempt id, the bank URL and the expiry. A new attempt for the same user and provider expires a pending older one. The client opens the URL (Android: a Custom Tab, D-173; web: the same tab, D-174); the user signs with BankID (app-to-app on the phone, QR in a desktop browser). The bank redirects the browser to E-39 with `code` and `state` (or `error`). E-39 looks the attempt up by the hash of `state`; an unknown, used or expired `state`, or an `error`, marks the attempt `failed` (when known) and shows the failure page; otherwise it calls Enable Banking `POST /sessions` with the code, picks the card account (exactly one account in the session: that one; several: the first whose `cash_account_type` is `CARD`, else the first; none: `failed` with `no_account`; K0: the SJ Prio session holds exactly one account, currency SEK, `cash_account_type` `CARD`), creates or updates the user's connection for that provider (status `active`, the new session id, account uid, `valid_until`, history), marks the attempt `connected`, starts the first fetch in the background (CP-4, user present with the callback request's address and user agent) and shows the success page. The attempt is single use: a second callback with the same `state` shows the failure page and changes nothing. The pages (bilingual by `Accept-Language`, copy in §13.3 "Card callback page") link back to the client: `kvitt://cards/connected?attempt=<attempt id>` for `android`, `/app/cards?attempt=<attempt id>` for `web`. The page never redirects by itself (a browser may block a scripted jump to `kvitt://`); the client learns the result by polling E-38 every 2 seconds while its connect screen is open (S-25, W-22) and when the return link opens it. Renewing an existing connection (CP-6) is the same flow; on success the connection keeps its id and every purchase, and the previous session is revoked at Enable Banking (best effort).
|
||||
**CP-2 Connecting (D-162).** E-37 creates an authorisation attempt for the caller (user and device), the provider, the chosen history and the client (`android` or `web`): a random 32-byte `state` (base64url, stored only as its SHA-256 hash, like invite codes, §8.3), status `pending`, expiry 15 minutes after creation. It calls Enable Banking `POST /auth` with `access.valid_until` (CP-6), the ASPSP, the `state`, `redirect_url` = `<KVITT_PUBLIC_URL>/cards/callback` and `psu_type` `personal`, and returns the attempt id, the bank URL and the expiry. A new attempt for the same user and provider expires a pending older one. The client opens the URL (Android: a Custom Tab, D-173; web: the same tab, D-174); the user signs with BankID (app-to-app on the phone, QR in a desktop browser). The bank redirects the browser to E-39 with `code` and `state` (or `error`). E-39 looks the attempt up by the hash of `state` (D-184): an unknown or already used `state` changes nothing and shows the failure page; an expired one stores the attempt as `expired` and shows it too; otherwise the attempt is claimed atomically (only one callback gets past this point) before Enable Banking is asked for anything; then an `error` marks it `failed` with `cancelled`, a callback with neither `code` nor `error` `failed` with `bank_error`, and with a code it calls Enable Banking `POST /sessions` with the code, picks the card account (exactly one account in the session: that one; several: the first whose `cash_account_type` is `CARD`, else the first; none: `failed` with `no_account`, and that session is revoked at Enable Banking, best effort, D-184; K0: the SJ Prio session holds exactly one account, currency SEK, `cash_account_type` `CARD`), creates or updates the user's connection for that provider (status `active`, the new session id, account uid, `valid_until`, history), marks the attempt `connected`, starts the first fetch in the background (CP-4, user present with the callback request's address and user agent) and shows the success page. The attempt is single use: a second callback with the same `state` shows the failure page and changes nothing. The pages (bilingual by `Accept-Language`, copy in §13.3 "Card callback page") link back to the client: `kvitt://cards/connected?attempt=<attempt id>` for `android`, `/app/cards?attempt=<attempt id>` for `web`. The page never redirects by itself (a browser may block a scripted jump to `kvitt://`); the client learns the result by polling E-38 every 2 seconds while its connect screen is open (S-25, W-22) and when the return link opens it. Renewing an existing connection (CP-6) is the same flow; on success the connection keeps its id and every purchase, and the previous session is revoked at Enable Banking (best effort).
|
||||
|
||||
**CP-3 History (D-164).** The connect screen offers "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30 days. The first fetch after an authorisation asks for transactions from today (Europe/Stockholm) minus that many days, in windows (CP-7). K0: right after BankID the card gave at least 150 days (five consecutive 30-day windows back to 150 days, all answered with rows; the last 30 days to 180 were not asked by window), so 180 stays offered (D-177). The setting can be shortened later (E-41; it only affects later renewals, stored purchases stay); a longer value needs a fresh BankID, so S-26 offers it only through "Förnya med BankID" (E-37 with the new value), and says so ("En längre period kräver BankID igen").
|
||||
**CP-3 History (D-164).** The connect screen offers "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30 days. The first fetch after an authorisation asks for transactions of that many days up to today (Europe/Stockholm), today counted as one of them (`date_from` = today − (`history_days` − 1), D-181), in windows (CP-7). K0: right after BankID the card gave at least 150 days (five consecutive 30-day windows back to 150 days, all answered with rows; the last 30 days to 180 were not asked by window), so 180 stays offered (D-177). The setting can be shortened later (E-41; it only affects later renewals, stored purchases stay); a longer value needs a fresh BankID, so S-26 offers it only through "Förnya med BankID" (E-37 with the new value), and says so ("En längre period kräver BankID igen").
|
||||
|
||||
**CP-4 Background fetch (D-165).** Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". The server fetches every `active` connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm (four slots spread over the day, away from the 04:00 pruning and the 06:00 recurring run), without the user's headers (not user present). A slot is skipped when the connection started 4 background fetches in the preceding 24 hours (`background_fetches`, the start times of the last four), when any fetch for it succeeded less than 2 hours before, or when one is running. The scheduler reads the clock as §9.5 does; after downtime it runs at most the slot that is due, never a backlog. K0: a fetch without the user's headers was accepted for the SJ Prio card (HTTP 200); how many the bank accepts per 24 hours was not measured, so the 4-per-24-hours rule stands. A background fetch normally makes one bank request (its range starts 10 days before the latest stored booking date, one window of CP-7); the `card_fetch` line counts the requests (§8.7).
|
||||
**CP-4 Background fetch (D-165).** Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". The server fetches every `active` connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm (four slots spread over the day, away from the 04:00 pruning and the 06:00 recurring run), without the user's headers (not user present). A slot is skipped when the connection started 4 background fetches in the preceding 24 hours (`background_fetches`, the start times of the last four), when any fetch for it succeeded less than 2 hours before, or when one is running. The scheduler reads the clock as §9.5 does; after downtime it runs at most the slot that is due, never a backlog: at start-up the latest slot already passed runs once, subject to the 4-per-24-hours quota and the 2-hour rule (D-185). Every background start counts towards the quota, whatever its outcome (D-185). K0: a fetch without the user's headers was accepted for the SJ Prio card (HTTP 200); how many the bank accepts per 24 hours was not measured, so the 4-per-24-hours rule stands. A background fetch normally makes one bank request (its range starts 10 days before the latest stored booking date, one window of CP-7); the `card_fetch` line counts the requests (§8.7).
|
||||
|
||||
**CP-5 User-present fetch (D-165).** "Uppdatera" and pull to refresh on S-23 (and "Uppdatera" on W-21) call E-42, which fetches with the user present, so it does not count as background access. Enable Banking's PSU headers are `Psu-Ip-Address`, `Psu-User-Agent`, `Psu-Referer`, `Psu-Accept`, `Psu-Accept-Charset`, `Psu-Accept-Encoding`, `Psu-Accept-language` and `Psu-Geo-Location`, and a fetch sends "either all required PSU headers (in case of online data fetching) or none", the required set being the ASPSP's `required_psu_headers` in `GET /aspsps`. A user-present fetch therefore sends `Psu-Ip-Address` (the client IP of §8.6) and `Psu-User-Agent` always, plus every other header the ASPSP requires, each taken from the app's own request (`User-Agent`, `Referer`, `Accept`, `Accept-Charset`, `Accept-Encoding`, `Accept-Language`); `Psu-Geo-Location` is never sent (Kvitt has no location). K0: the SJ Prio ASPSP's `required_psu_headers` is exactly [`psu-ip-address`]; Enable Banking lists the names in lower case, so they are compared case-insensitively; a fetch with `Psu-Ip-Address` and `Psu-User-Agent` was accepted (HTTP 200) from the MacBook, and from the Pi it is checked by K1's owner steps. A background fetch sends none. E-42 contacts the bank at most once per connection per 60 seconds (§8.6) and waits up to 25 seconds for the fetch; if it takes longer it answers with `fetching` true and the fetch completes in the background. The app then pulls E-40. On the phone the regular sync triggers (AC-15.3) also pull E-40, so purchases fetched in the background reach the phone without a tap.
|
||||
**CP-5 User-present fetch (D-165).** "Uppdatera" and pull to refresh on S-23 (and "Uppdatera" on W-21) call E-42, which fetches with the user present, so it does not count as background access. Enable Banking's PSU headers are `Psu-Ip-Address`, `Psu-User-Agent`, `Psu-Referer`, `Psu-Accept`, `Psu-Accept-Charset`, `Psu-Accept-Encoding`, `Psu-Accept-language` and `Psu-Geo-Location`, and a fetch sends "either all required PSU headers (in case of online data fetching) or none", the required set being the ASPSP's `required_psu_headers` in `GET /aspsps`. A user-present fetch therefore sends `Psu-Ip-Address` (the client IP of §8.6, which the rate limiter puts in a request extension for this, D-188) and `Psu-User-Agent` always, plus every other header the ASPSP requires, each taken from the app's own request (`User-Agent`, `Referer`, `Accept`, `Accept-Charset`, `Accept-Encoding`, `Accept-Language`); `Psu-Geo-Location` is never sent (Kvitt has no location). Each header is sent only when the app's request carries a value for it (D-186). The ASPSP's details from `GET /aspsps` are cached for 24 hours; when `/aspsps` fails, only `Psu-Ip-Address` and `Psu-User-Agent` are sent (D-186). K0: the SJ Prio ASPSP's `required_psu_headers` is exactly [`psu-ip-address`]; Enable Banking lists the names in lower case, so they are compared case-insensitively; a fetch with `Psu-Ip-Address` and `Psu-User-Agent` was accepted (HTTP 200) from the MacBook, and from the Pi it is checked by K1's owner steps. A background fetch sends none. E-42 contacts the bank at most once per connection per 60 seconds (§8.6) and waits up to 25 seconds for the fetch; if it takes longer it answers with `fetching` true and the fetch completes in the background. The app then pulls E-40. On the phone the regular sync triggers (AC-15.3) also pull E-40, so purchases fetched in the background reach the phone without a tap.
|
||||
|
||||
**CP-6 Consent (D-166).** `access.valid_until` asks for now plus the smaller of 180 days and the ASPSP's `maximum_consent_validity` (given in seconds in `GET /aspsps`), less 5 minutes (K0: the SJ Prio value is 15552000 seconds, 180 days, so the request is now plus 180 days less 5 minutes; the session made on 2026-10-24 returned `valid_until` 2027-04-22); the stored `valid_until` is the one Enable Banking's session returns. The session serves every fetch, user-present and background, until then without a new BankID (owner's assumption, D-177; K0 did not repeat a fetch on a later day, K1's owner steps check it). From 14 days before it, S-23 and S-26 show the expiry notice with "Förnya med BankID"; once it has passed, or Enable Banking reports the session expired or revoked, the connection's status becomes `expired`: no fetch runs, the stored purchases stay and keep working (link, ignore), and "Förnya med BankID" renews (CP-2).
|
||||
|
||||
**CP-7 Incremental fetch and mapping (D-168, D-178, D-179).** Each fetch covers the range from `date_from` = the later of (today minus `history_days`) and (the latest stored booking date of the connection minus 10 days) up to today, so a purchase booked late and a pending purchase turning booked are seen again; the first fetch after an authorisation starts at today minus `history_days`. K0: the SJ Prio card answers every transactions request with at most 100 transactions and never a `continuation_key`, so a longer range loses rows without an error (90 days gave 100 rows covering only the newest 60 days; 180 days with `strategy=longest` gave 100 rows that were neither the newest nor a contiguous range). The range is therefore fetched in windows (D-178):
|
||||
**CP-7 Incremental fetch and mapping (D-168, D-178, D-179).** Each fetch covers the range from `date_from` = the later of (today − (`history_days` − 1), today being one of the `history_days` days, D-181, revising the earlier "today minus `history_days`", which made 181 days and 7 windows for 180) and (the latest stored booking date of the connection minus 10 days) up to today, so a purchase booked late and a pending purchase turning booked are seen again; the first fetch after an authorisation starts at today − (`history_days` − 1). K0: the SJ Prio card answers every transactions request with at most 100 transactions and never a `continuation_key`, so a longer range loses rows without an error (90 days gave 100 rows covering only the newest 60 days; 180 days with `strategy=longest` gave 100 rows that were neither the newest nor a contiguous range). The range is therefore fetched in windows (D-178):
|
||||
* Windows: consecutive, non-overlapping, at most 30 days each, newest first, each asked with `date_from` and `date_to` (both inclusive); the oldest window takes what is left and may be shorter. A first fetch of 180 days makes 6 requests; an incremental fetch (10 to about 20 days) makes 1. A `continuation_key`, if one is ever returned, is followed within its window.
|
||||
* Split: a window whose response holds 100 or more transactions (the cap, so the response may be cut) is split into two halves, the newer half taking the extra day of an odd length, and each half is asked again, newest first, down to windows of one day. A one-day window that still holds 100 is kept as returned and counts as truncated.
|
||||
* Split: a window whose response holds 100 or more transactions (the cap, so the response may be cut) is split into two halves, the newer half taking the extra day of an odd length, and each half is asked again, newest first, down to windows of one day; the rows of the cut response are discarded and only the halves' rows are kept (D-185). A one-day window that still holds 100 is kept as returned and counts as truncated.
|
||||
* `strategy` is never sent (Enable Banking's default strategy): `strategy=longest` neither lifts the cap nor returns the newest rows.
|
||||
* `WRONG_TRANSACTIONS_PERIOD` for a window (not seen in K0) means the bank has no older history: that window and every older one are skipped and the fetch succeeds with the newer windows.
|
||||
* `WRONG_TRANSACTIONS_PERIOD` for a window (not seen in K0) means the bank has no older history: that window and every older one are skipped and the fetch succeeds with the newer windows; the deletion of vanished pending purchases (CP-8) then covers only the windows that answered (D-185).
|
||||
* A window may return transactions whose booking date lies one or two days outside it (K0: the bank filters on another date), so every transaction is kept as returned and the transactions of all windows are de-duplicated by `provider_ref` (below) before CP-8; the first occurrence, from the newer window, wins.
|
||||
* Any other error ends the fetch with CP-14's error; the transactions of the windows already fetched are applied (CP-8).
|
||||
* A fetch is complete when every window of its range answered and none was truncated; only a complete fetch deletes vanished pending purchases (CP-8).
|
||||
@@ -970,7 +970,7 @@ Each transaction maps to a purchase:
|
||||
* `merchant`: the `remittance_information` lines joined with a space, else `creditor.name`, else "Kortköp" ("Card purchase"); whitespace collapsed, trimmed, at most 140 code points (truncated). K0: `remittance_information` holds exactly one line on every transaction and it is the merchant text; `creditor.name` is never present (D-179).
|
||||
* Nothing else of a transaction is stored, logged or sent to a client (D-179): in particular not `debtor.name` (K0: the card holder's name), `debtor_account` (K0: a masked card number, scheme `CPAN`), `creditor.postal_address` (K0: the merchant's country and town), `transaction_id` or `bank_transaction_code`.
|
||||
|
||||
**CP-8 Matching a fetch to stored purchases (D-168, D-177).** Within one connection: a transaction whose `provider_ref` is stored updates that purchase's status, dates, amount and merchant (link and ignore state kept). On the SJ Prio card a pending transaction carries an `entry_reference` (K0), and a pending transaction is assumed to keep it when it is booked (owner's assumption, D-177; K1's owner steps check it through `booked_by_ref` and `booked_by_match`, §8.7), so the booked transaction updates the pending purchase by its reference. The secondary rule, for a booked transaction with no stored `provider_ref` (a bank that gives the booked transaction a new reference, or a pending one stored under the fallback hash), takes over a stored pending purchase of the connection (its id, link and ignore state kept; its `provider_ref` replaced) when the amounts and directions are equal, the dates are at most 3 days apart and the CS-4 first tokens (§9.11) of the merchant texts are equal; among several, the oldest pending one. Otherwise it is a new purchase. After a complete fetch (CP-7), a stored pending purchase dated inside the fetched range that the bank no longer reports, neither linked nor ignored, is deleted (a tombstone with its content erased, D-167); a linked or ignored one stays as it is. Credit purchases (`credit`: refunds, deposits, the card bill's payment; K0: 2 of 100 transactions, both payments of the card bill, no refund) are stored but never "Att hantera": they show only under "Alla", marked "Insättning", with no actions, because §9.6 has no negative expense; the sheet says to change the original expense by hand.
|
||||
**CP-8 Matching a fetch to stored purchases (D-168, D-177).** Within one connection: a transaction whose `provider_ref` is stored updates that purchase's status, dates, amount and merchant (link and ignore state kept). On the SJ Prio card a pending transaction carries an `entry_reference` (K0), and a pending transaction is assumed to keep it when it is booked (owner's assumption, D-177; K1's owner steps check it through `booked_by_ref` and `booked_by_match`, §8.7), so the booked transaction updates the pending purchase by its reference. The secondary rule, for a booked transaction with no stored `provider_ref` (a bank that gives the booked transaction a new reference, or a pending one stored under the fallback hash), takes over a stored pending purchase of the connection (its id, link and ignore state kept; its `provider_ref` replaced) when the amounts and directions are equal, the dates are at most 3 days apart and the CS-4 first tokens (§9.11) of the merchant texts are equal (the server takes the first token with Rust's standard Unicode character classes and without NFC normalisation: it only compares two texts from the same bank, D-182); among several, the oldest pending one. Otherwise it is a new purchase. After a complete fetch (CP-7), a stored pending purchase dated inside the fetched range that the bank no longer reports, neither linked nor ignored, is deleted (a tombstone with its content erased, D-167); a linked or ignored one stays as it is. Credit purchases (`credit`: refunds, deposits, the card bill's payment; K0: 2 of 100 transactions, both payments of the card bill, no refund) are stored but never "Att hantera": they show only under "Alla", marked "Insättning", with no actions, because §9.6 has no negative expense; the sheet says to change the original expense by hand.
|
||||
|
||||
**CP-9 Turning a purchase into an expense (D-169).** "Skapa utgift" opens S-7 (W-5 on the web) as a new expense prefilled with: amount = `amount_ore`; description = the merchant text, trimmed to 100 code points (§9.6), case unchanged; date = `purchase_date`; payer = the card holder (the viewer); account = from S-27 the account it was opened from, otherwise the AC-8.6 default (the account last used on this phone, or browser, if still active, else the oldest active account); category = the §9.11 suggestion for that description and account (it is a new expense, CS-1; the user's own pick wins, CS-2); split mode `default`; notes empty. Saving creates an ordinary household expense (sync push, audited, the partner is notified as for any expense) and links the purchase to its id. A purchase links to at most one expense, and an expense is linked from at most one purchase of each user (`expense_already_linked`). "Ta bort koppling" (S-23 sheet, S-8) clears the link; the expense stays. When a linked expense is deleted, by either user, the server clears the link in the same transaction (§14.4.4 step 7), so the purchase is "Att hantera" again; the phone clears it locally at its own delete. On the phone, link, unlink and ignore are written to the local copy at once (offline too) and sent with E-44 after the household push of the next sync cycle, so the expense exists on the server first (§14.4.5, D-173); the web sends E-44 at once. The link is not audited and not pushed; editing the expense later leaves the link.
|
||||
|
||||
@@ -982,7 +982,7 @@ Each transaction maps to a purchase:
|
||||
|
||||
**CP-13 Server configuration (D-175).** Enable Banking's production control panel accepts only `https` redirect URLs (an `http://localhost` URL "uses unsupported scheme"); the application's registered redirect URL is `https://<KVITT_PUBLIC_HOST>/cards/callback`, the path of E-39, and no other is needed. The application id is not a secret but is kept out of the repository's text (it is the configuration's value, §10.4, and the key file's name in K0's git-ignored folder). Card features need an Enable Banking application: an application id and its private key file (§10.4). One application may serve both users (the owner links both cards to it in Enable Banking's control panel) or each user has their own. The owner's application exists (K0: restricted production, activated by linking the owner's card, its key file in K0's git-ignored folder until K1's owner steps copy it to the Pi). The partner's card is assumed to link to the owner's application (owner's assumption, D-177; K0 did not try it, K1's owner steps do); the fallback is the partner's own application, which `KVITT_EB_MEMBER_APP_ID` and its key file configure without code change. A user without a configured application sees the provider as not available (E-36 `available` false, AC-22.1); E-37 answers `cards_unavailable`. The owner accepts Enable Banking's Terms of Service before an application exists (the licence step of `CLAUDE.md`, PLAN-K0).
|
||||
|
||||
**CP-14 Errors (D-165).** A fetch that fails records `last_fetch_error` on the connection: `bank_unavailable` (Enable Banking or the bank did not answer, or answered 5xx), `rate_limited` (the bank refused for too many requests), `consent_expired` (CP-6) or `failed` (anything else); a later successful fetch clears it. S-23 shows "Kunde inte hämta köp. Senast uppdaterad <time>" while it is set, except for `consent_expired`, which shows the expired state.
|
||||
**CP-14 Errors (D-165).** A fetch that fails records `last_fetch_error` on the connection: `bank_unavailable` (Enable Banking or the bank did not answer, or answered 5xx), `rate_limited` (the bank refused for too many requests), `consent_expired` (CP-6) or `failed` (anything else); a later successful fetch clears it. Enable Banking's answers map as follows (D-183): `EXPIRED_SESSION`, `CLOSED_SESSION`, `REVOKED_SESSION` and `SESSION_DOES_NOT_EXIST` → `consent_expired`, and the connection's status becomes `expired` (CP-6); `ASPSP_RATE_LIMIT_EXCEEDED` or HTTP 429 → `rate_limited`; `ASPSP_ERROR`, `ASPSP_TIMEOUT`, HTTP 408, any 5xx or no answer → `bank_unavailable`; `WRONG_TRANSACTIONS_PERIOD` is no error but ends the walk back (CP-7); anything else → `failed`. S-23 shows "Kunde inte hämta köp. Senast uppdaterad <time>" while it is set, except for `consent_expired`, which shows the expired state.
|
||||
|
||||
---
|
||||
|
||||
@@ -1008,11 +1008,11 @@ The Kvitt Pi's compose stack never builds anything. It only pulls images and run
|
||||
|
||||
### 10.2 Compose stack
|
||||
|
||||
File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it with `.env` (from `infra/pi/.env.example`) and `secrets/` to `/home/jacobhagstedt/kvitt` (`~/kvitt`) on the Kvitt Pi, a self-contained compose project: every path in it is relative to that folder and nothing outside it is installed or changed (D-125, revises D-103; until then `install.sh` installed the stack to `/opt/kvitt`). The folder holds `compose.yaml`, `.env`, `secrets/` (`fcm-service-account.json`, `rclone.conf`) and the bind-mounted folders `data/` (SQLite, pictures, `data/apk/`), `backups/` and `import/`, which the owner creates before the first start (`mkdir -p data/apk backups import`), because a bind-mount source that Docker creates itself belongs to root and the containers cannot write to it. Services:
|
||||
File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it with `.env` (from `infra/pi/.env.example`) and `secrets/` to `/home/jacobhagstedt/kvitt` (`~/kvitt`) on the Kvitt Pi, a self-contained compose project: every path in it is relative to that folder and nothing outside it is installed or changed (D-125, revises D-103; until then `install.sh` installed the stack to `/opt/kvitt`). The folder holds `compose.yaml`, `.env`, `secrets/` (`fcm-service-account.json`, `rclone.conf`) and the bind-mounted folders `data/` (SQLite, pictures, `data/apk/`), `backups/` and `import/`, which the owner creates before the first start (`mkdir -p data/apk backups import`; and `mkdir -p secrets/cards && chmod 700 secrets/cards` since K1, D-188), because a bind-mount source that Docker creates itself belongs to root and the containers cannot write to it. Services:
|
||||
|
||||
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|
||||
|---|---|---|---|---|
|
||||
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`, command `serve` | `./data:/data`; `./secrets/fcm-service-account.json` at `/secrets/fcm-service-account.json`, read-only, `create_host_path: false`; `./secrets/cards` at `/secrets/cards` (the Enable Banking private keys, D-175), read-only, `create_host_path: false` (an empty folder disables card features); environment also `KVITT_EB_APP_ID=${KVITT_EB_APP_ID:-}` and `KVITT_EB_MEMBER_APP_ID=${KVITT_EB_MEMBER_APP_ID:-}`; `./import:/import:ro` (empty except during the one-off import, §10.8, D-025); publishes `8080:8080` on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment `TZ`, `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`; the other settings keep their §10.4 defaults | 160 MiB (budget: RSS under 100 MB in steady state) | `kvitt-server healthcheck`, the binary's own `GET http://localhost:8080/health` (no curl in the image); every 30 s, timeout 5 s, 3 retries, start period 20 s |
|
||||
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`, command `serve` | `./data:/data`; `./secrets/fcm-service-account.json` at `/secrets/fcm-service-account.json`, read-only, `create_host_path: false`; `./secrets/cards` at `/secrets/cards` (the Enable Banking private keys, D-175), read-only, `create_host_path: false` (an empty folder disables card features, but a missing one stops `docker compose up`, even with no card configured: the folder must exist from K1's image on, D-188); environment also `KVITT_EB_APP_ID=${KVITT_EB_APP_ID:-}` and `KVITT_EB_MEMBER_APP_ID=${KVITT_EB_MEMBER_APP_ID:-}`; `./import:/import:ro` (empty except during the one-off import, §10.8, D-025); publishes `8080:8080` on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment `TZ`, `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`; the other settings keep their §10.4 defaults | 160 MiB (budget: RSS under 100 MB in steady state) | `kvitt-server healthcheck`, the binary's own `GET http://localhost:8080/health` (no curl in the image); every 30 s, timeout 5 s, 3 retries, start period 20 s |
|
||||
| `backup` | `${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}` | `./data:/data`; `./backups:/backups`; `./secrets/rclone.conf` at `/config/rclone/rclone.conf`, read-only, `create_host_path: false`; environment `TZ`, `HOME=/tmp`, `RCLONE_REMOTE=gdrive:` (fixed in `compose.yaml`, the `[gdrive]` remote of `rclone.conf`), `RCLONE_FOLDER_ID=${RCLONE_FOLDER_ID}` | 96 MiB | `/healthcheck.sh`: last successful run marker newer than 26 hours, or no run yet since start; every 5 min, start period 30 s |
|
||||
|
||||
* No named volumes: `data` and `backups` are the bind mounts `./data` and `./backups` (D-125; were the named volumes `data` and `backups`; `certs` and `caddy_data` were removed by D-120).
|
||||
@@ -1425,7 +1425,7 @@ SPEC v0.28 ─┬─► W0 web foundation (web/**) ─────────
|
||||
└─► W7 server: /app/ serving, /analytics/* (D-135..D-138, v0.29) ─┬─► W4's W-11
|
||||
└─► W6 Android Insights
|
||||
|
||||
Card purchases (v0.35, D-158 to D-176; K0 done, v0.36; K1 to K3 reports recorded in v0.37):
|
||||
Card purchases (v0.35, D-158 to D-176; K0 done, v0.36; K1 landed `671c00d`, its report recorded in v0.38, D-181 to D-188; K2's report goes into v0.39, K3's into v0.40):
|
||||
SPEC v0.35 ─► owner steps (Enable Banking account, ToS, application, key, linked cards) ─► K0 local test app on the Mac
|
||||
─► K0 report ─► SPEC v0.36 (K0's answers, D-177 to D-180) ────────┬─► K1 server (schema 4, adapter, E-36..E-44, poller, NPM text) ──┐ lands first
|
||||
├─► K2 Android (:feature:cards, Room 2, Kortköp tab) ──────────┼─► lands after K1
|
||||
@@ -1476,7 +1476,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| W5 | Web: settings | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). W-13 to W-19 in `web/src/features/settings/` | Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no); done 2026-10-24 (D-211) | W0 |
|
||||
| W6 | Android: Insights | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no); done 2026-10-24 against `v1.0.3` (D-211) | W7 landed, its image deployed |
|
||||
| K0 | Card purchases spike: local test app (D-176) | The test app landed `2ce317f`: `docs/spikes/k0/app.py` and its `README.md`, one command (`python3 docs/spikes/k0/app.py`, the standard library only, the RS256 JWT signed with `openssl`), a page on `http://localhost:8765/` that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored `docs/spikes/k0/.local/`. Done 2026-10-24: run with the owner on his card; report `docs/spikes/K0-enable-banking.md` (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps | The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; `git status` shows nothing from `.local/`; no gate (not part of any gate) | Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in `.local/`, the owner's card linked with BankID, the test app run (PLAN-K0 §8) | SPEC v0.35 |
|
||||
| K1 | Card purchases: server (D-158 to D-175) | Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) |
|
||||
| K1 | Card purchases: server (D-158 to D-175) | Landed `671c00d` (2026-10-24; D-181 to D-188, SPEC v0.38). Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub. At landing: server 356 tests (34 in `tests/cards_api/`), contracts-check 157 (17 `match_card_purchase` cases), web 609 (`match_card_purchase` listed as not ported until K3), the nginx check and both compose configs ok; the Android gate red by design until K2 lands (`VectorFilesTest`, `ExampleRoundTripTest` pin the contract's vector and example sets), so the commit bypassed the pre-commit hook (contract-first landing) | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) |
|
||||
| K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` | Android gate | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8) | SPEC v0.36; may start then on the spec and fakes; lands after K1 |
|
||||
| K3 | Card purchases: web (D-174) | W-21 to W-23 in `web/src/features/cards/`, the sidebar entry, the S-7-style prefill in W-5 and the card line in W-6, the committed card artwork mapped (D-180), `matchCardPurchase` in TypeScript passing its vectors | Web gate | Connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) |
|
||||
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10); deployed 2026-10-24 as `v1.0.3` (D-211) | SPEC v0.28 |
|
||||
@@ -1511,7 +1511,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
|
||||
* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups". W0b landed `5305f37` (v0.34). All of W0 to W7 are landed and verified: `v1.0.3` (built on the Mac from `8fa9198`, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4).
|
||||
|
||||
* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records in SPEC v0.37. Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
|
||||
* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed `671c00d`, D-181 to D-188), K2's in v0.39, K3's in v0.40 (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
|
||||
|
||||
### 11.4 Validation reports
|
||||
|
||||
@@ -2095,7 +2095,7 @@ UPDATE meta SET value = '3' WHERE key = 'schema_version';
|
||||
|
||||
#### 14.1.3 Migration 0004: schema version 4 (D-167)
|
||||
|
||||
Migration 0004 adds the card-purchase tables of §9.12. The SQL below is the intended content of the contract file `contracts/schema/migrations/0004_card_purchases.sql`, which K1 commits (the file wins over this copy once committed; K1 may only make changes its report justifies, recorded by a docs agent), and of the server migration `server/crates/kvitt-server/migrations/0004_card_purchases.sql`, byte for byte (a test asserts equality, as for 0001 to 0003).
|
||||
Migration 0004 adds the card-purchase tables of §9.12. The SQL below is the intended content of the contract file `contracts/schema/migrations/0004_card_purchases.sql`, which K1 commits (the file wins over this copy once committed; K1 may only make changes its report justifies, recorded by a docs agent), and of the server migration `server/crates/kvitt-server/migrations/0004_card_purchases.sql`, byte for byte (a test asserts equality, as for 0001 to 0003). K1 committed both files byte-identical to the copy below (`671c00d`, v0.38); server schema 4 is in code.
|
||||
|
||||
* No existing table changes: the household tables, `server_version` and `GET /sync` are untouched (card rows are not household rows, D-161). `card_version` comes from the new one-row `card_sequence`, taken like `server_version` (`UPDATE card_sequence SET value = value + 1 WHERE id = 1 RETURNING value`) inside the write's `BEGIN IMMEDIATE` transaction, so a card cursor never sees a gap that later fills (§6.4).
|
||||
* Tombstones (D-167): a card purchase is never hard-deleted while the database lives; a deleted one keeps `id`, `user_id`, `connection_id` and its new `card_version`, gets `deleted_at`, and has its content erased (`provider_ref` `''`, `merchant` `''`, `amount_ore`, `original_amount`, `original_currency`, `booking_date` and `expense_id` null, `ignored` 0, `purchase_date` kept as `1970-01-01`), so a phone learns of the deletion and nothing of the purchase is left. Connections are never deleted; a disconnected one keeps only its id, user, provider, history and timestamps.
|
||||
@@ -2732,6 +2732,7 @@ All server-produced objects. `?` marks nullable (always present). Types: `string
|
||||
"card_version": int }
|
||||
|
||||
// CardAttempt (E-37, E-38; D-162). auth_url only in the E-37 response (null in E-38); connection once connected.
|
||||
// failure stays a plain nullable string in the OpenAPI document, not an enum (D-187); its values are E-38's.
|
||||
{ "id": uuid, "provider_id": string, "status": "pending" | "connected" | "failed" | "expired", "failure": string?,
|
||||
"auth_url": string?, "expires_at": ts, "connection": CardConnection? }
|
||||
```
|
||||
@@ -2742,7 +2743,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
|
||||
|
||||
**E-1 `GET /health`** (none). 200:
|
||||
```json
|
||||
{ "status": "ok", "server_version": "0.1.0", "schema_version": 3, "db_ok": true }
|
||||
{ "status": "ok", "server_version": "0.1.0", "schema_version": 4, "db_ok": true }
|
||||
```
|
||||
`schema_version` comes from a constant in the server code (the version its embedded migrations produce), not from the database, so `/health` answers with the database down. When the database check (`SELECT 1`) fails: 503 with the same shape, `status` `"degraded"`, `db_ok` false. The OpenAPI document declares the 503 body as `oneOf` the health object or Error (an `unavailable` error from a shutting-down server is also possible).
|
||||
|
||||
@@ -2849,11 +2850,11 @@ One entry per non-deleted account (archived included), ordered by `created_at`,
|
||||
|
||||
**E-36 `GET /cards/providers`** (device). 200 `{ "providers": [CardProvider, ...] }` in registry order (CP-1). Errors: none beyond the common ones.
|
||||
|
||||
**E-37 `POST /cards/auth`** (device). Request `{ "provider_id": string, "history_days": int, "client": "android" | "web" }`. Creates an attempt (CP-2) for a new connection or a renewal of the caller's existing one for that provider, calls Enable Banking `POST /auth` and answers 201 `CardAttempt` with `auth_url` and `status` `pending`. Errors: `invalid_request`, `validation_failed` (`history_days`, §9.6), `not_found` (unknown provider), `cards_unavailable` (no Enable Banking application for this user, CP-13), `card_bank_unavailable` (Enable Banking did not answer or refused, 502).
|
||||
**E-37 `POST /cards/auth`** (device). Request `{ "provider_id": string, "history_days": int, "client": "android" | "web" }`. Creates an attempt (CP-2) for a new connection or a renewal of the caller's existing one for that provider, calls Enable Banking `POST /auth` and answers 201 `CardAttempt` with `auth_url` and `status` `pending`. Errors: `invalid_request`, `validation_failed` (`history_days`, §9.6), `not_found` (unknown provider), `cards_unavailable` (no Enable Banking application for this user, CP-13), `card_bank_unavailable` (Enable Banking did not answer or refused, 502: when `GET /aspsps` or `POST /auth` fails, D-183; the OpenAPI document's `BadGateway` response).
|
||||
|
||||
**E-38 `GET /cards/auth/{attempt_id}`** (device). 200 `CardAttempt` (`auth_url` null). A `pending` attempt past `expires_at` is answered and stored as `expired`. `failure` is one of `cancelled` (the bank redirected with an error, the user cancelled), `no_account`, `bank_error` (the session could not be created), `state_invalid` (never sent to a client: an unknown state names no attempt). Errors: `not_found`.
|
||||
|
||||
**E-39 `GET /cards/callback?code=<code>&state=<state>`** (none; `?error=<error>&state=<state>` when the user cancels). 200 `text/html; charset=utf-8`, the page of §13.3 "Card callback page", for success and failure alike (the status code does not tell an attacker whether a `state` existed); `Cache-Control: no-store`, `Referrer-Policy: no-referrer`. Never logged with its query (§8.7, §10.5.2). Not in `contracts/openapi.yaml`'s JSON error shape: a malformed query also answers the failure page. Rate limit §8.6.
|
||||
**E-39 `GET /cards/callback?code=<code>&state=<state>`** (none; `?error=<error>&state=<state>` when the user cancels). 200 `text/html; charset=utf-8`, the page of §13.3 "Card callback page", for success and failure alike (the status code does not tell an attacker whether a `state` existed); `Cache-Control: no-store`, `Referrer-Policy: no-referrer`. Never logged with its query (§8.7, §10.5.2). Not in `contracts/openapi.yaml`'s JSON error shape: a malformed query also answers the failure page. Rate limit §8.6. The attempt rules (expired, used, no `code` and no `error`, the atomic claim, a session without accounts) are CP-2's (D-184).
|
||||
|
||||
**E-40 `GET /cards/sync?since=<int>&limit=<int>`** (device). The caller's card rows with `card_version > since`, in one read transaction, like §14.4.2: 200
|
||||
```json
|
||||
@@ -2862,17 +2863,17 @@ One entry per non-deleted account (archived included), ordered by `created_at`,
|
||||
```
|
||||
`next_since` is the highest `card_version` in the page, or `since` when empty; rows are counted together across both arrays, ordered by `card_version`, up to `limit` (default 500, max 1000); tombstones included. Errors: `invalid_request`, `cursor_ahead` (`since` greater than the current `card_sequence` value).
|
||||
|
||||
**E-41 `PATCH /cards/connections/{connection_id}`** (device). Request `{ "history_days": int }`, only a value ≤ the current one (CP-3; a longer one is a renewal through E-37). 200 `CardConnection`. Errors: `invalid_request`, `validation_failed`, `not_found`.
|
||||
**E-41 `PATCH /cards/connections/{connection_id}`** (device). Request `{ "history_days": int }`, only a value ≤ the current one (CP-3; a longer one is a renewal through E-37). 200 `CardConnection`; the current value answers the unchanged connection with its existing `card_version`, no new version (D-187). Errors: `invalid_request`, `validation_failed`, `not_found`.
|
||||
|
||||
**E-42 `POST /cards/connections/{connection_id}/refresh`** (device). No body. Fetches with the user present (CP-5), at most once per 60 seconds per connection; waits up to 25 seconds. 200 `{ "connection": CardConnection, "fetched": bool }` (`fetched` false when the 60-second rule answered without contacting the bank, or the fetch is still running, then `connection.fetching` is true). A fetch that fails is recorded in `last_fetch_error` and still answers 200. Errors: `not_found`, `card_consent_expired` (status `expired` or `disconnected`, 409).
|
||||
|
||||
**E-43 `DELETE /cards/connections/{connection_id}?erase_handled=<bool>`** (device). Disconnects (CP-12); `erase_handled` defaults to false. 200 `CardConnection` (status `disconnected`). Idempotent on a disconnected connection. Errors: `invalid_request`, `not_found`.
|
||||
**E-43 `DELETE /cards/connections/{connection_id}?erase_handled=<bool>`** (device). Disconnects (CP-12); `erase_handled` defaults to false. 200 `CardConnection` (status `disconnected`). Idempotent on a disconnected connection: no new version of it, but `erase_handled=true` still erases its linked and ignored purchases (D-187). Errors: `invalid_request`, `not_found`.
|
||||
|
||||
**E-44 `PUT /cards/purchases/{purchase_id}`** (device). Request `{ "expense_id": uuid?, "ignored": bool }` (both keys required). Sets the link and the ignore flag (CP-9); the rules of §9.6. 200 `CardPurchase` with a new `card_version`, or the unchanged row with its existing `card_version` when nothing changes (idempotent). Errors: `invalid_request`, `validation_failed`, `not_found` (the purchase, or with `field` `expense_id` an unknown or deleted expense), `expense_already_linked` (409, another purchase of the caller links that expense).
|
||||
**E-44 `PUT /cards/purchases/{purchase_id}`** (device). Request `{ "expense_id": uuid?, "ignored": bool }` (both keys required). Sets the link and the ignore flag (CP-9); the rules of §9.6. A credit purchase can be neither linked nor ignored: `validation_failed` with `field` the key wrongly set (`expense_id` when it is not null, else `ignored`, D-187). 200 `CardPurchase` with a new `card_version`, or the unchanged row with its existing `card_version` when nothing changes (idempotent). Errors: `invalid_request`, `validation_failed`, `not_found` (the purchase, or with `field` `expense_id` an unknown or deleted expense), `expense_already_linked` (409, another purchase of the caller links that expense).
|
||||
|
||||
#### 14.3.5 Examples as contract
|
||||
|
||||
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have `server_version` 15 and 16; `E-7-me/response.json` shows those values and its test compares them (D-058). E-34 and E-35 have response examples only (`E-34-analytics-trends/response.json`, `E-35-analytics-forecast/response.json`, v0.29, D-138); `tools/contracts-check.py` checks that the operations are exactly E-1 to E-35 (136 checks at `50a0d67`); since v0.35 E-1 to E-44 (K1), with request and response examples for E-36 to E-38 and E-40 to E-44 (`E-36-card-providers`, `E-37-card-auth`, `E-38-card-attempt`, `E-40-card-sync`, `E-41-card-connection-update`, `E-42-card-refresh`, `E-43-card-disconnect`, `E-44-card-purchase-update`) and one per new error code. E-39 returns HTML like E-4: it is in `contracts/openapi.yaml` with a `text/html` response and has no JSON example.
|
||||
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have `server_version` 15 and 16; `E-7-me/response.json` shows those values and its test compares them (D-058). E-34 and E-35 have response examples only (`E-34-analytics-trends/response.json`, `E-35-analytics-forecast/response.json`, v0.29, D-138); `tools/contracts-check.py` checks that the operations are exactly E-1 to E-35 (136 checks at `50a0d67`); since v0.35 E-1 to E-44 (K1; 157 checks at `671c00d`, 17 of them `match_card_purchase` cases), with request and response examples for E-36 to E-38 and E-40 to E-44 (`E-36-card-providers`, `E-37-card-auth`, `E-38-card-attempt`, `E-40-card-sync`, `E-41-card-connection-update`, `E-42-card-refresh`, `E-43-card-disconnect`, `E-44-card-purchase-update`) and one per new error code. E-39 returns HTML like E-4: it is in `contracts/openapi.yaml` with a `text/html` response and has no JSON example.
|
||||
|
||||
### 14.4 Sync protocol v1
|
||||
|
||||
@@ -3342,12 +3343,12 @@ server/
|
||||
crates/kvitt-server/ migrations/{0001_init.sql, 0002_categories_v2.sql, 0003_device_version_name.sql, 0004_card_purchases.sql}
|
||||
src/{main.rs, lib.rs, config.rs, error.rs, router.rs, openapi.rs, auth.rs, time.rs}
|
||||
src/db/{mod.rs, pool.rs, sequence.rs}
|
||||
src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs}
|
||||
src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs, analytics.rs, card.rs}
|
||||
src/api/{mod.rs, health.rs, setup.rs, invites.rs, landing.rs, me.rs, pictures.rs, devices.rs, accounts.rs, categories.rs, recurring.rs, proposal.rs, apk.rs, sync.rs, rate_limit.rs, rows.rs, validate.rs}
|
||||
src/tasks/ src/sync/ src/audit/ src/push/
|
||||
src/cards/{mod.rs, providers.rs, api.rs, fetch.rs, poller.rs, callback.rs} src/cards/enable_banking/ (K1, D-160)
|
||||
src/cards/{mod.rs, providers.rs, api.rs, fetch.rs, poller.rs, callback.rs} src/cards/enable_banking/{mod.rs, mapping.rs} (K1, D-160; the card wire types in src/wire/card.rs, the row helpers in src/cards/mod.rs, D-188)
|
||||
src/cli/{mod.rs, bootstrap.rs, pair_device.rs, rotate_epoch.rs, healthcheck.rs, invite_partner.rs}
|
||||
tests/ tests/common/{mod.rs, sync_fixtures.rs} (shared harness, included through #[path] by every test binary)
|
||||
tests/ tests/common/{mod.rs, sync_fixtures.rs} tests/cards_api/ (K1: against a local Enable Banking stub) (shared harness, included through #[path] by every test binary)
|
||||
crates/kvitt-import/ src/ data/splitwise_categories.csv tests/fixtures/
|
||||
android/
|
||||
settings.gradle.kts build.gradle.kts gradle.properties gradle/{libs.versions.toml, gradle-daemon-jvm.properties, wrapper/}
|
||||
@@ -3598,3 +3599,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
|
||||
| 0.35 | 2026-10-24 | Card purchases through Enable Banking (owner's request and answers of 2026-10-24, D-158 to D-176) and plans K0 to K3. D-158: card purchases in scope, §2's card and bank non-goal struck through with a pointer, new US-18, US-19, FR-22 (AC-22.1 to AC-22.15), §9.12 (CP-1 to CP-14). D-159: Enable Banking in restricted production for the SJ Prio Mastercard (SEB Kort), G-7 allows it, PSD2 limits stated as facts, ToS as the owner's licence step. D-160: server-only credentials, one adapter `src/cards/enable_banking/`, provider registry, card catalogue E-36. D-161: only the card holder sees their purchases (owner's answer): own endpoints and cursor, not in `GET /sync`, audit, push or analytics, `not_found` for the other user, no card content in logs, session id protected by separation from the private key (§8.1, §8.4, §8.7, §10.9). D-162: connect flow with single-use attempts (E-37, E-38), the callback E-39 and its bilingual page, `kvitt://cards/connected`, polling. D-163: `/cards/callback` through the app gate and never logged (§10.5.2 block). D-164: history 7/30/90/180 days, default 30. D-165: background fetches at 06:45, 11:45, 16:45, 21:45, at most 4 per 24 hours; user-present "Uppdatera" with Enable Banking's PSU headers, once per 60 s; incremental fetch with a 10-day overlap; `card_bank_unavailable`. D-166: consent up to 180 days, notice 14 days before, renewal with BankID; `card_consent_expired`. D-167: migration 0004, server schema 4 (`card_sequence`, `card_connections`, `card_auth_attempts`, `card_purchases`, §14.1.3); disconnect rules (E-43). D-168: transaction mapping, pending→booked de-duplication, foreign currency, credits listed only under "Alla". D-169: "Skapa utgift" prefill, links (E-44), offline-capable link changes, unlink on expense delete (§14.4.4), `expense_already_linked`. D-170: suggested matches (same amount, ±3 days), `matchCardPurchase` with vectors `match_card_purchase.json` (§14.5.8). D-171: no bulk add, no card push (OQ-7, OQ-8). D-172: four bottom destinations, Översikt, Kortköp, Statistik, Inställningar (revises D-048); "Anslut kort" in Settings. D-173: `:feature:cards`, S-23 to S-27, Room version 2, `CardRepository` (§14.2.4), the owner's SJ Prio artwork with a drawn fallback. D-174: web W-21 to W-23 (K3). D-175: `KVITT_EB_*` configuration, `secrets/cards/`, two optional `.env` keys; `cards_unavailable`. D-176: plans K0 (the local test app of `2ce317f`, owner's refinement) to K3; K0's `TODO(spec)` answers go into v0.36 before K1. Endpoints E-36 to E-44; strings for the card screens and the callback page in §13.3; ownership rows K0 to K3 (§14.7); contract files 0004 and the new vector file (§14.8). |
|
||||
| 0.36 | 2026-10-24 | K0's results (the spike run on the owner's SJ Prio Mastercard on 2026-10-24 with `docs/spikes/k0/app.py`, report `docs/spikes/K0-enable-banking.md`), D-177 to D-180; no contract file changes, server schema 4 and Room 2 as specified in v0.35. D-177: K0's outcome; every K0 `TODO(spec)` of §9.12 answered: ASPSP `SJ Prio Mastercard`, `SE` (CP-1); one `CARD` account per session (CP-2); at least 150 days of history right after BankID, so 7/30/90/180 stay (CP-3, §9.6); a background fetch without PSU headers accepted (CP-4); `required_psu_headers` [`psu-ip-address`], compared case-insensitively (CP-5); `maximum_consent_validity` 180 days, `valid_until` 2027-04-22 (CP-6); foreign purchases arrive in SEK (CP-7); credits were card-bill payments (CP-8); and the owner's assumptions of 2026-10-24 for what was not measured: the partner's card links to the owner's application (CP-13, the D-175 fallback stays), a pending transaction keeps its `entry_reference` when booked (CP-8, the amount-and-date rule becomes the secondary rule), the session is reused on later days without BankID (CP-6), all three verified by K1's owner steps. D-178: 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, newest first, a window at 100 rows split in half down to one day, de-duplicated across windows by `provider_ref`; `strategy=longest` dropped; `WRONG_TRANSACTIONS_PERIOD` ends the walk back; only a complete fetch deletes vanished pending purchases (CP-7, CP-8); `card_fetch` counts requests, splits, truncated windows, `booked_by_ref` and `booked_by_match` (§8.7). D-179: transaction mapping from K0's field survey: merchant = `remittance_information`, else `creditor.name`, else "Kortköp"; `purchase_date` = `booking_date`, else `value_date`, else `transaction_date`; `bank_transaction_code` unused, so a debit `FEE` is a purchase to handle; the holder's name, the masked card number (`CPAN`) and the merchant's address are never stored, logged or shown (CP-7). D-180: the owner's SJ Prio Mastercard artwork cropped to the card (1313 × 829) and committed for Android and the web, mapped from the first commit of K2 and K3, the goldens use it, the owner's artwork steps removed (revises D-173, D-174). The K plans' proposed spec text goes into v0.37 (§11.1, §11.3). |
|
||||
| 0.37 | 2026-10-24 | D-211: image builds on the Mac with Podman (`infra/build-images.sh v1.0.N`) are the normal path, the Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag rule (the next `v1.0.N` after the highest tag on `origin`, pushed as a git tag too, `v1.0.3` = `8fa9198`); the registry from the Mac is `gitea.lan` on port 80, the Pi's `.env` keeps `gitea.lan:3001`; Podman's insecure-registry files on the Mac and in the Podman machine; the script fixes `f15fa23` and `8fa9198` (§10.3, §10.10). `v1.0.3` deployed on the Kvitt Pi and the web client and S-11's Insights verified by the owner (§11.2 W1 to W7, §11.3, §11.4, §15). No contract file changes. |
|
||||
| 0.38 | 2026-10-24 | K1's report (card purchases on the server, landed `671c00d`: migration 0004 byte-identical to §14.1.3, server schema 4 in code, E-36 to E-44 with four new error codes and a 502 `BadGateway` response in `contracts/openapi.yaml`, examples, `match_card_purchase.json` with its Python reference; gates at landing: server 356 tests, contracts-check 157, web 609, nginx check and compose configs ok, the Android gate red until K2 lands), D-181 to D-188 (D-189 and D-190 unused). D-181: the history counts today, `date_from` = today − (`history_days` − 1), 6 windows for 180 days (revises CP-3 and CP-7). D-182: the server's CS-4 first token for CP-8 uses Rust's standard Unicode classes without NFC. D-183: Enable Banking's errors mapped to CP-14 (`EXPIRED_SESSION`, `CLOSED_SESSION`, `REVOKED_SESSION`, `SESSION_DOES_NOT_EXIST` → `consent_expired` and `expired`; `ASPSP_RATE_LIMIT_EXCEEDED` or 429 → `rate_limited`; `ASPSP_ERROR`, `ASPSP_TIMEOUT`, 408, 5xx or no answer → `bank_unavailable`; anything else `failed`); E-37's `card_bank_unavailable` when `/aspsps` or `/auth` fails. D-184: the callback (an expired `state` stores `expired`, revising CP-2's `failed`; a used one changes nothing; neither `code` nor `error` → `failed`/`bank_error`; a session without accounts is revoked; the atomic claim). D-185: the poller's start-up slot, background starts counted whatever their outcome, deletion after `WRONG_TRANSACTIONS_PERIOD` only over the windows that answered, a split window's cut rows discarded. D-186: PSU headers only with a value, `/aspsps` cached 24 hours, the two fixed headers when it fails. D-187: E-41 with the current value and E-43 on a disconnected connection make no new version (`erase_handled=true` still erases), E-44's `field` for a credit purchase, `CardAttempt.failure` a plain nullable string. D-188: `src/wire/card.rs`, row helpers in `src/cards/mod.rs`, the client IP in a request extension, and the Pi's `compose.yaml` refusing to start until `secrets/cards/` exists. Changed: §9.12 CP-2, CP-3, CP-4, CP-5, CP-7, CP-8, CP-14; §10.2; §11.1, §11.2 K1 row, §11.3 (K2's text goes into v0.39, K3's into v0.40); §14.1.3; §14.3.3; §14.3.4 E-1 (`schema_version` 4), E-37, E-39, E-41, E-43, E-44; §14.3.5; §14.7. |
|
||||
|
||||
Reference in New Issue
Block a user