K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Build Kvitt images / build (push) Failing after 8m17s
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:
@@ -0,0 +1,462 @@
|
||||
//! Fetching (SPEC §9.12 CP-7, CP-8, CP-14; D-178, D-179): the windowed walk, the split at the 100-row cap, the
|
||||
//! de-duplication across windows, continuation keys, the end of history, the mapping, the matching of pending and
|
||||
//! booked transactions, deletion of vanished pending purchases and the recorded bank errors.
|
||||
|
||||
use axum::http::Method;
|
||||
use chrono::{Days, NaiveDate};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::stub::eb_error;
|
||||
use crate::support::*;
|
||||
|
||||
fn date(text: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
fn windows_asked(app: &CardsApp) -> Vec<(String, String)> {
|
||||
app.stub
|
||||
.transaction_requests()
|
||||
.iter()
|
||||
.map(|r| (r.query["date_from"].clone(), r.query["date_to"].clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_180_day_first_fetch_asks_six_windows_newest_first_without_strategy() {
|
||||
let app = CardsApp::new().await;
|
||||
app.stub.set_transactions(vec![
|
||||
booked("r-new", &days_ago(1), "10.00", "Testkaféet Ankan"),
|
||||
booked("r-old", &days_ago(170), "20.00", "Gamla Testbutiken"),
|
||||
]);
|
||||
app.connect(&admin_token(), 180).await;
|
||||
let asked = windows_asked(&app);
|
||||
assert_eq!(asked.len(), 6, "{asked:?}");
|
||||
assert_eq!(asked[0], (days_ago(29), days_ago(0)));
|
||||
assert_eq!(asked[5].0, days_ago(179), "180 days, today included");
|
||||
for pair in asked.windows(2) {
|
||||
assert_eq!(
|
||||
date(&pair[1].1).checked_add_days(Days::new(1)).unwrap(),
|
||||
date(&pair[0].0),
|
||||
"consecutive, non-overlapping, newest first"
|
||||
);
|
||||
}
|
||||
for (from, to) in &asked {
|
||||
assert_eq!((date(to) - date(from)).num_days(), 29, "30 days each");
|
||||
}
|
||||
for r in app.stub.transaction_requests() {
|
||||
assert!(
|
||||
!r.query.contains_key("strategy"),
|
||||
"strategy is never sent (D-178)"
|
||||
);
|
||||
assert!(!r.query.contains_key("continuation_key"));
|
||||
}
|
||||
assert_eq!(app.purchases(&admin_token()).await.len(), 2);
|
||||
|
||||
// The next fetch is incremental: 10 days before the latest stored booking date, one window.
|
||||
app.stub.clear();
|
||||
let connection = app.connection(&admin_token()).await["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
assert_eq!(windows_asked(&app), [(days_ago(11), days_ago(0))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_window_at_the_cap_is_split_down_to_a_truncated_day_and_deletes_nothing() {
|
||||
let app = CardsApp::new().await;
|
||||
app.stub.set_transactions(vec![pending(
|
||||
"p-stays",
|
||||
&days_ago(20),
|
||||
"77.00",
|
||||
"Testbutiken Väntar",
|
||||
)]);
|
||||
let connection = app.connect(&admin_token(), 30).await;
|
||||
assert_eq!(app.purchases(&admin_token()).await.len(), 1);
|
||||
|
||||
// 150 rows on one day (more than the cap), 5 elsewhere; the pending purchase has vanished.
|
||||
let mut rows = many("busy", &days_ago(7), 150);
|
||||
rows.extend(many("calm", &days_ago(27), 5));
|
||||
app.stub.set_transactions(rows);
|
||||
app.stub.clear();
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
|
||||
let asked = windows_asked(&app);
|
||||
let busy = days_ago(7);
|
||||
assert_eq!(
|
||||
asked.first().unwrap(),
|
||||
&(days_ago(29), days_ago(0)),
|
||||
"{asked:?}"
|
||||
);
|
||||
assert!(
|
||||
asked.contains(&(busy.clone(), busy.clone())),
|
||||
"split down to the one day: {asked:?}"
|
||||
);
|
||||
assert_eq!(asked.len(), 11, "{asked:?}");
|
||||
let purchases = app.purchases(&admin_token()).await;
|
||||
assert_eq!(
|
||||
purchases.len(),
|
||||
1 + 100 + 5,
|
||||
"the truncated day kept as returned"
|
||||
);
|
||||
assert!(
|
||||
purchases
|
||||
.iter()
|
||||
.any(|p| p["merchant"] == "Testbutiken Väntar"),
|
||||
"an incomplete fetch deletes no pending purchase"
|
||||
);
|
||||
assert_eq!(
|
||||
app.connection(&admin_token()).await["last_fetch_error"],
|
||||
Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rows_returned_outside_their_window_are_deduplicated_by_reference() {
|
||||
let app = CardsApp::new().await;
|
||||
let mut late = booked("dup", &days_ago(30), "11.00", "Gränsbutiken");
|
||||
late["_bank_date"] = json!(days_ago(29));
|
||||
let mut early = booked("dup", &days_ago(30), "99.00", "Gränsbutiken");
|
||||
early["_bank_date"] = json!(days_ago(30));
|
||||
app.stub.set_transactions(vec![late, early]);
|
||||
app.connect(&admin_token(), 90).await;
|
||||
assert_eq!(windows_asked(&app).len(), 3);
|
||||
let purchases = app.purchases(&admin_token()).await;
|
||||
assert_eq!(purchases.len(), 1, "{purchases:#?}");
|
||||
assert_eq!(
|
||||
purchases[0]["amount_ore"], 1100,
|
||||
"the newer window's occurrence wins"
|
||||
);
|
||||
assert_eq!(
|
||||
purchases[0]["purchase_date"],
|
||||
days_ago(30),
|
||||
"kept as returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_continuation_key_is_still_followed() {
|
||||
let app = CardsApp::new().await;
|
||||
*app.stub.state.paginate.lock().unwrap() = true;
|
||||
app.stub.set_transactions(many("page", &days_ago(3), 250));
|
||||
app.connect(&admin_token(), 7).await;
|
||||
let requests = app.stub.transaction_requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(requests[1].query["continuation_key"], "100");
|
||||
assert_eq!(
|
||||
requests[2].query["date_from"],
|
||||
days_ago(6),
|
||||
"within its window"
|
||||
);
|
||||
assert_eq!(app.purchases(&admin_token()).await.len(), 250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_transactions_period_ends_the_walk_back_and_the_fetch_succeeds() {
|
||||
let app = CardsApp::new().await;
|
||||
*app.stub.state.history_from.lock().unwrap() =
|
||||
Some(today().checked_sub_days(Days::new(75)).unwrap());
|
||||
app.stub.set_transactions(vec![
|
||||
booked("a", &days_ago(5), "10.00", "Butik A"),
|
||||
booked("b", &days_ago(65), "20.00", "Butik B"),
|
||||
]);
|
||||
app.connect(&admin_token(), 180).await;
|
||||
assert_eq!(
|
||||
windows_asked(&app).len(),
|
||||
4,
|
||||
"the fourth window answered WRONG_TRANSACTIONS_PERIOD"
|
||||
);
|
||||
let c = app.connection(&admin_token()).await;
|
||||
assert_eq!(c["last_fetch_error"], Value::Null);
|
||||
assert!(c["last_fetch_at"].is_string());
|
||||
assert_eq!(app.purchases(&admin_token()).await.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transactions_map_per_cp_7_and_personal_fields_are_never_stored_or_served() {
|
||||
let app = CardsApp::new().await;
|
||||
let mut fee = booked("fee", &days_ago(2), "25.00", "Årsavgift");
|
||||
fee["bank_transaction_code"] = json!({"code": "FEE"});
|
||||
let mut foreign = booked("eur", &days_ago(3), "-12.50", "Museum Example");
|
||||
foreign["transaction_amount"]["currency"] = json!("EUR");
|
||||
let mut no_ref = booked("x", &days_ago(4), "8.00", "Utan Referens");
|
||||
no_ref.as_object_mut().unwrap().remove("entry_reference");
|
||||
let mut twin = no_ref.clone();
|
||||
twin["transaction_id"] = json!("tid-twin");
|
||||
let mut multi = booked("multi", &days_ago(5), "1234.56", "x");
|
||||
multi["remittance_information"] = json!([" Rad ett ", "rad två"]);
|
||||
let mut nameless = booked("nameless", &days_ago(5), "3.00", "");
|
||||
nameless["remittance_information"] = json!([]);
|
||||
app.stub.set_transactions(vec![
|
||||
tx(
|
||||
Some("credit"),
|
||||
"BOOK",
|
||||
"CRDT",
|
||||
&days_ago(1),
|
||||
"5000.00",
|
||||
"Inbetalning",
|
||||
),
|
||||
fee,
|
||||
foreign,
|
||||
no_ref,
|
||||
twin,
|
||||
multi,
|
||||
nameless,
|
||||
booked("zero", &days_ago(6), "0.00", "Nollbelopp"),
|
||||
tx(
|
||||
Some("rjct"),
|
||||
"RJCT",
|
||||
"DBIT",
|
||||
&days_ago(6),
|
||||
"9.00",
|
||||
"Avvisad",
|
||||
),
|
||||
]);
|
||||
app.connect(&admin_token(), 30).await;
|
||||
let purchases = app.purchases(&admin_token()).await;
|
||||
assert_eq!(
|
||||
purchases.len(),
|
||||
7,
|
||||
"zero and rejected skipped: {purchases:#?}"
|
||||
);
|
||||
let credit = by_merchant(&purchases, "Inbetalning");
|
||||
assert_eq!(
|
||||
(credit["direction"].as_str(), credit["amount_ore"].as_i64()),
|
||||
(Some("credit"), Some(500_000))
|
||||
);
|
||||
let fee = by_merchant(&purchases, "Årsavgift");
|
||||
assert_eq!(
|
||||
fee["direction"], "debit",
|
||||
"a debit FEE is a purchase to handle (D-179)"
|
||||
);
|
||||
let eur = by_merchant(&purchases, "Museum Example");
|
||||
assert_eq!(
|
||||
(
|
||||
&eur["amount_ore"],
|
||||
&eur["original_amount"],
|
||||
&eur["original_currency"]
|
||||
),
|
||||
(&Value::Null, &json!("12.50"), &json!("EUR"))
|
||||
);
|
||||
assert_eq!(
|
||||
by_merchant(&purchases, "Rad ett rad två")["amount_ore"],
|
||||
123_456
|
||||
);
|
||||
assert!(
|
||||
purchases.iter().any(|p| p["merchant"] == "Kortköp"),
|
||||
"no merchant text: Kortköp"
|
||||
);
|
||||
assert_eq!(
|
||||
purchases
|
||||
.iter()
|
||||
.filter(|p| p["merchant"] == "Utan Referens")
|
||||
.count(),
|
||||
2,
|
||||
"identical rows without reference are told apart by their index"
|
||||
);
|
||||
let refs: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT provider_ref FROM card_purchases WHERE merchant = 'Utan Referens'",
|
||||
)
|
||||
.fetch_all(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
refs.iter().all(|r| r.starts_with("h:") && r.len() == 66),
|
||||
"{refs:?}"
|
||||
);
|
||||
|
||||
let served = app.card_sync(&admin_token()).await.to_string();
|
||||
let stored = sqlx::query_scalar::<_, String>(
|
||||
"SELECT group_concat(quote(provider_ref) || merchant || coalesce(original_amount, ''), '|') FROM card_purchases",
|
||||
)
|
||||
.fetch_one(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let connection: String =
|
||||
sqlx::query_scalar("SELECT session_id || account_uid FROM card_connections")
|
||||
.fetch_one(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(connection, "sess-secret-1acc-secret-1", "kept server-side");
|
||||
for never in [
|
||||
HOLDER_NAME,
|
||||
CARD_NUMBER,
|
||||
"4711",
|
||||
MERCHANT_TOWN,
|
||||
"tid-",
|
||||
"PURCHASE",
|
||||
"FEE",
|
||||
] {
|
||||
assert!(!served.contains(never), "served {never}");
|
||||
assert!(!stored.contains(never), "stored {never}");
|
||||
}
|
||||
for never in [
|
||||
"sess-secret-1",
|
||||
"acc-secret-1",
|
||||
"provider_ref",
|
||||
"entry_reference",
|
||||
] {
|
||||
assert!(!served.contains(never), "served {never}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turning_booked_keeps_the_purchase_by_reference_or_by_match() {
|
||||
let app = CardsApp::new().await;
|
||||
let expense = app
|
||||
.insert_expense(ACCOUNT_1, GROCERIES, 9900, ADMIN_ID, (4950, 4950), false)
|
||||
.await;
|
||||
app.stub.set_transactions(vec![
|
||||
pending("p1", &days_ago(3), "99.00", "Testbutik Ett"),
|
||||
pending("p2", &days_ago(4), "45.00", "Bokhandeln Exempel 123"),
|
||||
pending("p3", &days_ago(4), "45.00", "Annan Butik"),
|
||||
pending("p4", &days_ago(5), "12.00", "Försvinner"),
|
||||
pending("p5", &days_ago(5), "13.00", "Försvinner Ignorerad"),
|
||||
]);
|
||||
let connection = app.connect(&admin_token(), 30).await;
|
||||
let before = app.purchases(&admin_token()).await;
|
||||
let id_of = |m: &str| by_merchant(&before, m)["id"].as_str().unwrap().to_owned();
|
||||
let app_ref = &app;
|
||||
let put = |id: String, body: Value| {
|
||||
let path = format!("/cards/purchases/{id}");
|
||||
async move {
|
||||
app_ref
|
||||
.call(Method::PUT, &path, &admin_token(), Some(body))
|
||||
.await
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
put(
|
||||
id_of("Testbutik Ett"),
|
||||
json!({"expense_id": expense, "ignored": false})
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
200
|
||||
);
|
||||
assert_eq!(
|
||||
put(
|
||||
id_of("Försvinner Ignorerad"),
|
||||
json!({"expense_id": null, "ignored": true})
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
200
|
||||
);
|
||||
|
||||
// p1 booked under its own reference; p2 booked under a new reference; p3, p4, p5 no longer reported.
|
||||
app.stub.set_transactions(vec![
|
||||
booked("p1", &days_ago(2), "99.00", "Testbutik Ett"),
|
||||
booked("b2", &days_ago(2), "45.00", "BOKHANDELN Exempel"),
|
||||
]);
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
let after = app.purchases(&admin_token()).await;
|
||||
let p1 = after
|
||||
.iter()
|
||||
.find(|p| p["id"] == id_of("Testbutik Ett").as_str())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p1["status"].as_str(), p1["booking_date"].as_str()),
|
||||
(Some("booked"), Some(days_ago(2).as_str()))
|
||||
);
|
||||
assert_eq!(p1["expense_id"], expense.as_str(), "the link is kept");
|
||||
let p2 = after
|
||||
.iter()
|
||||
.find(|p| p["id"] == id_of("Bokhandeln Exempel 123").as_str())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
p2["status"], "booked",
|
||||
"taken over by CP-8's secondary rule"
|
||||
);
|
||||
assert_eq!(p2["merchant"], "BOKHANDELN Exempel");
|
||||
let refs: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT provider_ref FROM card_purchases WHERE deleted_at IS NULL ORDER BY provider_ref",
|
||||
)
|
||||
.fetch_all(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(refs, ["b2", "p1", "p5"]);
|
||||
assert!(
|
||||
after
|
||||
.iter()
|
||||
.any(|p| p["merchant"] == "Försvinner Ignorerad"),
|
||||
"an ignored pending purchase stays"
|
||||
);
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
3,
|
||||
"p3 (other merchant) and p4 vanished and were deleted: {after:#?}"
|
||||
);
|
||||
let tombstones = app.card_sync(&admin_token()).await["purchases"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| !p["deleted_at"].is_null())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(tombstones.len(), 2);
|
||||
for t in tombstones {
|
||||
assert_eq!(
|
||||
(t["merchant"].as_str(), t["purchase_date"].as_str()),
|
||||
(Some(""), Some("1970-01-01"))
|
||||
);
|
||||
assert_eq!(
|
||||
(&t["amount_ore"], &t["expense_id"], &t["ignored"]),
|
||||
(&Value::Null, &Value::Null, &json!(false))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bank_errors_are_recorded_per_cp_14_and_cleared_by_a_success() {
|
||||
let app = CardsApp::new().await;
|
||||
let connection = app.connect(&admin_token(), 7).await;
|
||||
let first_fetch = app.connection(&admin_token()).await["last_fetch_at"].clone();
|
||||
for (answer, recorded) in [
|
||||
(eb_error(500, "ASPSP_ERROR"), "bank_unavailable"),
|
||||
(eb_error(503, ""), "bank_unavailable"),
|
||||
(eb_error(429, "ASPSP_RATE_LIMIT_EXCEEDED"), "rate_limited"),
|
||||
(eb_error(400, "WRONG_REQUEST_PARAMETERS"), "failed"),
|
||||
] {
|
||||
app.stub.script_transactions(&[answer]);
|
||||
let reply = app.refetch(&admin_token(), &connection).await;
|
||||
assert_eq!(
|
||||
reply.json["fetched"], true,
|
||||
"a failed fetch still answers 200"
|
||||
);
|
||||
let c = app.connection(&admin_token()).await;
|
||||
assert_eq!(c["last_fetch_error"], recorded);
|
||||
assert_eq!(c["status"], "active");
|
||||
assert_eq!(
|
||||
c["last_fetch_at"], first_fetch,
|
||||
"last_fetch_at is the last successful fetch"
|
||||
);
|
||||
}
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
assert_eq!(
|
||||
app.connection(&admin_token()).await["last_fetch_error"],
|
||||
Value::Null
|
||||
);
|
||||
|
||||
// An expired session at the bank expires the connection; purchases stay; refresh is refused.
|
||||
app.stub
|
||||
.set_transactions(vec![booked("k", &days_ago(1), "5.00", "Kvar")]);
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
app.stub
|
||||
.script_transactions(&[eb_error(401, "EXPIRED_SESSION")]);
|
||||
app.refetch(&admin_token(), &connection).await;
|
||||
let c = app.connection(&admin_token()).await;
|
||||
assert_eq!(
|
||||
(c["status"].as_str(), c["last_fetch_error"].as_str()),
|
||||
(Some("expired"), Some("consent_expired"))
|
||||
);
|
||||
assert_eq!(app.purchases(&admin_token()).await.len(), 1);
|
||||
let now = app.state.clock.now();
|
||||
app.clock.set(now + chrono::Duration::seconds(61));
|
||||
assert_error(
|
||||
&app.refresh(&admin_token(), &connection).await,
|
||||
409,
|
||||
"card_consent_expired",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user