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,102 @@
//! The background fetch (SPEC §9.12 CP-4, CP-6): the four Stockholm slots, no user headers, the 2-hour rule, the
//! 4-per-24-hours quota, no backlog after downtime, an ended consent expiring the connection; driven with the
//! scripted clock through `poller::tick`.
use kvitt_server::cards::poller;
use serde_json::json;
use crate::common::*;
use crate::support::*;
#[tokio::test]
async fn slots_run_in_the_background_without_user_headers_and_skip_a_recent_success() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![booked(
"r1",
&days_ago(1),
"10.00",
"Testkaféet Ankan",
)]);
app.connect(&admin_token(), 30).await; // a user-present fetch at 12:00Z (14:00 local)
app.stub.clear();
let mut last = None;
// 11:45 local is due, but a fetch succeeded less than 2 hours ago.
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert!(app.stub.transaction_requests().is_empty());
// 16:45 local (14:45Z): due.
app.set_now("2026-10-17T14:45:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
let requests = app.stub.transaction_requests();
assert_eq!(
requests.len(),
1,
"one window: 10 days before the latest booking"
);
assert!(
requests[0].psu_headers().is_empty(),
"a background fetch sends no PSU header (CP-5)"
);
// The same slot never runs twice.
app.set_now("2026-10-17T15:30:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
let starts: String = sqlx::query_scalar("SELECT background_fetches FROM card_connections")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(starts, r#"["2026-10-17T14:45:00.000Z"]"#);
}
#[tokio::test]
async fn four_background_fetches_in_24_hours_are_the_limit() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
sqlx::query("UPDATE card_connections SET last_fetch_at = '2026-10-16T00:00:00.000Z', background_fetches = ?")
.bind(json!(["2026-10-16T19:50:00.000Z", "2026-10-17T04:45:00.000Z", "2026-10-17T09:45:00.000Z",
"2026-10-17T14:45:00.000Z"]).to_string())
.execute(&app.state.db)
.await
.unwrap();
app.stub.clear();
let mut last = None;
app.set_now("2026-10-17T19:45:00.000Z");
assert_eq!(
poller::tick(&app.state, &mut last).await,
0,
"4 started in the preceding 24 hours"
);
// The next morning the oldest two have left the window.
app.set_now("2026-10-18T04:46:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
assert_eq!(app.stub.transaction_requests().len(), 1);
}
#[tokio::test]
async fn after_downtime_only_the_due_slot_runs() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
let mut last = None;
app.set_now("2026-10-17T14:45:30.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
app.stub.clear();
// Down for two days and a half: eight slots passed, one runs.
app.set_now("2026-10-20T03:00:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert_eq!(app.stub.transaction_requests().len(), 1);
}
#[tokio::test]
async fn an_ended_consent_expires_the_connection_and_nothing_is_fetched() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
app.stub.clear();
let mut last = None;
app.set_now("2027-04-15T12:00:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert!(app.stub.transaction_requests().is_empty());
let c = app.connection(&admin_token()).await;
assert_eq!(c["status"], "expired");
}