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
@@ -0,0 +1,574 @@
{
"format": 1,
"function": "match_card_purchase",
"cases": [
{
"name": "no expenses",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [],
"window_days": 3,
"max": 3
},
"expected": {
"ok": []
}
},
{
"name": "exact amount, same day",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001"
]
}
},
{
"name": "three days before and after are included",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-19",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 12900,
"date": "2026-10-25",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001",
"0192e000-0000-7000-8000-000000000002"
]
}
},
{
"name": "four days before or after are excluded",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-18",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 12900,
"date": "2026-10-26",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": []
}
},
{
"name": "an amount 1 öre off is excluded",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12899,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 12901,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000003",
"amount_ore": 12900,
"date": "2026-10-23",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000003"
]
}
},
{
"name": "an expense linked from another purchase of the viewer is excluded",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": true
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 12900,
"date": "2026-10-21",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000002"
]
}
},
{
"name": "ordered by day difference",
"input": {
"purchase": {
"amount_ore": 5000,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 5000,
"date": "2026-10-25",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 5000,
"date": "2026-10-21",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000003",
"amount_ore": 5000,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000003",
"0192e000-0000-7000-8000-000000000002",
"0192e000-0000-7000-8000-000000000001"
]
}
},
{
"name": "equal day difference: newer created_at first",
"input": {
"purchase": {
"amount_ore": 5000,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 5000,
"date": "2026-10-20",
"created_at": "2026-10-20T08:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 5000,
"date": "2026-10-24",
"created_at": "2026-10-24T08:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000002",
"0192e000-0000-7000-8000-000000000001"
]
}
},
{
"name": "equal day difference and created_at: lower id first",
"input": {
"purchase": {
"amount_ore": 5000,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000007",
"amount_ore": 5000,
"date": "2026-10-23",
"created_at": "2026-10-23T08:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000004",
"amount_ore": 5000,
"date": "2026-10-21",
"created_at": "2026-10-23T08:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000004",
"0192e000-0000-7000-8000-000000000007"
]
}
},
{
"name": "more candidates than max are cut",
"input": {
"purchase": {
"amount_ore": 5000,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 5000,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 5000,
"date": "2026-10-23",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000003",
"amount_ore": 5000,
"date": "2026-10-21",
"created_at": "2026-10-21T08:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000004",
"amount_ore": 5000,
"date": "2026-10-25",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001",
"0192e000-0000-7000-8000-000000000003",
"0192e000-0000-7000-8000-000000000002"
]
}
},
{
"name": "max 1",
"input": {
"purchase": {
"amount_ore": 5000,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 5000,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 5000,
"date": "2026-10-23",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 1
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001"
]
}
},
{
"name": "a credit purchase has no matches",
"input": {
"purchase": {
"amount_ore": 12900,
"date": "2026-10-22",
"direction": "credit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": []
}
},
{
"name": "a purchase without amount (foreign currency) has no matches",
"input": {
"purchase": {
"amount_ore": null,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 12900,
"date": "2026-10-22",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": []
}
},
{
"name": "the window crosses a month boundary",
"input": {
"purchase": {
"amount_ore": 30000,
"date": "2026-11-01",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 30000,
"date": "2026-10-29",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 30000,
"date": "2026-10-28",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000003",
"amount_ore": 30000,
"date": "2026-11-04",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001",
"0192e000-0000-7000-8000-000000000003"
]
}
},
{
"name": "the window crosses a year boundary",
"input": {
"purchase": {
"amount_ore": 30000,
"date": "2027-01-02",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 30000,
"date": "2026-12-30",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 30000,
"date": "2026-12-29",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000003",
"amount_ore": 30000,
"date": "2027-01-05",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001",
"0192e000-0000-7000-8000-000000000003"
]
}
},
{
"name": "a leap day inside the window",
"input": {
"purchase": {
"amount_ore": 2500,
"date": "2028-03-01",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 2500,
"date": "2028-02-27",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 2500,
"date": "2028-02-26",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 3,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001"
]
}
},
{
"name": "a 14-day window (other callers)",
"input": {
"purchase": {
"amount_ore": 2500,
"date": "2026-10-22",
"direction": "debit"
},
"expenses": [
{
"id": "0192e000-0000-7000-8000-000000000001",
"amount_ore": 2500,
"date": "2026-10-08",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
},
{
"id": "0192e000-0000-7000-8000-000000000002",
"amount_ore": 2500,
"date": "2026-10-07",
"created_at": "2026-10-20T10:00:00.000Z",
"linked_by_viewer": false
}
],
"window_days": 14,
"max": 3
},
"expected": {
"ok": [
"0192e000-0000-7000-8000-000000000001"
]
}
}
]
}