From 07df30d04dbb12a98b4e5daf10a31fd84807aea4 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 24 Oct 2026 16:50:04 +0200 Subject: [PATCH] SPEC v0.38: K1's report recorded (D-181 to D-188), K2 v0.39 and K3 v0.40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K1 landed 671c00d (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 for 671c00d). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- PLAN-K2.md | 2 +- PLAN-K3.md | 2 +- SPEC.md | 54 ++++++++++++++++++++++++----------------------- docs/HANDOFF.md | 17 ++++++++------- docs/decisions.md | 14 ++++++++++-- prompts/README.md | 4 ++-- 6 files changed, 53 insertions(+), 40 deletions(-) diff --git a/PLAN-K2.md b/PLAN-K2.md index 3576274..5841e05 100644 --- a/PLAN-K2.md +++ b/PLAN-K2.md @@ -65,4 +65,4 @@ The Android gate, from `android/`: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH - Dependency-report lines; HUMAN steps outstanding worded for relay; open questions with SPEC section numbers. ## 11. Numbering -Room version 2; decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.37** (v0.36 recorded K0's results; K1, K2 and K3 share v0.37). +Room version 2; decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.39** (v0.36 recorded K0's results, v0.38 K1's report; K3's goes into v0.40). diff --git a/PLAN-K3.md b/PLAN-K3.md index 6e06908..bfd2fcc 100644 --- a/PLAN-K3.md +++ b/PLAN-K3.md @@ -47,4 +47,4 @@ The web gate, from `web/`: `npm run check`. Report the real result lines (test f - `web_cards_` strings added; proposed decision entries and spec text for anything beyond §15.6 and §9.12 (numbered from the brief); HUMAN steps outstanding worded for relay; open questions with SPEC section numbers. ## 11. Numbering -Decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.37** (v0.36 recorded K0's results; K1, K2 and K3 share v0.37). +Decisions from the start prompt (`TODO(spec)` if none was given). Proposed spec text goes into SPEC **v0.40** (v0.36 recorded K0's results, v0.38 K1's report, v0.39 K2's). diff --git a/SPEC.md b/SPEC.md index 3589e8b..52d4d00 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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` = `/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=` for `android`, `/app/cards?attempt=` 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` = `/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=` for `android`, `/app/cards?attempt=` 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:///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