K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Build Kvitt images / build (push) Failing after 8m17s

Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical
contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error
codes and a 502 response, examples for every card body and error code, the
match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py
(157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists
match_card_purchase as not ported until K3 ports it (orchestrator's allowance).

Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its
Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions,
transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping),
the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day,
de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk),
CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45,
4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38
and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch
log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub
(tests/cards_api/, 34 tests); the real API is never called.

Infra: Pi compose mounts secrets/cards read-only and passes the two application ids;
.env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback
gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases".

Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail
in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin
the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design
(contract-first landing; K2 lands next). Server, contracts-check and web gates are green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 16:43:26 +02:00
parent ae73f58079
commit 671c00d0d8
67 changed files with 9515 additions and 47 deletions
+655
View File
@@ -1464,6 +1464,414 @@ paths:
'503':
$ref: '#/components/responses/Unavailable'
/cards/providers:
get:
operationId: listCardProviders
summary: E-36 Card catalogue
description: The supported cards in registry order (SPEC §9.12 CP-1). `available` is false when the server has no card application for the caller (CP-13).
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The catalogue.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardProviderList'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/auth:
post:
operationId: createCardAuth
summary: E-37 Start connecting or renewing a card
description: |
Creates an authorisation attempt (SPEC §9.12 CP-2) and returns the bank URL the client opens. A pending older
attempt of the caller for the same provider expires.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CreateCardAuthRequest'
responses:
'201':
description: The pending attempt with `auth_url`.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardAttempt'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (unknown provider).'
'409':
$ref: '#/components/responses/Conflict'
description: '`cards_unavailable`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed` (`history_days`).'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'502':
$ref: '#/components/responses/BadGateway'
'503':
$ref: '#/components/responses/Unavailable'
/cards/auth/{attempt_id}:
get:
operationId: getCardAuth
summary: E-38 Poll an authorisation attempt
description: A pending attempt past `expires_at` is answered and stored as `expired` (SPEC §9.12 CP-2). `auth_url` is null.
parameters:
- $ref: '#/components/parameters/AttemptId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The attempt.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardAttempt'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/callback:
get:
operationId: getCardCallback
summary: E-39 Card connection callback page (HTML)
description: |
The bank's redirect after BankID (SPEC §9.12 CP-2). Always 200 HTML, success and failure alike;
`Cache-Control: no-store`, `Referrer-Policy: no-referrer`. Swedish when `Accept-Language` prefers `sv`, English
otherwise. A malformed query answers the failure page.
security: []
parameters:
- name: code
in: query
required: false
description: The one-time authorisation code.
schema:
type: string
- name: state
in: query
required: false
description: The attempt's state.
schema:
type: string
- name: error
in: query
required: false
description: Set by the bank when the user cancelled.
schema:
type: string
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The callback page.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
text/html; charset=utf-8:
schema:
type: string
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/sync:
get:
operationId: cardSync
summary: E-40 Pull the caller's card rows since a cursor
description: |
The caller's connections and purchases with `card_version > since`, tombstones included, counted together and
ordered by `card_version` (SPEC §14.3.4, like §14.4.2).
parameters:
- name: since
in: query
required: true
description: The highest `card_version` the client has applied (0 initially).
schema:
type: integer
format: int64
- name: limit
in: query
required: false
description: Rows per page, 1 to 1000 (`invalid_request` otherwise).
schema:
type: integer
default: 500
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: One page.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardSyncResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`cursor_ahead`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/connections/{connection_id}:
patch:
operationId: patchCardConnection
summary: E-41 Shorten the history setting
description: |
Only a value of 7, 30, 90 or 180 at most the current one (SPEC §9.12 CP-3); a longer one is a renewal through
E-37. Stored purchases stay.
parameters:
- $ref: '#/components/parameters/ConnectionId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PatchCardConnectionRequest'
responses:
'200':
description: The connection.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardConnection'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
delete:
operationId: deleteCardConnection
summary: E-43 Disconnect a card
description: |
Revokes the session at the bank (best effort), sets `disconnected`, deletes the purchases neither linked nor
ignored and, with `erase_handled=true`, the linked and ignored ones too (SPEC §9.12 CP-12). Expenses stay.
Idempotent on a disconnected connection.
parameters:
- $ref: '#/components/parameters/ConnectionId'
- name: erase_handled
in: query
required: false
description: Also delete linked and ignored purchases. Default false.
schema:
type: boolean
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The disconnected connection.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardConnection'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/connections/{connection_id}/refresh:
post:
operationId: refreshCardConnection
summary: E-42 Fetch purchases with the user present
description: |
Contacts the bank at most once per connection per 60 seconds and waits up to 25 seconds (SPEC §9.12 CP-5).
`fetched` is false when the 60-second rule answered without contacting the bank or the fetch is still running
(then `connection.fetching` is true). A failed fetch is recorded in `last_fetch_error` and still answers 200.
parameters:
- $ref: '#/components/parameters/ConnectionId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The connection after the fetch.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardRefreshResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`card_consent_expired`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/cards/purchases/{purchase_id}:
put:
operationId: putCardPurchase
summary: E-44 Link, unlink or ignore a card purchase
description: |
Sets the link and the ignore flag (SPEC §9.12 CP-9, §9.6). The unchanged row with its existing `card_version`
when nothing changes.
parameters:
- $ref: '#/components/parameters/PurchaseId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PutCardPurchaseRequest'
responses:
'200':
description: The purchase.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CardPurchase'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (the purchase, or with `field` `expense_id` an unknown or deleted expense).'
'409':
$ref: '#/components/responses/Conflict'
description: '`expense_already_linked`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
components:
securitySchemes:
device:
@@ -1540,6 +1948,27 @@ components:
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
AttemptId:
name: attempt_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
ConnectionId:
name: connection_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
PurchaseId:
name: purchase_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
CategoryId:
name: category_id
in: path
@@ -1678,6 +2107,15 @@ components:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
BadGateway:
description: '`card_bank_unavailable` (the card service or the bank did not answer or refused).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Unavailable:
description: '`unavailable` (database busy beyond `busy_timeout`, or server shutting down).'
headers:
@@ -1762,6 +2200,9 @@ components:
- cannot_revoke_current_device
- no_accounts
- cursor_ahead
- cards_unavailable
- card_consent_expired
- expense_already_linked
- payload_too_large
- unsupported_media_type
- validation_failed
@@ -1770,6 +2211,7 @@ components:
- protocol_unsupported
- rate_limited
- internal
- card_bank_unavailable
- not_implemented
- unavailable
RejectionReason:
@@ -2750,3 +3192,216 @@ components:
description: One result per mutation, in request order.
items:
$ref: '#/components/schemas/MutationResult'
# ---- card purchases (SPEC §9.12, §14.3.3, D-158 to D-180) ----
CardConnectionStatus:
type: string
enum: [active, expired, disconnected]
CardFetchError:
type: string
description: CP-14.
enum: [bank_unavailable, rate_limited, consent_expired, failed]
CardPurchaseStatus:
type: string
enum: [pending, booked]
CardDirection:
type: string
enum: [debit, credit]
CardAttemptStatus:
type: string
enum: [pending, connected, failed, expired]
CardClient:
type: string
enum: [android, web]
CardProvider:
type: object
description: An entry of the card catalogue (CP-1). `available`, the server can connect it for the caller (CP-13).
required: [id, name, issuer, image_key, available]
properties:
id:
type: string
description: Open string, never an enum.
name:
type: string
issuer:
type: string
image_key:
type: string
available:
type: boolean
CardProviderList:
type: object
required: [providers]
properties:
providers:
type: array
items:
$ref: '#/components/schemas/CardProvider'
CardConnection:
type: object
description: Only ever sent to its own user's devices. No session id, account uid or provider reference.
required: [id, provider_id, status, history_days, valid_until, last_fetch_at, last_fetch_error, fetching, created_at,
updated_at, card_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
provider_id:
type: string
status:
$ref: '#/components/schemas/CardConnectionStatus'
history_days:
type: integer
valid_until:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
last_fetch_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
last_fetch_error:
oneOf:
- $ref: '#/components/schemas/CardFetchError'
- type: 'null'
fetching:
type: boolean
description: A fetch for it is running at the time of the response.
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
card_version:
type: integer
format: int64
description: From `card_sequence` (SPEC §14.1.3), not the household's `server_version`.
CardPurchase:
type: object
description: A tombstone has `deleted_at` set and its content erased (SPEC §14.1.3).
required: [id, connection_id, status, direction, purchase_date, booking_date, amount_ore, original_amount,
original_currency, merchant, expense_id, ignored, created_at, updated_at, deleted_at, card_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
connection_id:
$ref: '#/components/schemas/Uuid'
status:
$ref: '#/components/schemas/CardPurchaseStatus'
direction:
$ref: '#/components/schemas/CardDirection'
purchase_date:
$ref: '#/components/schemas/Date'
booking_date:
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
amount_ore:
type: [integer, 'null']
format: int64
description: Null for a purchase in another currency than SEK and for a tombstone.
original_amount:
type: [string, 'null']
description: The bank's decimal amount, for a purchase in another currency than SEK.
original_currency:
type: [string, 'null']
description: ISO 4217, for a purchase in another currency than SEK.
merchant:
type: string
expense_id:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
ignored:
type: boolean
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
card_version:
type: integer
format: int64
CardAttempt:
type: object
description: '`auth_url` only in the E-37 response (null in E-38); `connection` once connected.'
required: [id, provider_id, status, failure, auth_url, expires_at, connection]
properties:
id:
$ref: '#/components/schemas/Uuid'
provider_id:
type: string
status:
$ref: '#/components/schemas/CardAttemptStatus'
failure:
type: [string, 'null']
description: '`cancelled`, `no_account` or `bank_error` for a failed attempt, else null.'
auth_url:
type: [string, 'null']
expires_at:
$ref: '#/components/schemas/Timestamp'
connection:
oneOf:
- $ref: '#/components/schemas/CardConnection'
- type: 'null'
CreateCardAuthRequest:
type: object
required: [provider_id, history_days, client]
properties:
provider_id:
type: string
history_days:
type: integer
description: One of 7, 30, 90, 180 (SPEC §9.6).
client:
$ref: '#/components/schemas/CardClient'
CardSyncResponse:
type: object
required: [since, next_since, has_more, server_time, connections, purchases]
properties:
since:
type: integer
format: int64
next_since:
type: integer
format: int64
description: The highest `card_version` in the page, or `since` when the page is empty.
has_more:
type: boolean
server_time:
$ref: '#/components/schemas/Timestamp'
connections:
type: array
items:
$ref: '#/components/schemas/CardConnection'
purchases:
type: array
items:
$ref: '#/components/schemas/CardPurchase'
PatchCardConnectionRequest:
type: object
required: [history_days]
properties:
history_days:
type: integer
description: One of 7, 30, 90, 180, at most the current value (CP-3).
CardRefreshResponse:
type: object
required: [connection, fetched]
properties:
connection:
$ref: '#/components/schemas/CardConnection'
fetched:
type: boolean
description: False when the 60-second rule answered without contacting the bank, or the fetch is still running.
PutCardPurchaseRequest:
type: object
required: [expense_id, ignored]
properties:
expense_id:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
ignored:
type: boolean