671c00d0d8
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
463 lines
16 KiB
Rust
463 lines
16 KiB
Rust
//! 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",
|
|
);
|
|
}
|