From 671c00d0d8a947b1002cbfa7da150e89fcd3c670 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 24 Oct 2026 16:43:26 +0200 Subject: [PATCH] K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- contracts/examples/E-1-health/response.json | 2 +- .../E-36-card-providers/response.json | 11 + .../examples/E-37-card-auth/request.json | 5 + .../examples/E-37-card-auth/response.json | 9 + .../examples/E-38-card-attempt/response.json | 21 + .../examples/E-40-card-sync/response.json | 95 +++ .../E-41-card-connection-update/request.json | 3 + .../E-41-card-connection-update/response.json | 13 + .../examples/E-42-card-refresh/response.json | 16 + .../E-43-card-disconnect/response.json | 13 + .../E-44-card-purchase-update/request.json | 4 + .../E-44-card-purchase-update/response.json | 18 + .../errors/card_bank_unavailable.json | 5 + .../examples/errors/card_consent_expired.json | 5 + .../examples/errors/cards_unavailable.json | 5 + .../errors/expense_already_linked.json | 5 + contracts/openapi.yaml | 655 +++++++++++++++ .../schema/migrations/0004_card_purchases.sql | 83 ++ .../test-vectors/match_card_purchase.json | 574 +++++++++++++ infra/README.md | 47 ++ infra/dev/.env.example | 6 + infra/dev/compose.yaml | 9 +- infra/dev/lib.sh | 25 +- infra/dev/up.sh | 1 + infra/npm/kvitt.conf | 13 + infra/pi/.env.example | 6 + infra/pi/compose.yaml | 15 +- server/README.md | 27 +- server/crates/kvitt-domain/tests/vectors.rs | 8 +- .../migrations/0004_card_purchases.sql | 83 ++ .../crates/kvitt-server/src/api/rate_limit.rs | 16 +- server/crates/kvitt-server/src/cards/api.rs | 662 +++++++++++++++ .../crates/kvitt-server/src/cards/callback.rs | 441 ++++++++++ .../src/cards/enable_banking/mapping.rs | 279 +++++++ .../src/cards/enable_banking/mod.rs | 566 +++++++++++++ server/crates/kvitt-server/src/cards/fetch.rs | 756 ++++++++++++++++++ server/crates/kvitt-server/src/cards/mod.rs | 750 +++++++++++++++++ .../crates/kvitt-server/src/cards/poller.rs | 188 +++++ .../kvitt-server/src/cards/providers.rs | 57 ++ server/crates/kvitt-server/src/config.rs | 45 ++ server/crates/kvitt-server/src/db/mod.rs | 6 +- server/crates/kvitt-server/src/error.rs | 20 +- server/crates/kvitt-server/src/lib.rs | 4 + server/crates/kvitt-server/src/openapi.rs | 11 + server/crates/kvitt-server/src/router.rs | 25 +- server/crates/kvitt-server/src/sync/push.rs | 4 + server/crates/kvitt-server/src/tasks/mod.rs | 9 +- .../kvitt-server/src/tasks/scheduler.rs | 11 +- server/crates/kvitt-server/src/wire/card.rs | 315 ++++++++ server/crates/kvitt-server/src/wire/mod.rs | 7 + .../kvitt-server/tests/cards_api/connect.rs | 456 +++++++++++ .../kvitt-server/tests/cards_api/endpoints.rs | 738 +++++++++++++++++ .../kvitt-server/tests/cards_api/fetch.rs | 462 +++++++++++ .../kvitt-server/tests/cards_api/logging.rs | 143 ++++ .../kvitt-server/tests/cards_api/main.rs | 17 + .../kvitt-server/tests/cards_api/poller.rs | 102 +++ .../kvitt-server/tests/cards_api/stub.rs | 349 ++++++++ .../kvitt-server/tests/cards_api/support.rs | 255 ++++++ .../kvitt-server/tests/openapi_contract.rs | 4 +- .../kvitt-server/tests/skeleton/errors.rs | 4 + .../kvitt-server/tests/skeleton/migration.rs | 123 ++- .../kvitt-server/tests/skeleton/routes.rs | 4 +- .../kvitt-server/tests/skeleton/support.rs | 5 + .../tests/skeleton/wire_examples.rs | 16 + tools/contracts-check.py | 228 +++++- web/src/api/schema.d.ts | 699 +++++++++++++++- web/src/lib/vectors.test.ts | 3 +- 67 files changed, 9515 insertions(+), 47 deletions(-) create mode 100644 contracts/examples/E-36-card-providers/response.json create mode 100644 contracts/examples/E-37-card-auth/request.json create mode 100644 contracts/examples/E-37-card-auth/response.json create mode 100644 contracts/examples/E-38-card-attempt/response.json create mode 100644 contracts/examples/E-40-card-sync/response.json create mode 100644 contracts/examples/E-41-card-connection-update/request.json create mode 100644 contracts/examples/E-41-card-connection-update/response.json create mode 100644 contracts/examples/E-42-card-refresh/response.json create mode 100644 contracts/examples/E-43-card-disconnect/response.json create mode 100644 contracts/examples/E-44-card-purchase-update/request.json create mode 100644 contracts/examples/E-44-card-purchase-update/response.json create mode 100644 contracts/examples/errors/card_bank_unavailable.json create mode 100644 contracts/examples/errors/card_consent_expired.json create mode 100644 contracts/examples/errors/cards_unavailable.json create mode 100644 contracts/examples/errors/expense_already_linked.json create mode 100644 contracts/schema/migrations/0004_card_purchases.sql create mode 100644 contracts/test-vectors/match_card_purchase.json create mode 100644 server/crates/kvitt-server/migrations/0004_card_purchases.sql create mode 100644 server/crates/kvitt-server/src/cards/api.rs create mode 100644 server/crates/kvitt-server/src/cards/callback.rs create mode 100644 server/crates/kvitt-server/src/cards/enable_banking/mapping.rs create mode 100644 server/crates/kvitt-server/src/cards/enable_banking/mod.rs create mode 100644 server/crates/kvitt-server/src/cards/fetch.rs create mode 100644 server/crates/kvitt-server/src/cards/mod.rs create mode 100644 server/crates/kvitt-server/src/cards/poller.rs create mode 100644 server/crates/kvitt-server/src/cards/providers.rs create mode 100644 server/crates/kvitt-server/src/wire/card.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/connect.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/endpoints.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/fetch.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/logging.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/main.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/poller.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/stub.rs create mode 100644 server/crates/kvitt-server/tests/cards_api/support.rs diff --git a/contracts/examples/E-1-health/response.json b/contracts/examples/E-1-health/response.json index 379edc3..079cbf4 100644 --- a/contracts/examples/E-1-health/response.json +++ b/contracts/examples/E-1-health/response.json @@ -1,6 +1,6 @@ { "status": "ok", "server_version": "0.1.0", - "schema_version": 3, + "schema_version": 4, "db_ok": true } diff --git a/contracts/examples/E-36-card-providers/response.json b/contracts/examples/E-36-card-providers/response.json new file mode 100644 index 0000000..420ceca --- /dev/null +++ b/contracts/examples/E-36-card-providers/response.json @@ -0,0 +1,11 @@ +{ + "providers": [ + { + "id": "sj_prio_mastercard", + "name": "SJ Prio Mastercard", + "issuer": "SEB Kort", + "image_key": "sj_prio_mastercard", + "available": true + } + ] +} diff --git a/contracts/examples/E-37-card-auth/request.json b/contracts/examples/E-37-card-auth/request.json new file mode 100644 index 0000000..c4da62f --- /dev/null +++ b/contracts/examples/E-37-card-auth/request.json @@ -0,0 +1,5 @@ +{ + "provider_id": "sj_prio_mastercard", + "history_days": 30, + "client": "android" +} diff --git a/contracts/examples/E-37-card-auth/response.json b/contracts/examples/E-37-card-auth/response.json new file mode 100644 index 0000000..f61e8f4 --- /dev/null +++ b/contracts/examples/E-37-card-auth/response.json @@ -0,0 +1,9 @@ +{ + "id": "0192c300-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "pending", + "failure": null, + "auth_url": "https://bank.example.invalid/authorize?session=example", + "expires_at": "2026-10-24T10:15:00.000Z", + "connection": null +} diff --git a/contracts/examples/E-38-card-attempt/response.json b/contracts/examples/E-38-card-attempt/response.json new file mode 100644 index 0000000..c8a5dd9 --- /dev/null +++ b/contracts/examples/E-38-card-attempt/response.json @@ -0,0 +1,21 @@ +{ + "id": "0192c300-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "connected", + "failure": null, + "auth_url": null, + "expires_at": "2026-10-24T10:15:00.000Z", + "connection": { + "id": "0192c100-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "active", + "history_days": 30, + "valid_until": "2027-04-22T10:00:00.000Z", + "last_fetch_at": null, + "last_fetch_error": null, + "fetching": true, + "created_at": "2026-10-24T10:00:00.000Z", + "updated_at": "2026-10-24T10:00:00.000Z", + "card_version": 1 + } +} diff --git a/contracts/examples/E-40-card-sync/response.json b/contracts/examples/E-40-card-sync/response.json new file mode 100644 index 0000000..1820592 --- /dev/null +++ b/contracts/examples/E-40-card-sync/response.json @@ -0,0 +1,95 @@ +{ + "since": 0, + "next_since": 5, + "has_more": false, + "server_time": "2026-10-24T12:00:00.000Z", + "connections": [ + { + "id": "0192c100-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "active", + "history_days": 30, + "valid_until": "2027-04-22T10:00:00.000Z", + "last_fetch_at": "2026-10-24T10:00:05.000Z", + "last_fetch_error": null, + "fetching": false, + "created_at": "2026-10-24T10:00:00.000Z", + "updated_at": "2026-10-24T10:00:05.000Z", + "card_version": 5 + } + ], + "purchases": [ + { + "id": "0192c200-0000-7000-8000-000000000001", + "connection_id": "0192c100-0000-7000-8000-000000000001", + "status": "booked", + "direction": "debit", + "purchase_date": "2026-10-22", + "booking_date": "2026-10-22", + "amount_ore": 12900, + "original_amount": null, + "original_currency": null, + "merchant": "Kafé Exempel", + "expense_id": null, + "ignored": false, + "created_at": "2026-10-24T10:00:05.000Z", + "updated_at": "2026-10-24T10:00:05.000Z", + "deleted_at": null, + "card_version": 1 + }, + { + "id": "0192c200-0000-7000-8000-000000000002", + "connection_id": "0192c100-0000-7000-8000-000000000001", + "status": "pending", + "direction": "debit", + "purchase_date": "2026-10-23", + "booking_date": null, + "amount_ore": 45000, + "original_amount": null, + "original_currency": null, + "merchant": "Bokhandel Exempel", + "expense_id": null, + "ignored": false, + "created_at": "2026-10-24T10:00:05.000Z", + "updated_at": "2026-10-24T10:00:05.000Z", + "deleted_at": null, + "card_version": 2 + }, + { + "id": "0192c200-0000-7000-8000-000000000003", + "connection_id": "0192c100-0000-7000-8000-000000000001", + "status": "booked", + "direction": "debit", + "purchase_date": "2026-10-20", + "booking_date": "2026-10-21", + "amount_ore": null, + "original_amount": "24.90", + "original_currency": "EUR", + "merchant": "Museum Example", + "expense_id": null, + "ignored": false, + "created_at": "2026-10-24T10:00:05.000Z", + "updated_at": "2026-10-24T10:00:05.000Z", + "deleted_at": null, + "card_version": 3 + }, + { + "id": "0192c200-0000-7000-8000-000000000004", + "connection_id": "0192c100-0000-7000-8000-000000000001", + "status": "pending", + "direction": "debit", + "purchase_date": "1970-01-01", + "booking_date": null, + "amount_ore": null, + "original_amount": null, + "original_currency": null, + "merchant": "", + "expense_id": null, + "ignored": false, + "created_at": "2026-10-24T10:00:05.000Z", + "updated_at": "2026-10-24T10:00:05.000Z", + "deleted_at": "2026-10-24T10:00:05.000Z", + "card_version": 4 + } + ] +} diff --git a/contracts/examples/E-41-card-connection-update/request.json b/contracts/examples/E-41-card-connection-update/request.json new file mode 100644 index 0000000..cd38695 --- /dev/null +++ b/contracts/examples/E-41-card-connection-update/request.json @@ -0,0 +1,3 @@ +{ + "history_days": 7 +} diff --git a/contracts/examples/E-41-card-connection-update/response.json b/contracts/examples/E-41-card-connection-update/response.json new file mode 100644 index 0000000..f508fd8 --- /dev/null +++ b/contracts/examples/E-41-card-connection-update/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192c100-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "active", + "history_days": 7, + "valid_until": "2027-04-22T10:00:00.000Z", + "last_fetch_at": "2026-10-24T10:00:05.000Z", + "last_fetch_error": null, + "fetching": false, + "created_at": "2026-10-24T10:00:00.000Z", + "updated_at": "2026-10-24T12:00:00.000Z", + "card_version": 6 +} diff --git a/contracts/examples/E-42-card-refresh/response.json b/contracts/examples/E-42-card-refresh/response.json new file mode 100644 index 0000000..3630248 --- /dev/null +++ b/contracts/examples/E-42-card-refresh/response.json @@ -0,0 +1,16 @@ +{ + "connection": { + "id": "0192c100-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "active", + "history_days": 30, + "valid_until": "2027-04-22T10:00:00.000Z", + "last_fetch_at": "2026-10-24T12:00:03.000Z", + "last_fetch_error": null, + "fetching": false, + "created_at": "2026-10-24T10:00:00.000Z", + "updated_at": "2026-10-24T12:00:03.000Z", + "card_version": 9 + }, + "fetched": true +} diff --git a/contracts/examples/E-43-card-disconnect/response.json b/contracts/examples/E-43-card-disconnect/response.json new file mode 100644 index 0000000..22aa200 --- /dev/null +++ b/contracts/examples/E-43-card-disconnect/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192c100-0000-7000-8000-000000000001", + "provider_id": "sj_prio_mastercard", + "status": "disconnected", + "history_days": 30, + "valid_until": null, + "last_fetch_at": null, + "last_fetch_error": null, + "fetching": false, + "created_at": "2026-10-24T10:00:00.000Z", + "updated_at": "2026-10-24T12:30:00.000Z", + "card_version": 12 +} diff --git a/contracts/examples/E-44-card-purchase-update/request.json b/contracts/examples/E-44-card-purchase-update/request.json new file mode 100644 index 0000000..6e99a82 --- /dev/null +++ b/contracts/examples/E-44-card-purchase-update/request.json @@ -0,0 +1,4 @@ +{ + "expense_id": "0192e000-0000-7000-8000-000000000001", + "ignored": false +} diff --git a/contracts/examples/E-44-card-purchase-update/response.json b/contracts/examples/E-44-card-purchase-update/response.json new file mode 100644 index 0000000..517a3f7 --- /dev/null +++ b/contracts/examples/E-44-card-purchase-update/response.json @@ -0,0 +1,18 @@ +{ + "id": "0192c200-0000-7000-8000-000000000001", + "connection_id": "0192c100-0000-7000-8000-000000000001", + "status": "booked", + "direction": "debit", + "purchase_date": "2026-10-22", + "booking_date": "2026-10-22", + "amount_ore": 12900, + "original_amount": null, + "original_currency": null, + "merchant": "Kafé Exempel", + "expense_id": "0192e000-0000-7000-8000-000000000001", + "ignored": false, + "created_at": "2026-10-24T10:00:05.000Z", + "updated_at": "2026-10-24T12:00:00.000Z", + "deleted_at": null, + "card_version": 10 +} diff --git a/contracts/examples/errors/card_bank_unavailable.json b/contracts/examples/errors/card_bank_unavailable.json new file mode 100644 index 0000000..6b1b955 --- /dev/null +++ b/contracts/examples/errors/card_bank_unavailable.json @@ -0,0 +1,5 @@ +{ + "code": "card_bank_unavailable", + "message": "the card service or the bank did not answer or refused", + "field": null +} diff --git a/contracts/examples/errors/card_consent_expired.json b/contracts/examples/errors/card_consent_expired.json new file mode 100644 index 0000000..96cdfa1 --- /dev/null +++ b/contracts/examples/errors/card_consent_expired.json @@ -0,0 +1,5 @@ +{ + "code": "card_consent_expired", + "message": "the card connection has expired or is disconnected", + "field": null +} diff --git a/contracts/examples/errors/cards_unavailable.json b/contracts/examples/errors/cards_unavailable.json new file mode 100644 index 0000000..4db0bc9 --- /dev/null +++ b/contracts/examples/errors/cards_unavailable.json @@ -0,0 +1,5 @@ +{ + "code": "cards_unavailable", + "message": "the server has no card application for this user", + "field": null +} diff --git a/contracts/examples/errors/expense_already_linked.json b/contracts/examples/errors/expense_already_linked.json new file mode 100644 index 0000000..af1f1aa --- /dev/null +++ b/contracts/examples/errors/expense_already_linked.json @@ -0,0 +1,5 @@ +{ + "code": "expense_already_linked", + "message": "the expense is linked from another card purchase", + "field": "expense_id" +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index e1d3c52..67c30c0 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -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 diff --git a/contracts/schema/migrations/0004_card_purchases.sql b/contracts/schema/migrations/0004_card_purchases.sql new file mode 100644 index 0000000..702f67d --- /dev/null +++ b/contracts/schema/migrations/0004_card_purchases.sql @@ -0,0 +1,83 @@ +-- Kvitt server schema v4: card purchases (SPEC §9.12, FR-22, D-167). Do not edit; add a new migration and bump the +-- schema version (SPEC §14.8). Applied on top of 0003_device_version_name.sql. +-- +-- Card data is private to the card holder (D-161): none of these rows is in the household sync stream (GET /sync), the +-- audit log or a push. Card rows carry card_version from their own sequence and are read through GET /cards/sync. + +CREATE TABLE card_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + value INTEGER NOT NULL +) STRICT; +INSERT INTO card_sequence (id, value) VALUES (1, 0); + +-- One connection per user and card provider. session_id is an Enable Banking session: a credential usable only with the +-- application's private key, which is never in the database or a backup (D-161). Erased when disconnected. +CREATE TABLE card_connections ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + provider_id TEXT NOT NULL CHECK (length(provider_id) BETWEEN 1 AND 64), + status TEXT NOT NULL CHECK (status IN ('active', 'expired', 'disconnected')), + session_id TEXT, + account_uid TEXT, + valid_until TEXT, + history_days INTEGER NOT NULL CHECK (history_days BETWEEN 1 AND 365), + last_fetch_at TEXT, + last_fetch_error TEXT, + last_user_fetch_at TEXT, + background_fetches TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + card_version INTEGER NOT NULL UNIQUE, + CHECK ((status = 'disconnected') = (session_id IS NULL)) +) STRICT; +CREATE UNIQUE INDEX card_connections_user_provider ON card_connections (user_id, provider_id); + +-- Single-use authorisation attempts (D-162); state is stored only as its SHA-256 hash. Pruned 7 days after creation. +CREATE TABLE card_auth_attempts ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + device_id TEXT NOT NULL REFERENCES devices (id), + provider_id TEXT NOT NULL CHECK (length(provider_id) BETWEEN 1 AND 64), + client TEXT NOT NULL CHECK (client IN ('android', 'web')), + state_hash TEXT NOT NULL UNIQUE, + history_days INTEGER NOT NULL CHECK (history_days BETWEEN 1 AND 365), + status TEXT NOT NULL CHECK (status IN ('pending', 'connected', 'failed', 'expired')), + failure TEXT, + connection_id TEXT REFERENCES card_connections (id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + finished_at TEXT +) STRICT; +CREATE INDEX card_auth_attempts_created ON card_auth_attempts (created_at); + +-- Purchases as the bank reports them, mapped per SPEC §9.12 CP-7. amount_ore is null only for a non-SEK purchase +-- (original_amount and original_currency then set) and for a tombstone. A tombstone has its content erased (D-167). +CREATE TABLE card_purchases ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + connection_id TEXT NOT NULL REFERENCES card_connections (id), + provider_ref TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'booked')), + direction TEXT NOT NULL CHECK (direction IN ('debit', 'credit')), + purchase_date TEXT NOT NULL, + booking_date TEXT, + amount_ore INTEGER CHECK (amount_ore IS NULL OR amount_ore >= 1), + original_amount TEXT, + original_currency TEXT CHECK (original_currency IS NULL OR length(original_currency) = 3), + merchant TEXT NOT NULL CHECK (length(merchant) <= 140), + expense_id TEXT REFERENCES expenses (id), + ignored INTEGER NOT NULL DEFAULT 0 CHECK (ignored IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + card_version INTEGER NOT NULL UNIQUE, + CHECK (NOT (ignored = 1 AND expense_id IS NOT NULL)), + CHECK (deleted_at IS NOT NULL OR (amount_ore IS NULL) = (original_currency IS NOT NULL)) +) STRICT; +CREATE UNIQUE INDEX card_purchases_ref ON card_purchases (connection_id, provider_ref) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX card_purchases_expense_once ON card_purchases (user_id, expense_id) + WHERE expense_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX card_purchases_user_version ON card_purchases (user_id, card_version); +CREATE INDEX card_purchases_expense ON card_purchases (expense_id); + +UPDATE meta SET value = '4' WHERE key = 'schema_version'; diff --git a/contracts/test-vectors/match_card_purchase.json b/contracts/test-vectors/match_card_purchase.json new file mode 100644 index 0000000..879a045 --- /dev/null +++ b/contracts/test-vectors/match_card_purchase.json @@ -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" + ] + } + } + ] +} diff --git a/infra/README.md b/infra/README.md index 30c01ba..d920249 100644 --- a/infra/README.md +++ b/infra/README.md @@ -509,6 +509,8 @@ if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; } # and /apk/, which stay LAN-or-key only. if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; } if ($uri = "/invites/accept") { set $kvitt_gate 1; } +# Card connection (SPEC §9.12, D-163): the bank redirects the user's browser here, without the app key. +if ($uri = "/cards/callback") { set $kvitt_gate 1; } set $kvitt_bearer 0; if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; } if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; } @@ -554,6 +556,17 @@ location ^~ /i/ { proxy_set_header X-Real-IP $remote_addr; proxy_pass $forward_scheme://$server:$port$request_uri; } +# Card connection callback /cards/callback?code=…&state=… (SPEC §9.12, D-163): the query carries a one-time +# authorisation code and the attempt's state, so this path is not logged at all, a 502 included. +location = /cards/callback { + access_log off; + error_log /dev/null crit; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass $forward_scheme://$server:$port$request_uri; +} ``` Save. The new row under Proxy Hosts shows the status Online and an SSL entry for Let's Encrypt (yes/no). Then on @@ -718,6 +731,40 @@ compare the pulled digests (Operations, "Image digests") with the workflow's "Pr run (D-117; a Mac build prints none). Rollback: set the previous tag in `.env` the same way and run `docker compose pull && docker compose up -d`; if a migration had partially applied, restore the pre-deploy backup (`infra/RESTORE.md`). +### Card purchases (SPEC §9.12, §10.4, D-175) + +The server reads card purchases through Enable Banking with the owner's application (restricted production mode, +the cards linked in Enable Banking's control panel with BankID). The application id is not a secret but stays out of +the repository's text: it is only in `.env` and in the key file's name. + +* **Key files.** On the Pi in `~/kvitt/secrets/cards/` (folder mode 0700, each key 0600, owned by `jacobhagstedt`): + `enable-banking.pem`, the private key of `KVITT_EB_APP_ID` as the control panel downloaded it + (`.pem`, renamed), and only for a member's own application (CP-13 fallback) + `enable-banking-member.pem`. On the Mac the key lives in the main checkout's git-ignored `infra/secrets/cards/` + (the development stack stages it from there); it is never committed, never in `data/` and never in a backup. +* **The folder must exist** (`compose.yaml` mounts it with `create_host_path: false`): before the first start with + this `compose.yaml`, `mkdir -p secrets/cards && chmod 700 secrets/cards`, also when no card is configured. +* **`.env`.** `KVITT_EB_APP_ID=` the application id (the key file's name before `.pem`); `KVITT_EB_MEMBER_APP_ID=` + empty unless the member has her own application. Empty id or a missing or unreadable key: card features are off for + that user (the app lists the card as "Inte konfigurerat på servern"), with one warning in the server log at start. + After changing `.env` or a key: `docker compose up -d` (the server restarts and reads them). +* **NPM.** The step 4 block carries the card lines: `/cards/callback` passes the app gate (the bank redirects the + user's browser there without the app key) and is not logged by NPM (its query holds the one-time `code` and the + `state`). After changing the block, paste it again (step 4). +* **Renewing.** The consent lasts at most 180 days (the bank's maximum). From 14 days before it ends the app shows + "Kortanslutningen upphör " with "Förnya med BankID"; tap it and sign with BankID. Renewing keeps every + purchase. After it has ended the app shows "Kortanslutningen har upphört" and fetches nothing until renewed. +* **Background fetches** run at 06:45, 11:45, 16:45 and 21:45 (Europe/Stockholm), at most 4 per card per 24 hours, + skipped when a fetch succeeded less than 2 hours before. +* **The log line.** Each fetch logs one line `card_fetch` with counts only (no merchant, amount, date or id): + `kind` (`background` or `user`), `new`, `updated`, `removed`, `requests`, `windows_split`, `windows_truncated`, + `booked_by_ref`, `booked_by_match`, `status_class` of the last bank call, `error_code` (empty on success) and the + last 8 characters of the connection id. To see them: `docker compose logs server | grep card_fetch | tail -n 20`. + A `windows_truncated` above 0 means a single day held 100 or more transactions (the bank's cap); `booked_by_match` + above 0 means the bank gave a booked purchase a new reference (CP-8's secondary rule took over). +* **Disconnecting** ("Koppla från" in the app) revokes the session at Enable Banking; to stop card access server-wide, + empty `KVITT_EB_APP_ID` and run `docker compose up -d`. + ### Fallback (SPEC §10.5.3): when NPM is down * NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over diff --git a/infra/dev/.env.example b/infra/dev/.env.example index 1f74ed4..961dd46 100644 --- a/infra/dev/.env.example +++ b/infra/dev/.env.example @@ -16,6 +16,12 @@ KVITT_PUBLIC_URL= # Firebase project for FCM HTTP v1 (SPEC §10.4, §10.7). KVITT_FCM_PROJECT_ID=kvitt-1337 +# Enable Banking application ids for card purchases (SPEC §10.4, D-175). Empty: card features off. The keys are +# ${KVITT_SECRETS_DIR}/cards/enable-banking.pem (and enable-banking-member.pem), staged by up.sh. The application's +# registered redirect is the Pi's https address, so the real BankID flow cannot complete against this stack. +KVITT_EB_APP_ID= +KVITT_EB_MEMBER_APP_ID= + # tracing filter (SPEC §10.4). RUST_LOG=info diff --git a/infra/dev/compose.yaml b/infra/dev/compose.yaml index 24f8e07..adbf451 100644 --- a/infra/dev/compose.yaml +++ b/infra/dev/compose.yaml @@ -41,12 +41,17 @@ services: KVITT_PUBLIC_URL: ${KVITT_PUBLIC_URL:-http://localhost:8080} KVITT_FCM_CREDENTIALS: /secrets/fcm-service-account.json KVITT_FCM_PROJECT_ID: ${KVITT_FCM_PROJECT_ID:-kvitt-1337} + # Card purchases (SPEC §10.4, D-175): empty = card features off. up.sh stages the keys of + # ${KVITT_SECRETS_DIR}/cards/ into infra/dev/secrets/cards/, read by the KVITT_EB_KEY_FILE defaults. + KVITT_EB_APP_ID: ${KVITT_EB_APP_ID:-} + KVITT_EB_MEMBER_APP_ID: ${KVITT_EB_MEMBER_APP_ID:-} RUST_LOG: ${RUST_LOG:-info} volumes: # SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored). - ${KVITT_DEV_DATA_DIR:-./data}:/data - # Only the FCM key, read-only (D-117): up.sh copies ${KVITT_SECRETS_DIR}/fcm-service-account.json into - # infra/dev/secrets/ (git-ignored), which holds nothing else. A folder is mounted rather than the file, because + # Only the FCM key and the card keys, read-only (D-117, D-175): up.sh copies + # ${KVITT_SECRETS_DIR}/fcm-service-account.json and ${KVITT_SECRETS_DIR}/cards/ into infra/dev/secrets/ + # (git-ignored), which holds nothing else. A folder is mounted rather than the file, because # a missing source file would make Docker create a directory in its place, while a missing key must simply # leave push disabled with a start-up warning (§10.4); without the key the folder is empty. - ./secrets:/secrets:ro diff --git a/infra/dev/lib.sh b/infra/dev/lib.sh index 4e15fcd..33556f8 100644 --- a/infra/dev/lib.sh +++ b/infra/dev/lib.sh @@ -36,14 +36,14 @@ lan_address() { [[ -n "$iface" ]] && ipconfig getifaddr "$iface" 2>/dev/null } -# Fill infra/dev/secrets/ (git-ignored, mounted read-only at /secrets) with only the FCM key from -# KVITT_SECRETS_DIR, so the server container sees no other secret (D-117). Without the key the folder is left -# empty and the server starts with push disabled (§10.4). +# Fill infra/dev/secrets/ (git-ignored, mounted read-only at /secrets) with only the FCM key and the card keys +# folder from KVITT_SECRETS_DIR, so the server container sees no other secret (D-117, D-175). Without the FCM key +# the server starts with push disabled (§10.4); see stage_card_keys for the card keys. stage_fcm_key() { local src stage="$dev_dir/secrets" src="$(dev_path "$(env_value KVITT_SECRETS_DIR ../secrets)")/fcm-service-account.json" install -d -m 0700 "$stage" - find "$stage" -mindepth 1 -maxdepth 1 ! -name fcm-service-account.json -exec rm -rf {} + + find "$stage" -mindepth 1 -maxdepth 1 ! -name fcm-service-account.json ! -name cards -exec rm -rf {} + if [[ -f "$src" ]]; then install -m 0600 "$src" "$stage/fcm-service-account.json" else @@ -51,3 +51,20 @@ stage_fcm_key() { echo "$(basename "$0"): no $src; the server starts with push disabled." >&2 fi } + +# Copy the Enable Banking keys of KVITT_SECRETS_DIR/cards/ (only *.pem) into infra/dev/secrets/cards/ (0700, keys +# 0600), replacing what was there. Without them card features are off, with one warning at start (§10.4, D-175). +stage_card_keys() { + local src stage="$dev_dir/secrets/cards" key + src="$(dev_path "$(env_value KVITT_SECRETS_DIR ../secrets)")/cards" + rm -rf "$stage" + install -d -m 0700 "$stage" + if [[ -d "$src" ]]; then + for key in "$src"/*.pem; do + [[ -f "$key" ]] && install -m 0600 "$key" "$stage/$(basename "$key")" + done + fi + if [[ -z "$(ls -A "$stage")" ]]; then + echo "$(basename "$0"): no keys in $src; the server starts with card features off." >&2 + fi +} diff --git a/infra/dev/up.sh b/infra/dev/up.sh index 03f373d..a013fd3 100755 --- a/infra/dev/up.sh +++ b/infra/dev/up.sh @@ -23,6 +23,7 @@ export KVITT_PUBLIC_URL="$public_url" data_dir="$(dev_path "$(env_value KVITT_DEV_DATA_DIR ./data)")" mkdir -p "$data_dir" stage_fcm_key +stage_card_keys compose up -d server diff --git a/infra/npm/kvitt.conf b/infra/npm/kvitt.conf index 1107e90..b5eedf1 100644 --- a/infra/npm/kvitt.conf +++ b/infra/npm/kvitt.conf @@ -13,6 +13,8 @@ if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; } # and /apk/, which stay LAN-or-key only. if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; } if ($uri = "/invites/accept") { set $kvitt_gate 1; } +# Card connection (SPEC §9.12, D-163): the bank redirects the user's browser here, without the app key. +if ($uri = "/cards/callback") { set $kvitt_gate 1; } set $kvitt_bearer 0; if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; } if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; } @@ -58,3 +60,14 @@ location ^~ /i/ { proxy_set_header X-Real-IP $remote_addr; proxy_pass $forward_scheme://$server:$port$request_uri; } +# Card connection callback /cards/callback?code=…&state=… (SPEC §9.12, D-163): the query carries a one-time +# authorisation code and the attempt's state, so this path is not logged at all, a 502 included. +location = /cards/callback { + access_log off; + error_log /dev/null crit; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass $forward_scheme://$server:$port$request_uri; +} diff --git a/infra/pi/.env.example b/infra/pi/.env.example index ce279f7..7fb3908 100644 --- a/infra/pi/.env.example +++ b/infra/pi/.env.example @@ -13,5 +13,11 @@ HA_HOST=CHANGE_ME KVITT_UID=1000 # compose.yaml: gid both containers run as, `id -g` of the same user. KVITT_GID=1000 +# compose.yaml: the server's KVITT_EB_APP_ID, the Enable Banking application id for card purchases (SPEC §10.4, +# D-175): the key file's name before .pem. Empty: card features off. The key goes to secrets/cards/enable-banking.pem. +KVITT_EB_APP_ID= +# compose.yaml: the server's KVITT_EB_MEMBER_APP_ID, only for a member's own application (CP-13 fallback); empty: the +# member uses KVITT_EB_APP_ID. Its key goes to secrets/cards/enable-banking-member.pem. +KVITT_EB_MEMBER_APP_ID= # compose.yaml: the backup's Google Drive folder id (OQ-4). RCLONE_FOLDER_ID=1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV diff --git a/infra/pi/compose.yaml b/infra/pi/compose.yaml index 99c2efd..840593d 100644 --- a/infra/pi/compose.yaml +++ b/infra/pi/compose.yaml @@ -37,8 +37,13 @@ services: # NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry # is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IPv4 address, not a host name. KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env} + # Card purchases (SPEC §9.12, §10.4, D-175): the Enable Banking application ids; empty = card features off, + # with one warning at start. The keys are secrets/cards/enable-banking.pem (and enable-banking-member.pem for + # a member's own application), the KVITT_EB_KEY_FILE and KVITT_EB_MEMBER_KEY_FILE defaults. + KVITT_EB_APP_ID: ${KVITT_EB_APP_ID:-} + KVITT_EB_MEMBER_APP_ID: ${KVITT_EB_MEMBER_APP_ID:-} # The other server settings keep their SPEC §10.4 defaults (KVITT_FCM_CREDENTIALS - # /secrets/fcm-service-account.json, KVITT_FCM_PROJECT_ID kvitt-1337, ...). + # /secrets/fcm-service-account.json, KVITT_FCM_PROJECT_ID kvitt-1337, KVITT_EB_API_URL, ...). volumes: # SQLite database, pictures and APKs (data/apk/). - ./data:/data @@ -50,6 +55,14 @@ services: read_only: true bind: create_host_path: false + # The Enable Banking private keys (D-175), read-only; the folder is 0700 and each key 0600, owned by the owner. + # create_host_path false: a missing folder stops the start instead of Docker creating a root-owned one. + - type: bind + source: ./secrets/cards + target: /secrets/cards + read_only: true + bind: + create_host_path: false # Empty except during the one-off Splitwise import (SPEC §10.8, D-025). - ./import:/import:ro healthcheck: diff --git a/server/README.md b/server/README.md index 2420ba9..28bca01 100644 --- a/server/README.md +++ b/server/README.md @@ -35,13 +35,19 @@ Configuration is environment variables only (SPEC §10.4, exhaustive): | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. | | `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. | | `KVITT_WEB_DIR` | `/app` | The built web client, served under `/app/` (D-132): `index.html` for app routes, `assets/*` cached for a year, the app's own CSP. Missing directory: every `/app` path is 404 `not_found` (as with `cargo run`, unless you point it at `web/dist`). | +| `KVITT_EB_APP_ID` | empty | Enable Banking application id for card purchases (SPEC §10.4, D-175): the admin's, and the member's unless the next is set. Empty, or its key missing or unusable: card features off for that user (one warning at start). | +| `KVITT_EB_KEY_FILE` | `/secrets/cards/enable-banking.pem` | The PEM private key of `KVITT_EB_APP_ID`; never logged, never in `data/`. | +| `KVITT_EB_MEMBER_APP_ID` | empty | The member's own application (CP-13 fallback). | +| `KVITT_EB_MEMBER_KEY_FILE` | `/secrets/cards/enable-banking-member.pem` | Its key. | +| `KVITT_EB_API_URL` | `https://api.enablebanking.com` | Card API base URL; the tests point it at a local stub. | | `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. | The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR` (for example `./data`, which `server/.gitignore` keeps out of git); the database is then `$KVITT_DATA_DIR/kvitt.sqlite`. Every connection uses WAL, `foreign_keys = ON`, `synchronous = NORMAL`, `busy_timeout = 5000` (SPEC §8.9). `serve` and `migrate` run the embedded migrations -(`crates/kvitt-server/migrations/`, `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`). +(`crates/kvitt-server/migrations/`, `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, 0002 to 0004 to +their copies in `contracts/schema/migrations/`; schema version 4). ## Subcommands @@ -79,6 +85,14 @@ Building blocks for handler authors (`src/api/mod.rs`, `src/auth.rs`, `src/error * `AppState.clock` (`time::Clock`; tests use `FixedClock`), `time::format_ts` for the fixed RFC 3339 form, `time::STOCKHOLM`. +Card purchases (SPEC §9.12, E-36 to E-44) live in `src/cards/`: the registry (`providers.rs`), the `CardSource` +trait with Kvitt's own records (`mod.rs`) and its one adapter `enable_banking/` (the only code naming the vendor or +reading its JSON: JWT, `/aspsps`, `/auth`, `/sessions`, transactions, the CP-7 mapping), the windowed fetch and CP-8 +matching (`fetch.rs`), the background poller (`poller.rs`, started by `tasks::start`), the callback page +(`callback.rs`, E-39) and the other endpoints (`api.rs`). Card rows take `card_version` from `card_sequence`, never +`server_version`, and are not audited or pushed; `cards::unlink_expense` is called by sync push step 7. `AppState.cards` +is disabled until `tasks::start` installs the configured sources. + The OpenAPI document is generated with utoipa from the `#[utoipa::path]` annotations and the wire types in `src/wire/`; `src/openapi.rs` lists the handlers and applies the document-wide rules. A handler change that alters the wire contract makes `tests/openapi_contract.rs` fail against `contracts/openapi.yaml`. @@ -93,7 +107,8 @@ the wire contract makes `tests/openapi_contract.rs` fail against `contracts/open * **Background tasks:** `src/tasks/mod.rs` has `pub fn start(state: AppState) -> AppState`, called once by `serve` after migrations; it installs the push queue in the returned state (the router is built from it) and spawns the FCM sender, the recurring scheduler (at start and daily 06:00 Europe/Stockholm, through - `generate_all_due`) and the `sync_mutations` pruning (daily 04:00). The scheduler loop (`tasks/scheduler.rs`) reads + `generate_all_due`), the `sync_mutations` and `card_auth_attempts` pruning (daily 04:00) and the card poller + (06:45, 11:45, 16:45, 21:45, SPEC §9.12 CP-4). The scheduler loop (`tasks/scheduler.rs`) reads the injectable `AppState.clock` and re-checks it at least every 30 s, so clock jumps are handled. * **Audit (`src/audit/mod.rs`):** @@ -134,8 +149,8 @@ The pre-commit hook (`tools/git-hooks/pre-commit`) runs `cargo audit --no-fetch` `Cargo.toml` under `server/` is staged, so ordinary commits stay fast. When `cargo-audit` is not installed or the database was never fetched it prints a message and skips the check; it never fails a commit for a missing tool. -* `crates/kvitt-domain/tests/vectors.rs`: every case of every file in `contracts/test-vectors/` (the two - Kotlin-only analytics files are parsed and skipped). +* `crates/kvitt-domain/tests/vectors.rs`: every case of every file in `contracts/test-vectors/` (the Kotlin-only + files, and `match_card_purchase.json` of the phone and the web, are parsed and skipped). * `crates/kvitt-server/tests/openapi_contract.rs`: the generated document equals `contracts/openapi.yaml` after the normalisations documented at the top of the file, plus self-tests that the comparison catches changes. * `crates/kvitt-server/tests/skeleton/`: migration identity and pragmas, every route registered and implemented, E-1, E-2, @@ -152,6 +167,10 @@ database was never fetched it prints a message and skips the check; it never fai (an RSA key is generated per test; none is committed). * `crates/kvitt-server/tests/scheduler_daily/`: 06:00 and 04:00 across both DST changes, run at start, clock jumps, catch-up through `generate_all_due`, paused and deleted templates skipped, 90-day pruning. +* `crates/kvitt-server/tests/cards_api/`: card purchases against a local Enable Banking stub (RSA key generated per + test): the connect flow and the callback page, the windowed fetch (splits, truncation, de-duplication, continuation, + end of history), the mapping and the fields never stored, CP-8 matching and deletion, CP-14 errors, the poller with + a scripted clock, E-36 to E-44 with their authorization, link clearing on expense delete, E-39's rate limit, logs. * `crates/kvitt-import/tests/import.rs`: both header variants, split and settlement rules, `--fx`, the import key and idempotent re-runs, reconciliation and its exit codes, dry run, the CLI, and the §10.8 category table against `data/splitwise_categories.csv`; synthetic fixtures in `crates/kvitt-import/tests/fixtures/` only. diff --git a/server/crates/kvitt-domain/tests/vectors.rs b/server/crates/kvitt-domain/tests/vectors.rs index bacd9c5..95fb3c6 100644 --- a/server/crates/kvitt-domain/tests/vectors.rs +++ b/server/crates/kvitt-domain/tests/vectors.rs @@ -16,7 +16,13 @@ use serde::de::DeserializeOwned; use serde_json::{Value, json}; /// Functions that exist only in Kotlin (SPEC §14.5.4, the category suggestion); their files are parsed but not run here. -const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range", "suggest_category"]; +// `match_card_purchase` is Kotlin and web only (SPEC §14.5.8, D-170): the server never suggests matches. +const KOTLIN_ONLY: &[&str] = &[ + "spend_series", + "resolve_range", + "suggest_category", + "match_card_purchase", +]; /// Every Rust function of SPEC §14.5.2; each must have a vector file. const RUST_FUNCTIONS: &[&str] = &[ "compute_split", diff --git a/server/crates/kvitt-server/migrations/0004_card_purchases.sql b/server/crates/kvitt-server/migrations/0004_card_purchases.sql new file mode 100644 index 0000000..702f67d --- /dev/null +++ b/server/crates/kvitt-server/migrations/0004_card_purchases.sql @@ -0,0 +1,83 @@ +-- Kvitt server schema v4: card purchases (SPEC §9.12, FR-22, D-167). Do not edit; add a new migration and bump the +-- schema version (SPEC §14.8). Applied on top of 0003_device_version_name.sql. +-- +-- Card data is private to the card holder (D-161): none of these rows is in the household sync stream (GET /sync), the +-- audit log or a push. Card rows carry card_version from their own sequence and are read through GET /cards/sync. + +CREATE TABLE card_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + value INTEGER NOT NULL +) STRICT; +INSERT INTO card_sequence (id, value) VALUES (1, 0); + +-- One connection per user and card provider. session_id is an Enable Banking session: a credential usable only with the +-- application's private key, which is never in the database or a backup (D-161). Erased when disconnected. +CREATE TABLE card_connections ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + provider_id TEXT NOT NULL CHECK (length(provider_id) BETWEEN 1 AND 64), + status TEXT NOT NULL CHECK (status IN ('active', 'expired', 'disconnected')), + session_id TEXT, + account_uid TEXT, + valid_until TEXT, + history_days INTEGER NOT NULL CHECK (history_days BETWEEN 1 AND 365), + last_fetch_at TEXT, + last_fetch_error TEXT, + last_user_fetch_at TEXT, + background_fetches TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + card_version INTEGER NOT NULL UNIQUE, + CHECK ((status = 'disconnected') = (session_id IS NULL)) +) STRICT; +CREATE UNIQUE INDEX card_connections_user_provider ON card_connections (user_id, provider_id); + +-- Single-use authorisation attempts (D-162); state is stored only as its SHA-256 hash. Pruned 7 days after creation. +CREATE TABLE card_auth_attempts ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + device_id TEXT NOT NULL REFERENCES devices (id), + provider_id TEXT NOT NULL CHECK (length(provider_id) BETWEEN 1 AND 64), + client TEXT NOT NULL CHECK (client IN ('android', 'web')), + state_hash TEXT NOT NULL UNIQUE, + history_days INTEGER NOT NULL CHECK (history_days BETWEEN 1 AND 365), + status TEXT NOT NULL CHECK (status IN ('pending', 'connected', 'failed', 'expired')), + failure TEXT, + connection_id TEXT REFERENCES card_connections (id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + finished_at TEXT +) STRICT; +CREATE INDEX card_auth_attempts_created ON card_auth_attempts (created_at); + +-- Purchases as the bank reports them, mapped per SPEC §9.12 CP-7. amount_ore is null only for a non-SEK purchase +-- (original_amount and original_currency then set) and for a tombstone. A tombstone has its content erased (D-167). +CREATE TABLE card_purchases ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + connection_id TEXT NOT NULL REFERENCES card_connections (id), + provider_ref TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'booked')), + direction TEXT NOT NULL CHECK (direction IN ('debit', 'credit')), + purchase_date TEXT NOT NULL, + booking_date TEXT, + amount_ore INTEGER CHECK (amount_ore IS NULL OR amount_ore >= 1), + original_amount TEXT, + original_currency TEXT CHECK (original_currency IS NULL OR length(original_currency) = 3), + merchant TEXT NOT NULL CHECK (length(merchant) <= 140), + expense_id TEXT REFERENCES expenses (id), + ignored INTEGER NOT NULL DEFAULT 0 CHECK (ignored IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + card_version INTEGER NOT NULL UNIQUE, + CHECK (NOT (ignored = 1 AND expense_id IS NOT NULL)), + CHECK (deleted_at IS NOT NULL OR (amount_ore IS NULL) = (original_currency IS NOT NULL)) +) STRICT; +CREATE UNIQUE INDEX card_purchases_ref ON card_purchases (connection_id, provider_ref) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX card_purchases_expense_once ON card_purchases (user_id, expense_id) + WHERE expense_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX card_purchases_user_version ON card_purchases (user_id, card_version); +CREATE INDEX card_purchases_expense ON card_purchases (expense_id); + +UPDATE meta SET value = '4' WHERE key = 'schema_version'; diff --git a/server/crates/kvitt-server/src/api/rate_limit.rs b/server/crates/kvitt-server/src/api/rate_limit.rs index 826f293..536a8f7 100644 --- a/server/crates/kvitt-server/src/api/rate_limit.rs +++ b/server/crates/kvitt-server/src/api/rate_limit.rs @@ -64,9 +64,14 @@ pub const PROXY_RESOLVE_INTERVAL: std::time::Duration = std::time::Duration::fro /// How often expired windows are pruned from memory. const PRUNE_INTERVAL: Duration = Duration::minutes(10); -/// Route templates of the unauthenticated endpoints (E-1 to E-6) and of the web client (D-132), which count against -/// the client IP even when a request carries a bearer token. -const PUBLIC_ROUTES: [&str; 9] = [ +/// The client IP of a request as the limiter determined it (SPEC §8.5, §8.6), in the request extensions for the +/// handlers that need it (the card PSU headers, CP-5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientIp(pub IpAddr); + +/// Route templates of the unauthenticated endpoints (E-1 to E-6, E-39) and of the web client (D-132), which count +/// against the client IP even when a request carries a bearer token. +const PUBLIC_ROUTES: [&str; 10] = [ "/health", "/setup/status", "/invites/accept", @@ -76,6 +81,8 @@ const PUBLIC_ROUTES: [&str; 9] = [ "/app", "/app/", "/app/{*path}", + // E-39, the bank's redirect (SPEC §8.6, D-162). + "/cards/callback", ]; const ACCEPT_ROUTE: &str = "/invites/accept"; /// Route template of E-6, limited by [`APK_DOWNLOAD_LIMIT`] instead of [`UNAUTHENTICATED_LIMIT`]. @@ -363,7 +370,7 @@ impl Limiter { } } - async fn handle(self: Arc, request: Request, mut inner: S) -> Response + async fn handle(self: Arc, mut request: Request, mut inner: S) -> Response where S: Service, { @@ -376,6 +383,7 @@ impl Limiter { let (peer, forwarded) = Self::peer_and_forwarded(&request); let ip = self.client_ip(peer, forwarded).await; let key = Self::key(&request, &route, ip); + request.extensions_mut().insert(ClientIp(ip)); let admitted = match self.admit(&key, accept, self.clock.now()) { Ok(admitted) => admitted, Err(retry_after) => return AppError::rate_limited(retry_after).into_response(), diff --git a/server/crates/kvitt-server/src/cards/api.rs b/server/crates/kvitt-server/src/cards/api.rs new file mode 100644 index 0000000..0cc6d5b --- /dev/null +++ b/server/crates/kvitt-server/src/cards/api.rs @@ -0,0 +1,662 @@ +//! Card endpoints E-36 to E-38 and E-40 to E-44 (SPEC §14.3.4, §9.12; E-39 is `callback.rs`). Every one acts only +//! on the caller's own rows: another user's connection, purchase or attempt is `not_found`, the same as an unknown +//! id (§8.4, CP-11). Card writes take `card_version` from `card_sequence` and never touch `server_version`; nothing +//! here is audited or pushed. + +use std::collections::HashMap; + +use axum::Extension; +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use base64::Engine; +use chrono::Duration; +use sqlx::{Row, SqliteConnection}; +use uuid::Uuid; + +use super::fetch::{self, FetchKind}; +use super::providers::{self, PROVIDERS}; +use super::{ + AuthRequest, ConnectionRow, PsuContext, card_stream, connection, current_card_version, + expire_if_ended, next_card_version, own_connection, own_purchase, purchase, tombstone_purchase, +}; +use crate::AppState; +use crate::api::rate_limit::ClientIp; +use crate::api::{ApiJson, IdPath, JsonBody}; +use crate::auth::{AuthDevice, sha256_hex}; +use crate::db; +use crate::error::{AppError, ErrorCode}; +use crate::openapi::responses::{BadGateway, BadRequest, Conflict, NotFound, Unprocessable}; +use crate::sync::pull::page_size; +use crate::time::{format_ts, parse_ts}; +use crate::wire::{ + CardAttempt, CardAttemptStatus, CardConnection, CardConnectionStatus, CardDirection, + CardProvider, CardProviderList, CardPurchase, CardRefreshResponse, CardSyncResponse, + CreateCardAuthRequest, PatchCardConnectionRequest, PutCardPurchaseRequest, SyncLimitSchema, + UuidSchema, +}; + +/// The history choices of CP-3 and §9.6. +pub const HISTORY_CHOICES: [i64; 4] = [7, 30, 90, 180]; +/// The longest consent Kvitt asks for (CP-6: 180 days, the RTS's renewal interval). +pub const CONSENT_MAX: Duration = Duration::days(180); +/// Margin below the bank's maximum consent (CP-6). +pub const CONSENT_MARGIN: Duration = Duration::minutes(5); +/// How long an authorisation attempt stays pending (CP-2). +pub const ATTEMPT_LIFETIME: Duration = Duration::minutes(15); +/// E-42 contacts the bank at most once per connection in this time (CP-5, §8.6). +pub const REFRESH_INTERVAL: Duration = Duration::seconds(60); + +fn bank_unavailable() -> AppError { + AppError::new( + ErrorCode::CardBankUnavailable, + "the card service or the bank did not answer or refused", + ) +} + +fn connection_wire(state: &AppState, row: &ConnectionRow) -> CardConnection { + row.wire(state.cards.is_fetching(row.id)) +} + +async fn reload_connection( + conn: &mut SqliteConnection, + state: &AppState, + id: Uuid, +) -> Result { + let row = connection(conn, id) + .await? + .ok_or_else(|| AppError::internal("card connection vanished"))?; + Ok(connection_wire(state, &row)) +} + +#[utoipa::path( + get, + path = "/cards/providers", + operation_id = "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).", + responses( + (status = 200, description = "The catalogue.", body = CardProviderList, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn list_card_providers( + device: AuthDevice, + State(state): State, +) -> JsonBody { + JsonBody(CardProviderList { + providers: PROVIDERS + .iter() + .map(|p| CardProvider { + id: p.id.to_owned(), + name: p.name.to_owned(), + issuer: p.issuer.to_owned(), + image_key: p.image_key.to_owned(), + available: state.cards.source_for(p, device.role).is_some(), + }) + .collect(), + }) +} + +fn random_state() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|e| AppError::internal(format!("OS random source failed: {e}")))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +#[utoipa::path( + post, + path = "/cards/auth", + operation_id = "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.", + request_body(content = CreateCardAuthRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 201, description = "The pending attempt with `auth_url`.", body = CardAttempt, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + (status = 502, response = BadGateway), + ) +)] +pub async fn create_card_auth( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result<(StatusCode, JsonBody), AppError> { + let provider = providers::find(&body.provider_id).ok_or_else(AppError::not_found)?; + if !HISTORY_CHOICES.contains(&body.history_days) { + return Err(AppError::validation( + "history_days", + "history_days must be 7, 30, 90 or 180", + )); + } + let source = state + .cards + .source_for(provider, device.role) + .ok_or_else(|| { + AppError::new( + ErrorCode::CardsUnavailable, + "the server has no card application for this user", + ) + })?; + let aspsp = source.aspsp(provider).await.map_err(|e| { + tracing::warn!(detail = %e.detail, "card bank details unavailable"); + bank_unavailable() + })?; + let now = state.clock.now(); + let consent = aspsp + .max_consent_secs + .map_or(CONSENT_MAX, |secs| CONSENT_MAX.min(Duration::seconds(secs))); + let token = random_state()?; + let redirect_url = format!("{}/cards/callback", state.config.public_url); + let auth_url = source + .start_auth(AuthRequest { + provider, + state: &token, + redirect_url: &redirect_url, + valid_until: now + consent - CONSENT_MARGIN, + }) + .await + .map_err(|e| { + tracing::warn!(detail = %e.detail, "card authorisation could not be started"); + bank_unavailable() + })?; + + let id = Uuid::now_v7(); + let now_ts = format_ts(now); + let expires_at = format_ts(now + ATTEMPT_LIFETIME); + let mut tx = db::begin_immediate(&state.db).await?; + sqlx::query( + "UPDATE card_auth_attempts SET status = 'expired', finished_at = ? WHERE user_id = ? AND provider_id = ? \ + AND status = 'pending'", + ) + .bind(&now_ts) + .bind(device.user_id.to_string()) + .bind(provider.id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO card_auth_attempts (id, user_id, device_id, provider_id, client, state_hash, history_days, \ + status, failure, connection_id, created_at, expires_at, finished_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, NULL)", + ) + .bind(id.to_string()) + .bind(device.user_id.to_string()) + .bind(device.device_id.to_string()) + .bind(provider.id) + .bind(body.client.as_str()) + .bind(sha256_hex(&token)) + .bind(body.history_days) + .bind(&now_ts) + .bind(&expires_at) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(( + StatusCode::CREATED, + JsonBody(CardAttempt { + id, + provider_id: provider.id.to_owned(), + status: CardAttemptStatus::Pending, + failure: None, + auth_url: Some(auth_url), + expires_at, + connection: None, + }), + )) +} + +#[utoipa::path( + get, + path = "/cards/auth/{attempt_id}", + operation_id = "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.", + params( + ("attempt_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The attempt.", body = CardAttempt, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn get_card_auth( + device: AuthDevice, + State(state): State, + IdPath(attempt_id): IdPath, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let row = sqlx::query( + "SELECT provider_id, status, failure, connection_id, expires_at FROM card_auth_attempts \ + WHERE id = ? AND user_id = ?", + ) + .bind(attempt_id.to_string()) + .bind(device.user_id.to_string()) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(AppError::not_found)?; + let status_text: String = row.try_get("status")?; + let mut status = CardAttemptStatus::from_db(&status_text) + .ok_or_else(|| AppError::internal("unknown attempt status"))?; + let expires_at: String = row.try_get("expires_at")?; + let now = state.clock.now(); + if status == CardAttemptStatus::Pending && parse_ts(&expires_at).is_none_or(|t| t <= now) { + sqlx::query( + "UPDATE card_auth_attempts SET status = 'expired', finished_at = COALESCE(finished_at, ?) WHERE id = ?", + ) + .bind(format_ts(now)) + .bind(attempt_id.to_string()) + .execute(&mut *tx) + .await?; + status = CardAttemptStatus::Expired; + } + let connection_id: Option = row.try_get("connection_id")?; + let connection = match (status, connection_id) { + (CardAttemptStatus::Connected, Some(id)) => { + Some(reload_connection(&mut tx, &state, crate::api::rows::uuid(&id)?).await?) + } + _ => None, + }; + let failure: Option = row.try_get("failure")?; + let provider_id: String = row.try_get("provider_id")?; + tx.commit().await?; + Ok(JsonBody(CardAttempt { + id: attempt_id, + provider_id, + status, + failure: if status == CardAttemptStatus::Failed { + failure + } else { + None + }, + auth_url: None, + expires_at, + connection, + })) +} + +#[utoipa::path( + get, + path = "/cards/sync", + operation_id = "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).", + params( + ("since" = i64, Query, description = "The highest `card_version` the client has applied (0 initially)."), + ("limit" = Option, Query, description = "Rows per page, 1 to 1000 (`invalid_request` otherwise)."), + ), + responses( + (status = 200, description = "One page.", body = CardSyncResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + ) +)] +pub async fn card_sync( + device: AuthDevice, + State(state): State, + Query(query): Query>, +) -> Result, AppError> { + let int = |key: &str| -> Result, AppError> { + query + .get(key) + .map(|v| { + v.parse::().map_err(|_| { + AppError::invalid_request(format!("{key} must be an integer")).with_field(key) + }) + }) + .transpose() + }; + let since = int("since")? + .ok_or_else(|| AppError::invalid_request("since is required").with_field("since"))?; + if since < 0 { + return Err(AppError::invalid_request("since must be 0 or more").with_field("since")); + } + let limit = page_size(int("limit")?)?; + + let mut tx = state.db.begin().await?; + if since > current_card_version(&mut tx).await? { + return Err(AppError::new( + ErrorCode::CursorAhead, + "since is greater than the server's current card sequence value", + ) + .with_field("since")); + } + let mut stream = card_stream(&mut tx, device.user_id, since, limit + 1).await?; + let has_more = i64::try_from(stream.len()).map_err(AppError::internal)? > limit; + stream.truncate(usize::try_from(limit).map_err(AppError::internal)?); + let next_since = stream.last().map_or(since, |(v, _, _)| *v); + let mut page = CardSyncResponse { + since, + next_since, + has_more, + server_time: state.clock.now_ts(), + connections: Vec::new(), + purchases: Vec::new(), + }; + for (_, is_connection, id) in stream { + let id = crate::api::rows::uuid(&id)?; + if is_connection { + page.connections + .push(reload_connection(&mut tx, &state, id).await?); + } else { + page.purchases.push( + purchase(&mut tx, id) + .await? + .ok_or_else(|| AppError::internal("card purchase vanished during pull"))? + .wire, + ); + } + } + tx.commit().await?; + Ok(JsonBody(page)) +} + +#[utoipa::path( + patch, + path = "/cards/connections/{connection_id}", + operation_id = "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.", + params( + ("connection_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + request_body(content = PatchCardConnectionRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The connection.", body = CardConnection, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 422, response = Unprocessable), + ) +)] +pub async fn patch_card_connection( + device: AuthDevice, + State(state): State, + IdPath(connection_id): IdPath, + ApiJson(body): ApiJson, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let row = own_connection(&mut tx, connection_id, device.user_id) + .await? + .ok_or_else(AppError::not_found)?; + if !HISTORY_CHOICES.contains(&body.history_days) || body.history_days > row.history_days { + return Err(AppError::validation( + "history_days", + "history_days must be 7, 30, 90 or 180 and at most the current value", + )); + } + if body.history_days != row.history_days { + let version = next_card_version(&mut tx).await?; + sqlx::query( + "UPDATE card_connections SET history_days = ?, updated_at = ?, card_version = ? WHERE id = ?", + ) + .bind(body.history_days) + .bind(state.clock.now_ts()) + .bind(version) + .bind(connection_id.to_string()) + .execute(&mut *tx) + .await?; + } + let wire = reload_connection(&mut tx, &state, connection_id).await?; + tx.commit().await?; + Ok(JsonBody(wire)) +} + +#[utoipa::path( + post, + path = "/cards/connections/{connection_id}/refresh", + operation_id = "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.", + params( + ("connection_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The connection after the fetch.", body = CardRefreshResponse, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + ) +)] +pub async fn refresh_card_connection( + device: AuthDevice, + State(state): State, + IdPath(connection_id): IdPath, + headers: HeaderMap, + client_ip: Option>, +) -> Result, AppError> { + let now = state.clock.now(); + let consent_expired = || { + AppError::new( + ErrorCode::CardConsentExpired, + "the card connection has expired or is disconnected", + ) + }; + let row = { + let mut tx = db::begin_immediate(&state.db).await?; + let row = own_connection(&mut tx, connection_id, device.user_id) + .await? + .ok_or_else(AppError::not_found)?; + if expire_if_ended(&mut tx, &row, now).await? { + tx.commit().await?; + return Err(consent_expired()); + } + tx.commit().await?; + row + }; + if row.status != CardConnectionStatus::Active { + return Err(consent_expired()); + } + let recent = row + .last_user_fetch_at + .as_deref() + .and_then(parse_ts) + .is_some_and(|t| now - t < REFRESH_INTERVAL); + let guard = if recent { + None + } else { + state.cards.try_begin(connection_id) + }; + let fetched = match guard { + None => false, + Some(guard) => { + let psu = + PsuContext::from_request(&headers, client_ip.map(|Extension(ClientIp(ip))| ip)); + let task_state = state.clone(); + let task = tokio::spawn(async move { + fetch::run(&task_state, guard, FetchKind::User(psu), false).await + }); + match tokio::time::timeout(state.cards.refresh_wait, task).await { + Ok(Ok(Ok(_))) => true, + Ok(Ok(Err(e))) => return Err(e), + Ok(Err(e)) => return Err(AppError::internal(format!("card fetch task: {e}"))), + // Still running: it completes in the background (CP-5). + Err(_) => false, + } + } + }; + let mut conn = state.db.acquire().await?; + let connection = reload_connection(&mut conn, &state, connection_id).await?; + Ok(JsonBody(CardRefreshResponse { + connection, + fetched, + })) +} + +fn erase_handled(query: &HashMap) -> Result { + match query.get("erase_handled").map(String::as_str) { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + Some(_) => Err( + AppError::invalid_request("erase_handled must be true or false") + .with_field("erase_handled"), + ), + } +} + +#[utoipa::path( + delete, + path = "/cards/connections/{connection_id}", + operation_id = "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.", + params( + ("connection_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ("erase_handled" = Option, Query, description = "Also delete linked and ignored purchases. Default false."), + ), + responses( + (status = 200, description = "The disconnected connection.", body = CardConnection, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + ) +)] +pub async fn delete_card_connection( + device: AuthDevice, + State(state): State, + IdPath(connection_id): IdPath, + Query(query): Query>, +) -> Result, AppError> { + let erase = erase_handled(&query)?; + let now_ts = state.clock.now_ts(); + let mut tx = db::begin_immediate(&state.db).await?; + let row = own_connection(&mut tx, connection_id, device.user_id) + .await? + .ok_or_else(AppError::not_found)?; + let doomed: Vec = sqlx::query_scalar( + "SELECT id FROM card_purchases WHERE connection_id = ? AND deleted_at IS NULL \ + AND (? OR (expense_id IS NULL AND ignored = 0)) ORDER BY id", + ) + .bind(connection_id.to_string()) + .bind(erase) + .fetch_all(&mut *tx) + .await?; + for id in &doomed { + tombstone_purchase(&mut tx, crate::api::rows::uuid(id)?, &now_ts).await?; + } + let session = row.session_id.clone(); + if row.status != CardConnectionStatus::Disconnected { + let version = next_card_version(&mut tx).await?; + sqlx::query( + "UPDATE card_connections SET status = 'disconnected', session_id = NULL, account_uid = NULL, \ + valid_until = NULL, last_fetch_at = NULL, last_fetch_error = NULL, last_user_fetch_at = NULL, \ + background_fetches = '[]', updated_at = ?, card_version = ? WHERE id = ?", + ) + .bind(&now_ts) + .bind(version) + .bind(connection_id.to_string()) + .execute(&mut *tx) + .await?; + } + let wire = reload_connection(&mut tx, &state, connection_id).await?; + tx.commit().await?; + + // Revoke at the bank, best effort (CP-12): a failure is logged without the id and the disconnect stands. + if let Some(session) = session { + let source = + providers::find(&row.provider_id).and_then(|p| state.cards.source_for(p, device.role)); + match source { + Some(source) => { + if let Err(e) = source.delete_session(&session).await { + tracing::warn!(detail = %e.detail, "card session could not be revoked"); + } + } + None => tracing::warn!("card session not revoked: no card application"), + } + } + Ok(JsonBody(wire)) +} + +#[utoipa::path( + put, + path = "/cards/purchases/{purchase_id}", + operation_id = "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.", + params( + ("purchase_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + request_body(content = PutCardPurchaseRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The purchase.", body = CardPurchase, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn put_card_purchase( + device: AuthDevice, + State(state): State, + IdPath(purchase_id): IdPath, + ApiJson(body): ApiJson, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let stored = own_purchase(&mut tx, purchase_id, device.user_id) + .await? + .filter(|p| p.wire.deleted_at.is_none()) + .ok_or_else(AppError::not_found)?; + if body.ignored && body.expense_id.is_some() { + return Err(AppError::validation( + "ignored", + "an ignored purchase cannot be linked", + )); + } + if stored.wire.direction == CardDirection::Credit && (body.ignored || body.expense_id.is_some()) + { + let field = if body.expense_id.is_some() { + "expense_id" + } else { + "ignored" + }; + return Err(AppError::validation( + field, + "a credit purchase cannot be linked or ignored", + )); + } + if let Some(expense_id) = body.expense_id { + let live: Option> = + sqlx::query_scalar("SELECT deleted_at FROM expenses WHERE id = ?") + .bind(expense_id.to_string()) + .fetch_optional(&mut *tx) + .await?; + if !matches!(live, Some(None)) { + return Err(AppError::not_found().with_field("expense_id")); + } + let taken: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM card_purchases WHERE user_id = ? AND expense_id = ? AND id != ? \ + AND deleted_at IS NULL)", + ) + .bind(device.user_id.to_string()) + .bind(expense_id.to_string()) + .bind(purchase_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if taken { + return Err(AppError::new( + ErrorCode::ExpenseAlreadyLinked, + "the expense is linked from another card purchase", + ) + .with_field("expense_id")); + } + } + if stored.wire.expense_id == body.expense_id && stored.wire.ignored == body.ignored { + tx.commit().await?; + return Ok(JsonBody(stored.wire)); + } + let version = next_card_version(&mut tx).await?; + sqlx::query( + "UPDATE card_purchases SET expense_id = ?, ignored = ?, updated_at = ?, card_version = ? WHERE id = ?", + ) + .bind(body.expense_id.map(|id| id.to_string())) + .bind(i64::from(body.ignored)) + .bind(state.clock.now_ts()) + .bind(version) + .bind(purchase_id.to_string()) + .execute(&mut *tx) + .await?; + let wire = purchase(&mut tx, purchase_id) + .await? + .ok_or_else(|| AppError::internal("card purchase vanished"))? + .wire; + tx.commit().await?; + Ok(JsonBody(wire)) +} diff --git a/server/crates/kvitt-server/src/cards/callback.rs b/server/crates/kvitt-server/src/cards/callback.rs new file mode 100644 index 0000000..f75309a --- /dev/null +++ b/server/crates/kvitt-server/src/cards/callback.rs @@ -0,0 +1,441 @@ +//! E-39 `GET /cards/callback` (SPEC §9.12 CP-2, D-162, D-163): the bank redirects the user's browser here with +//! `code` and `state` (or `error` and `state`). The answer is always 200 HTML, for success and failure alike, with +//! `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, in the language of `Accept-Language` (copy of §13.3 +//! "Card callback page"). The page never redirects by itself; its one link leads back to the client. +//! +//! The query is never logged (the request log has the route template only); `state` is looked up only by its +//! SHA-256 hash; an attempt is single use (claimed atomically before the bank is asked for a session). + +use axum::Extension; +use axum::extract::{RawQuery, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use percent_encoding::percent_decode_str; +use sqlx::Row; +use uuid::Uuid; + +use super::fetch::{self, FetchKind}; +use super::providers; +use super::{PsuContext, connection_for_provider, next_card_version, user_role}; +use crate::AppState; +use crate::api::landing::{Lang, preferred_lang}; +use crate::api::rate_limit::ClientIp; +use crate::auth::sha256_hex; +use crate::db; +use crate::error::AppError; +use crate::time::{format_ts, parse_ts}; +use crate::wire::{CardAttemptStatus, CardClient}; + +/// The copy of SPEC §13.3 "Card callback page". +struct Copy { + html_lang: &'static str, + app_name: &'static str, + success_heading: &'static str, + success_sentence: &'static str, + failure_heading: &'static str, + failure_sentence: &'static str, + link: &'static str, +} + +const EN: Copy = Copy { + html_lang: "en", + app_name: "Kvitt", + success_heading: "Card connected", + success_sentence: "Your card purchases are being fetched. Go back to Kvitt to see them.", + failure_heading: "The card could not be connected", + failure_sentence: "Go back to Kvitt and try again.", + link: "Back to Kvitt", +}; + +const SV: Copy = Copy { + html_lang: "sv", + app_name: "Kvitt", + success_heading: "Kortet är anslutet", + success_sentence: "Dina kortköp hämtas nu. Gå tillbaka till Kvitt för att se dem.", + failure_heading: "Det gick inte att ansluta kortet", + failure_sentence: "Gå tillbaka till Kvitt och försök igen.", + link: "Tillbaka till Kvitt", +}; + +const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;\ +text-align:center;color:#1b1b1b;background:#fff}h1{font-size:2rem}a.button{display:block;margin:.75rem 0;\ +padding:.9rem;border-radius:.75rem;text-decoration:none;font-weight:600;background:#2e7d32;color:#fff}\ +@media (prefers-color-scheme:dark){body{background:#121212;color:#eee}}"; + +/// The outcome the page shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Success, + Failure, +} + +/// The link back to the client that started the attempt (CP-2). +pub fn return_link(client: CardClient, attempt_id: Uuid) -> String { + match client { + CardClient::Android => format!("kvitt://cards/connected?attempt={attempt_id}"), + CardClient::Web => format!("/app/cards?attempt={attempt_id}"), + } +} + +/// The page: app name, one heading, one sentence and, when the attempt is known, one link. No personal data. +pub fn render(lang: Lang, outcome: Outcome, link: Option<&str>) -> String { + let copy = match lang { + Lang::En => EN, + Lang::Sv => SV, + }; + let (heading, sentence) = match outcome { + Outcome::Success => (copy.success_heading, copy.success_sentence), + Outcome::Failure => (copy.failure_heading, copy.failure_sentence), + }; + // The link is built from a UUID and constants only; nothing to escape. + let link = link + .map(|href| format!("{}\n", copy.link)) + .unwrap_or_default(); + format!( + "\n\n\n\n\ +\n\n\ +{app}\n\n\n\n

{app}

\n

{heading}

\n

{sentence}

\n\ +{link}\n\n", + lang = copy.html_lang, + app = copy.app_name, + ) +} + +fn page(lang: Lang, outcome: Outcome, link: Option<&str>) -> Response { + let mut response = (StatusCode::OK, render(lang, outcome, link)).into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} + +/// `code`, `state` and `error` of the raw query (first occurrence each, `+` as space, percent-decoded as UTF-8). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, +} + +pub fn parse_query(raw: Option<&str>) -> CallbackQuery { + let mut q = CallbackQuery::default(); + for pair in raw.unwrap_or("").split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + let spaced = value.replace('+', " "); + let Ok(value) = percent_decode_str(&spaced).decode_utf8() else { + continue; + }; + let value = value.trim().to_owned(); + if value.is_empty() { + continue; + } + let slot = match key { + "code" => &mut q.code, + "state" => &mut q.state, + "error" => &mut q.error, + _ => continue, + }; + if slot.is_none() { + *slot = Some(value); + } + } + q +} + +/// The attempt a `state` names. +struct Attempt { + id: Uuid, + user_id: Uuid, + provider_id: String, + client: CardClient, + status: CardAttemptStatus, + history_days: i64, + expires_at: String, +} + +async fn find_attempt(state: &AppState, state_param: &str) -> Result, AppError> { + let row = sqlx::query( + "SELECT id, user_id, provider_id, client, status, history_days, expires_at FROM card_auth_attempts \ + WHERE state_hash = ?", + ) + .bind(sha256_hex(state_param)) + .fetch_optional(&state.db) + .await?; + let Some(row) = row else { return Ok(None) }; + let client: String = row.try_get("client")?; + let status: String = row.try_get("status")?; + Ok(Some(Attempt { + id: crate::api::rows::uuid(&row.try_get::("id")?)?, + user_id: crate::api::rows::uuid(&row.try_get::("user_id")?)?, + provider_id: row.try_get("provider_id")?, + client: CardClient::from_db(&client) + .ok_or_else(|| AppError::internal("unknown attempt client"))?, + status: CardAttemptStatus::from_db(&status) + .ok_or_else(|| AppError::internal("unknown attempt status"))?, + history_days: row.try_get("history_days")?, + expires_at: row.try_get("expires_at")?, + })) +} + +/// Ends a claimed attempt as `failed` with `failure`. +async fn fail(state: &AppState, attempt: Uuid, failure: &str) -> Result<(), AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + sqlx::query( + "UPDATE card_auth_attempts SET status = 'failed', failure = ?, finished_at = ? WHERE id = ?", + ) + .bind(failure) + .bind(state.clock.now_ts()) + .bind(attempt.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +/// What the callback did, for the page. +enum Handled { + /// Unknown, used or expired `state`: the failure page without a link. + Unknown, + Failed(String), + Connected(String), +} + +async fn handle( + state: &AppState, + query: CallbackQuery, + psu: PsuContext, +) -> Result { + let Some(state_param) = query.state else { + return Ok(Handled::Unknown); + }; + let Some(attempt) = find_attempt(state, &state_param).await? else { + return Ok(Handled::Unknown); + }; + if attempt.status != CardAttemptStatus::Pending { + return Ok(Handled::Unknown); + } + let now = state.clock.now(); + let now_ts = format_ts(now); + if parse_ts(&attempt.expires_at).is_none_or(|t| t <= now) { + let mut tx = db::begin_immediate(&state.db).await?; + sqlx::query( + "UPDATE card_auth_attempts SET status = 'expired', finished_at = ? WHERE id = ? AND status = 'pending'", + ) + .bind(&now_ts) + .bind(attempt.id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + return Ok(Handled::Unknown); + } + // Claim the attempt: only one callback gets past this point (single use, CP-2). + let mut tx = db::begin_immediate(&state.db).await?; + let claimed = sqlx::query( + "UPDATE card_auth_attempts SET finished_at = ? WHERE id = ? AND status = 'pending' AND finished_at IS NULL", + ) + .bind(&now_ts) + .bind(attempt.id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + if claimed != 1 { + return Ok(Handled::Unknown); + } + let link = return_link(attempt.client, attempt.id); + + let code = match (query.error, query.code) { + (Some(_), _) => { + fail(state, attempt.id, "cancelled").await?; + return Ok(Handled::Failed(link)); + } + (None, None) => { + fail(state, attempt.id, "bank_error").await?; + return Ok(Handled::Failed(link)); + } + (None, Some(code)) => code, + }; + let mut conn = state.db.acquire().await?; + let role = user_role(&mut conn, attempt.user_id).await?; + drop(conn); + let source = providers::find(&attempt.provider_id) + .and_then(|provider| role.and_then(|role| state.cards.source_for(provider, role))); + let Some(source) = source else { + fail(state, attempt.id, "bank_error").await?; + return Ok(Handled::Failed(link)); + }; + let session = match source.create_session(&code).await { + Ok(session) => session, + Err(e) => { + tracing::warn!(detail = %e.detail, "card session could not be created"); + fail(state, attempt.id, "bank_error").await?; + return Ok(Handled::Failed(link)); + } + }; + let Some(account) = session.card_account().cloned() else { + fail(state, attempt.id, "no_account").await?; + if let Err(e) = source.delete_session(&session.session_id).await { + tracing::warn!(detail = %e.detail, "card session without accounts could not be revoked"); + } + return Ok(Handled::Failed(link)); + }; + + // Create or renew the user's connection for the provider (one per user and provider, D-167). + let done_ts = state.clock.now_ts(); + let valid_until = session.valid_until.map(format_ts); + let mut tx = db::begin_immediate(&state.db).await?; + let existing = connection_for_provider(&mut tx, attempt.user_id, &attempt.provider_id).await?; + let version = next_card_version(&mut tx).await?; + let (connection_id, previous_session) = match &existing { + Some(c) => { + sqlx::query( + "UPDATE card_connections SET status = 'active', session_id = ?, account_uid = ?, valid_until = ?, \ + history_days = ?, last_fetch_error = NULL, updated_at = ?, card_version = ? WHERE id = ?", + ) + .bind(&session.session_id) + .bind(&account.uid) + .bind(&valid_until) + .bind(attempt.history_days) + .bind(&done_ts) + .bind(version) + .bind(c.id.to_string()) + .execute(&mut *tx) + .await?; + (c.id, c.session_id.clone()) + } + None => { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO card_connections (id, user_id, provider_id, status, session_id, account_uid, \ + valid_until, history_days, last_fetch_at, last_fetch_error, last_user_fetch_at, background_fetches, \ + created_at, updated_at, card_version) \ + VALUES (?, ?, ?, 'active', ?, ?, ?, ?, NULL, NULL, NULL, '[]', ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(attempt.user_id.to_string()) + .bind(&attempt.provider_id) + .bind(&session.session_id) + .bind(&account.uid) + .bind(&valid_until) + .bind(attempt.history_days) + .bind(&done_ts) + .bind(&done_ts) + .bind(version) + .execute(&mut *tx) + .await?; + (id, None) + } + }; + sqlx::query( + "UPDATE card_auth_attempts SET status = 'connected', connection_id = ?, finished_at = ? WHERE id = ?", + ) + .bind(connection_id.to_string()) + .bind(&done_ts) + .bind(attempt.id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + // A renewal revokes the previous session (best effort, CP-2). + if let Some(old) = previous_session.filter(|old| *old != session.session_id) + && let Err(e) = source.delete_session(&old).await + { + tracing::warn!(detail = %e.detail, "previous card session could not be revoked"); + } + // The first fetch, user present with the callback request's address and user agent (CP-2, CP-4). + if let Some(guard) = state.cards.try_begin(connection_id) { + let task_state = state.clone(); + tokio::spawn(async move { + if let Err(e) = fetch::run(&task_state, guard, FetchKind::User(psu), true).await { + tracing::warn!(error_code = e.code.as_str(), detail = %e.message, "first card fetch failed"); + } + }); + } + Ok(Handled::Connected(link)) +} + +#[utoipa::path( + get, + path = "/cards/callback", + operation_id = "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(()), + params( + ("code" = Option, Query, description = "The one-time authorisation code."), + ("state" = Option, Query, description = "The attempt's state."), + ("error" = Option, Query, description = "Set by the bank when the user cancelled."), + ), + responses( + (status = 200, description = "The callback page.", body = String, content_type = "text/html; charset=utf-8"), + ) +)] +pub async fn get_card_callback( + State(state): State, + headers: HeaderMap, + client_ip: Option>, + RawQuery(query): RawQuery, +) -> Response { + let lang = preferred_lang( + headers + .get(header::ACCEPT_LANGUAGE) + .and_then(|v| v.to_str().ok()), + ); + let psu = PsuContext::from_request(&headers, client_ip.map(|Extension(ClientIp(ip))| ip)); + match handle(&state, parse_query(query.as_deref()), psu).await { + Ok(Handled::Connected(link)) => page(lang, Outcome::Success, Some(&link)), + Ok(Handled::Failed(link)) => page(lang, Outcome::Failure, Some(&link)), + Ok(Handled::Unknown) => page(lang, Outcome::Failure, None), + Err(e) => { + tracing::warn!(error_code = e.code.as_str(), "card callback failed"); + page(lang, Outcome::Failure, None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_parsing() { + assert_eq!(parse_query(None), CallbackQuery::default()); + let q = parse_query(Some("code=a%2Bb&state=s+1&state=other&x=1")); + assert_eq!(q.code.as_deref(), Some("a+b")); + assert_eq!(q.state.as_deref(), Some("s 1")); + assert_eq!(q.error, None); + let q = parse_query(Some("error=access_denied&state=s&code=%FF")); + assert_eq!(q.error.as_deref(), Some("access_denied")); + assert_eq!(q.code, None, "not UTF-8"); + } + + #[test] + fn pages_carry_the_copy_and_only_a_known_link() { + let id = Uuid::nil(); + let page = render( + Lang::Sv, + Outcome::Success, + Some(&return_link(CardClient::Android, id)), + ); + assert!(page.contains("Kortet är anslutet")); + assert!( + page.contains("kvitt://cards/connected?attempt=00000000-0000-0000-0000-000000000000") + ); + assert!(page.contains("Tillbaka till Kvitt")); + let page = render(Lang::En, Outcome::Failure, None); + assert!(page.contains("The card could not be connected")); + assert!(page.contains("Go back to Kvitt and try again.")); + assert!(!page.contains(" TransactionsPage { + let transactions = value + .get("transactions") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + TransactionsPage { + purchases: transactions.iter().filter_map(transaction).collect(), + rows: transactions.len(), + continuation_key: value + .get("continuation_key") + .and_then(Value::as_str) + .filter(|k| !k.is_empty()) + .map(str::to_owned), + } +} + +fn text<'a>(t: &'a Value, pointer: &str) -> Option<&'a str> { + t.pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +fn date(t: &Value, key: &str) -> Option { + let s = text(t, &format!("/{key}"))?; + NaiveDate::parse_from_str(s.get(..10)?, "%Y-%m-%d").ok() +} + +/// One transaction, or `None` when CP-7 skips it (status other than booked or pending, no date, no or zero amount, +/// an amount that is not an exact decimal). +pub fn transaction(t: &Value) -> Option { + let status = match text(t, "/status")? { + "BOOK" => CardPurchaseStatus::Booked, + "PDNG" => CardPurchaseStatus::Pending, + _ => return None, + }; + let indicator = text(t, "/credit_debit_indicator")?; + let direction = match indicator { + "DBIT" => CardDirection::Debit, + "CRDT" => CardDirection::Credit, + _ => return None, + }; + let booking = date(t, "booking_date"); + let purchase_date = booking + .or_else(|| date(t, "value_date")) + .or_else(|| date(t, "transaction_date"))?; + let raw_amount = text(t, "/transaction_amount/amount")?; + let decimal = unsigned_decimal(raw_amount)?; + let ore = ore_of(&decimal)?; + if ore == 0 { + return None; + } + let currency = text(t, "/transaction_amount/currency") + .unwrap_or("SEK") + .to_ascii_uppercase(); + let amount = if currency == "SEK" { + PurchaseAmount::Sek(ore) + } else if currency.len() == 3 && currency.bytes().all(|b| b.is_ascii_uppercase()) { + PurchaseAmount::Foreign { + amount: decimal.clone(), + currency, + } + } else { + return None; + }; + let merchant = merchant(t); + let reference = text(t, "/entry_reference").map(str::to_owned); + Some(SourcePurchase { + fallback_key: format!( + "{}|{}|{}|{}", + purchase_date.format("%Y-%m-%d"), + raw_amount, + indicator, + merchant + ), + reference, + status, + direction, + purchase_date, + booking_date: if status == CardPurchaseStatus::Booked { + booking + } else { + None + }, + amount, + merchant, + }) +} + +/// `remittance_information` joined with a space, else `creditor.name`, else "Kortköp"; whitespace collapsed, +/// trimmed, at most 140 code points (CP-7). +pub fn merchant(t: &Value) -> String { + let remittance = match t.get("remittance_information") { + Some(Value::Array(lines)) => lines + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "), + Some(Value::String(line)) => line.clone(), + _ => String::new(), + }; + let candidates = [ + remittance, + text(t, "/creditor/name").unwrap_or("").to_owned(), + ]; + let chosen = candidates + .iter() + .map(|c| c.split_whitespace().collect::>().join(" ")) + .find(|c| !c.is_empty()) + .unwrap_or_else(|| MERCHANT_FALLBACK.to_owned()); + chosen.chars().take(MERCHANT_MAX).collect() +} + +/// The decimal string without a sign: digits, optionally `.` and digits. `None` for anything else. +fn unsigned_decimal(raw: &str) -> Option { + let s = raw.trim().trim_start_matches(['+', '-']); + let (int, frac) = s.split_once('.').unwrap_or((s, "")); + let ok = !int.is_empty() + && int.bytes().all(|b| b.is_ascii_digit()) + && frac.bytes().all(|b| b.is_ascii_digit()) + && !(s.contains('.') && frac.is_empty()); + ok.then(|| s.to_owned()) +} + +/// Exact öre of an unsigned decimal (string parsing, never floating point, as §9.6's amount input); `None` when it +/// has non-zero digits beyond the second decimal or overflows. +pub fn ore_of(decimal: &str) -> Option { + let (int, frac) = decimal.split_once('.').unwrap_or((decimal, "")); + let (cents, rest) = frac.split_at(frac.len().min(2)); + if rest.bytes().any(|b| b != b'0') { + return None; + } + let units: i64 = int.parse().ok()?; + let cents: i64 = format!("{cents:0<2}").parse().ok()?; + units.checked_mul(100)?.checked_add(cents) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tx(extra: Value) -> Value { + let mut base = json!({ + "entry_reference": "ref-1", "status": "BOOK", "credit_debit_indicator": "DBIT", + "booking_date": "2026-10-20", "value_date": "2026-10-19", + "transaction_amount": {"amount": "123.45", "currency": "SEK"}, + "remittance_information": [" Kafé Exempel "], + "debtor": {"name": "Holder Name"}, "debtor_account": {"other": {"identification": "5555 **** 9876", "scheme_name": "CPAN"}}, + "creditor": {"postal_address": {"country": "SE", "town_name": "Exempelstad"}}, + "transaction_id": "tx-1", "bank_transaction_code": {"code": "PURCHASE"} + }); + if let (Some(b), Value::Object(e)) = (base.as_object_mut(), extra) { + b.extend(e); + } + base + } + + #[test] + fn a_booked_purchase() { + let p = transaction(&tx(json!({}))).unwrap(); + assert_eq!(p.reference.as_deref(), Some("ref-1")); + assert_eq!(p.status, CardPurchaseStatus::Booked); + assert_eq!(p.direction, CardDirection::Debit); + assert_eq!(p.purchase_date.to_string(), "2026-10-20"); + assert_eq!( + p.booking_date.map(|d| d.to_string()).as_deref(), + Some("2026-10-20") + ); + assert_eq!(p.amount, PurchaseAmount::Sek(12_345)); + assert_eq!(p.merchant, "Kafé Exempel"); + assert_eq!(p.fallback_key, "2026-10-20|123.45|DBIT|Kafé Exempel"); + let debug = format!("{p:?}"); + for never in ["Holder Name", "9876", "Exempelstad", "tx-1", "PURCHASE"] { + assert!(!debug.contains(never), "{never} kept"); + } + } + + #[test] + fn a_pending_purchase_dates_by_value_date() { + let p = transaction(&tx(json!({"status": "PDNG", "booking_date": null}))).unwrap(); + assert_eq!(p.status, CardPurchaseStatus::Pending); + assert_eq!(p.purchase_date.to_string(), "2026-10-19"); + assert_eq!(p.booking_date, None); + let t = transaction(&tx( + json!({"booking_date": null, "value_date": null, "transaction_date": "2026-10-18"}), + )) + .unwrap(); + assert_eq!(t.purchase_date.to_string(), "2026-10-18"); + assert!(transaction(&tx(json!({"booking_date": null, "value_date": null}))).is_none()); + } + + #[test] + fn skipped_rows() { + for extra in [ + json!({"status": "RJCT"}), + json!({"credit_debit_indicator": "X"}), + json!({"transaction_amount": {"amount": "0.00", "currency": "SEK"}}), + json!({"transaction_amount": {"amount": "1,50", "currency": "SEK"}}), + json!({"transaction_amount": {"amount": "1.505", "currency": "SEK"}}), + ] { + assert!(transaction(&tx(extra.clone())).is_none(), "{extra}"); + } + } + + #[test] + fn amounts_credits_and_currencies() { + let p = transaction(&tx(json!({"credit_debit_indicator": "CRDT", + "transaction_amount": {"amount": "-500", "currency": "SEK"}}))) + .unwrap(); + assert_eq!(p.direction, CardDirection::Credit); + assert_eq!(p.amount, PurchaseAmount::Sek(50_000)); + let f = transaction(&tx( + json!({"transaction_amount": {"amount": "12.5", "currency": "eur"}}), + )) + .unwrap(); + assert_eq!( + f.amount, + PurchaseAmount::Foreign { + amount: "12.5".into(), + currency: "EUR".into() + } + ); + assert_eq!(ore_of("1.10"), Some(110)); + assert_eq!(ore_of("1.100"), Some(110)); + assert_eq!(ore_of("7"), Some(700)); + } + + #[test] + fn merchant_fallbacks_and_truncation() { + assert_eq!( + merchant(&json!({"remittance_information": ["A", "B"]})), + "A B" + ); + assert_eq!( + merchant(&json!({"remittance_information": [" "], "creditor": {"name": " Shop "}})), + "Shop" + ); + assert_eq!(merchant(&json!({})), "Kortköp"); + assert_eq!( + merchant(&json!({"remittance_information": ["å".repeat(200)]})) + .chars() + .count(), + 140 + ); + } + + #[test] + fn a_page() { + let p = page( + &json!({"transactions": [tx(json!({})), tx(json!({"status": "CNCL"}))], "continuation_key": null}), + ); + assert_eq!( + (p.purchases.len(), p.rows, p.continuation_key), + (1, 2, None) + ); + let p = page(&json!({"transactions": [], "continuation_key": "k"})); + assert_eq!(p.continuation_key.as_deref(), Some("k")); + } +} diff --git a/server/crates/kvitt-server/src/cards/enable_banking/mod.rs b/server/crates/kvitt-server/src/cards/enable_banking/mod.rs new file mode 100644 index 0000000..9f87f36 --- /dev/null +++ b/server/crates/kvitt-server/src/cards/enable_banking/mod.rs @@ -0,0 +1,566 @@ +//! The Enable Banking adapter (SPEC §9.12, D-159, D-160, D-175): the only code that names the vendor or reads its +//! JSON. It implements [`CardSource`] and maps every answer into Kvitt's own records (`cards::*`) before returning. +//! +//! Requests carry a JWT signed RS256 with the application's private key (header `kid` = the application id, `iss` +//! `enablebanking.com`, `aud` `api.enablebanking.com`, one hour, cached until 5 minutes before expiry, as the FCM +//! token is). Never logged: the key, the JWT, a session id, an account uid, an authorisation code, a `state`, any +//! response body (SPEC §8.7). Error answers are classified by their HTTP status and the `error` code of Enable +//! Banking's `ErrorResponse` only. + +pub mod mapping; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use reqwest::Method; +use serde::Serialize; +use serde_json::{Value, json}; +use tokio::sync::Mutex; + +use super::providers::Provider; +use super::{ + AspspInfo, AuthRequest, BankAccount, BankSession, CardSource, DateWindow, PsuContext, + SourceError, SourceErrorKind, TransactionsPage, +}; +use crate::audit::BoxFuture; +use crate::error::AppError; +use crate::time::Clock; + +/// The source id providers name in the registry (CP-1). +pub const SOURCE_ID: &str = "enable_banking"; +/// JWT `iss`. +pub const JWT_ISSUER: &str = "enablebanking.com"; +/// JWT `aud`. +pub const JWT_AUDIENCE: &str = "api.enablebanking.com"; +/// JWT lifetime (Enable Banking accepts at most 24 hours). +pub const JWT_LIFETIME: Duration = Duration::hours(1); +/// A cached JWT is replaced this long before it expires. +pub const JWT_REFRESH_MARGIN: Duration = Duration::minutes(5); +/// Per-request timeout. +pub const HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(30); +/// How long a `GET /aspsps` answer is reused. +pub const ASPSP_CACHE: Duration = Duration::hours(24); + +/// The PSU headers Kvitt can fill, by Enable Banking's lower-case name (CP-5). `psu-geo-location` is never sent. +const PSU_HEADERS: [(&str, &str); 7] = [ + ("psu-ip-address", "Psu-Ip-Address"), + ("psu-user-agent", "Psu-User-Agent"), + ("psu-referer", "Psu-Referer"), + ("psu-accept", "Psu-Accept"), + ("psu-accept-charset", "Psu-Accept-Charset"), + ("psu-accept-encoding", "Psu-Accept-Encoding"), + ("psu-accept-language", "Psu-Accept-Language"), +]; +/// Sent on every user-present fetch, whatever the bank requires (CP-5). +const PSU_ALWAYS: [&str; 2] = ["psu-ip-address", "psu-user-agent"]; + +#[derive(Serialize)] +struct Claims<'a> { + iss: &'a str, + aud: &'a str, + iat: i64, + exp: i64, +} + +#[derive(Debug, Clone)] +struct Aspsp { + max_consent_secs: Option, + /// Lower case (K0: Enable Banking lists them so; compared case-insensitively). + required_psu_headers: Vec, +} + +/// A bank by (name, country). +type AspspKey = (String, String); + +/// The Enable Banking client of one application. +pub struct EnableBanking { + http: reqwest::Client, + app_id: String, + key: EncodingKey, + api_url: String, + clock: Arc, + jwt: Mutex)>>, + aspsps: Mutex, Aspsp)>>, +} + +impl std::fmt::Debug for EnableBanking { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the key or the application id. + f.debug_struct("EnableBanking") + .field("api_url", &self.api_url) + .finish_non_exhaustive() + } +} + +impl EnableBanking { + /// A client for application `app_id` with its PEM private key. + pub fn new( + app_id: &str, + key_pem: &[u8], + api_url: &str, + clock: Arc, + ) -> Result { + let key = EncodingKey::from_rsa_pem(key_pem) + .map_err(|e| AppError::internal(format!("card key is not an RSA PEM key: {e}")))?; + let http = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|e| AppError::internal(format!("HTTP client: {e}")))?; + Ok(Self { + http, + app_id: app_id.to_owned(), + key, + api_url: api_url.trim_end_matches('/').to_owned(), + clock, + jwt: Mutex::new(None), + aspsps: Mutex::new(HashMap::new()), + }) + } + + /// A client whose key is read from `path` (an error when missing or unusable). + pub fn from_key_file( + app_id: &str, + path: &Path, + api_url: &str, + clock: Arc, + ) -> Result { + let pem = std::fs::read(path).map_err(|e| { + AppError::new( + crate::error::ErrorCode::Internal, + format!("cannot read {}: {}", path.display(), e.kind()), + ) + })?; + Self::new(app_id, &pem, api_url, clock) + } + + async fn token(&self) -> Result { + let now = self.clock.now(); + let mut cache = self.jwt.lock().await; + if let Some((token, expires)) = cache.as_ref() + && now < *expires - JWT_REFRESH_MARGIN + { + return Ok(token.clone()); + } + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(self.app_id.clone()); + let expires = now + JWT_LIFETIME; + let claims = Claims { + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + iat: now.timestamp(), + exp: expires.timestamp(), + }; + let token = jsonwebtoken::encode(&header, &claims, &self.key).map_err(|e| { + SourceError::new( + SourceErrorKind::Failed, + None, + format!("JWT signing failed: {e}"), + ) + })?; + *cache = Some((token.clone(), expires)); + Ok(token) + } + + /// One call; `Ok` with the parsed body of a 2xx answer, else the classified error. `route` is logged-safe. + async fn call( + &self, + method: Method, + path: &str, + route: &str, + query: &[(&str, String)], + body: Option, + headers: &[(&'static str, String)], + ) -> Result { + let token = self.token().await?; + let mut url = format!("{}{path}", self.api_url); + for (i, (key, value)) in query.iter().enumerate() { + url.push(if i == 0 { '?' } else { '&' }); + url.push_str(key); + url.push('='); + url.extend(utf8_percent_encode(value, NON_ALPHANUMERIC)); + } + let mut request = self + .http + .request(method, url) + .bearer_auth(token) + .header("accept", "application/json"); + for (name, value) in headers { + request = request.header(*name, value); + } + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.map_err(|e| { + SourceError::new( + SourceErrorKind::BankUnavailable, + None, + format!( + "{route}: no answer ({})", + if e.is_timeout() { "timeout" } else { "network" } + ), + ) + })?; + let status = response.status().as_u16(); + let value: Value = response.json().await.unwrap_or(Value::Null); + if (200..300).contains(&status) { + return Ok(value); + } + let code = value.get("error").and_then(Value::as_str).unwrap_or(""); + Err(classify(status, code, route)) + } + + async fn lookup_aspsp(&self, provider: &Provider) -> Result { + let key = ( + provider.aspsp_name.to_owned(), + provider.aspsp_country.to_owned(), + ); + let now = self.clock.now(); + if let Some((at, aspsp)) = self.aspsps.lock().await.get(&key) + && now - *at < ASPSP_CACHE + { + return Ok(aspsp.clone()); + } + let value = self + .call( + Method::GET, + "/aspsps", + "/aspsps", + &[("country", provider.aspsp_country.to_owned())], + None, + &[], + ) + .await?; + let found = value + .get("aspsps") + .and_then(Value::as_array) + .and_then(|list| { + list.iter().find(|a| { + a.get("name").and_then(Value::as_str) == Some(provider.aspsp_name) + && a.get("country") + .and_then(Value::as_str) + .is_none_or(|c| c.eq_ignore_ascii_case(provider.aspsp_country)) + }) + }) + .ok_or_else(|| { + SourceError::new( + SourceErrorKind::Failed, + Some(200), + "/aspsps: the provider's bank is not listed", + ) + })?; + let aspsp = Aspsp { + max_consent_secs: found + .get("maximum_consent_validity") + .and_then(Value::as_i64), + required_psu_headers: found + .get("required_psu_headers") + .and_then(Value::as_array) + .map(|hs| { + hs.iter() + .filter_map(Value::as_str) + .map(str::to_ascii_lowercase) + .collect() + }) + .unwrap_or_default(), + }; + self.aspsps.lock().await.insert(key, (now, aspsp.clone())); + Ok(aspsp) + } + + /// The PSU headers of a user-present fetch (CP-5): `Psu-Ip-Address` and `Psu-User-Agent` always, plus every + /// header the bank requires that the user's request lets Kvitt fill. + async fn psu_headers( + &self, + provider: &Provider, + psu: &PsuContext, + ) -> Vec<(&'static str, String)> { + let required = match self.lookup_aspsp(provider).await { + Ok(aspsp) => aspsp.required_psu_headers, + Err(e) => { + tracing::warn!(detail = %e.detail, "card bank details unavailable: sending the basic PSU headers"); + Vec::new() + } + }; + psu_header_values(psu, &required) + } +} + +/// The PSU headers to send for `required` (lower-case names): the always-sent ones, then the required ones, each +/// only when the user's request provided a value. +pub fn psu_header_values(psu: &PsuContext, required: &[String]) -> Vec<(&'static str, String)> { + let value = |lower: &str| -> Option<&String> { + match lower { + "psu-ip-address" => psu.ip_address.as_ref(), + "psu-user-agent" => psu.user_agent.as_ref(), + "psu-referer" => psu.referer.as_ref(), + "psu-accept" => psu.accept.as_ref(), + "psu-accept-charset" => psu.accept_charset.as_ref(), + "psu-accept-encoding" => psu.accept_encoding.as_ref(), + "psu-accept-language" => psu.accept_language.as_ref(), + _ => None, + } + }; + PSU_HEADERS + .iter() + .filter(|(lower, _)| { + PSU_ALWAYS.contains(lower) || required.iter().any(|r| r.eq_ignore_ascii_case(lower)) + }) + .filter_map(|(lower, canonical)| value(lower).map(|v| (*canonical, v.clone()))) + .collect() +} + +/// Maps an error answer to CP-14 by HTTP status and Enable Banking's `error` code. +pub fn classify(status: u16, code: &str, route: &str) -> SourceError { + let kind = match code { + "WRONG_TRANSACTIONS_PERIOD" => SourceErrorKind::NoOlderHistory, + "EXPIRED_SESSION" | "CLOSED_SESSION" | "REVOKED_SESSION" | "SESSION_DOES_NOT_EXIST" => { + SourceErrorKind::ConsentExpired + } + "ASPSP_RATE_LIMIT_EXCEEDED" => SourceErrorKind::RateLimited, + "ASPSP_ERROR" | "ASPSP_TIMEOUT" => SourceErrorKind::BankUnavailable, + _ if status == 429 => SourceErrorKind::RateLimited, + _ if status == 408 || status >= 500 => SourceErrorKind::BankUnavailable, + _ => SourceErrorKind::Failed, + }; + // The code is an enum token of the vendor's, never personal data; the body itself is not logged. + let code = if code.chars().all(|c| c.is_ascii_uppercase() || c == '_') { + code + } else { + "?" + }; + SourceError::new(kind, Some(status), format!("{route}: {status} {code}")) +} + +/// `2027-04-22T10:00:00+00:00`, the form K0 sent. +fn eb_timestamp(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::Secs, false) +} + +impl CardSource for EnableBanking { + fn aspsp<'a>( + &'a self, + provider: &'a Provider, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let aspsp = self.lookup_aspsp(provider).await?; + Ok(AspspInfo { + max_consent_secs: aspsp.max_consent_secs, + }) + }) + } + + fn start_auth<'a>( + &'a self, + request: AuthRequest<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let body = json!({ + "access": { "valid_until": eb_timestamp(request.valid_until) }, + "aspsp": { "name": request.provider.aspsp_name, "country": request.provider.aspsp_country }, + "state": request.state, + "redirect_url": request.redirect_url, + "psu_type": "personal", + }); + let value = self + .call(Method::POST, "/auth", "/auth", &[], Some(body), &[]) + .await?; + value + .get("url") + .and_then(Value::as_str) + .filter(|u| !u.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + SourceError::new(SourceErrorKind::Failed, Some(200), "/auth: no url") + }) + }) + } + + fn create_session<'a>( + &'a self, + code: &'a str, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let value = self + .call( + Method::POST, + "/sessions", + "/sessions", + &[], + Some(json!({ "code": code })), + &[], + ) + .await?; + let session_id = value + .get("session_id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + SourceError::new( + SourceErrorKind::Failed, + Some(200), + "/sessions: no session_id", + ) + })? + .to_owned(); + let accounts = value + .get("accounts") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .filter_map(|a| { + let uid = a.get("uid").and_then(Value::as_str)?; + Some(BankAccount { + uid: uid.to_owned(), + is_card: a + .get("cash_account_type") + .and_then(Value::as_str) + .is_some_and(|t| t.eq_ignore_ascii_case("CARD")), + }) + }) + .collect() + }) + .unwrap_or_default(); + let valid_until = value + .pointer("/access/valid_until") + .and_then(Value::as_str) + .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&Utc)); + Ok(BankSession { + session_id, + accounts, + valid_until, + }) + }) + } + + fn transactions<'a>( + &'a self, + provider: &'a Provider, + account_uid: &'a str, + window: DateWindow, + continuation: Option<&'a str>, + psu: Option<&'a PsuContext>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let headers = match psu { + Some(psu) => self.psu_headers(provider, psu).await, + None => Vec::new(), + }; + // `strategy` is never sent (CP-7, D-178). + let mut query = vec![ + ("date_from", window.from.format("%Y-%m-%d").to_string()), + ("date_to", window.to.format("%Y-%m-%d").to_string()), + ]; + if let Some(key) = continuation { + query.push(("continuation_key", key.to_owned())); + } + let path = format!( + "/accounts/{}/transactions", + utf8_percent_encode(account_uid, NON_ALPHANUMERIC) + ); + let value = self + .call( + Method::GET, + &path, + "/accounts/{uid}/transactions", + &query, + None, + &headers, + ) + .await?; + Ok(mapping::page(&value)) + }) + } + + fn delete_session<'a>(&'a self, session_id: &'a str) -> BoxFuture<'a, Result<(), SourceError>> { + Box::pin(async move { + let path = format!( + "/sessions/{}", + utf8_percent_encode(session_id, NON_ALPHANUMERIC) + ); + self.call(Method::DELETE, &path, "/sessions/{id}", &[], None, &[]) + .await + .map(|_| ()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_map_to_cp_14() { + let kind = |status, code| classify(status, code, "/x").kind; + assert_eq!( + kind(422, "WRONG_TRANSACTIONS_PERIOD"), + SourceErrorKind::NoOlderHistory + ); + assert_eq!( + kind(401, "EXPIRED_SESSION"), + SourceErrorKind::ConsentExpired + ); + assert_eq!(kind(400, "CLOSED_SESSION"), SourceErrorKind::ConsentExpired); + assert_eq!( + kind(400, "REVOKED_SESSION"), + SourceErrorKind::ConsentExpired + ); + assert_eq!( + kind(429, "ASPSP_RATE_LIMIT_EXCEEDED"), + SourceErrorKind::RateLimited + ); + assert_eq!(kind(429, ""), SourceErrorKind::RateLimited); + assert_eq!(kind(500, ""), SourceErrorKind::BankUnavailable); + assert_eq!(kind(408, ""), SourceErrorKind::BankUnavailable); + assert_eq!(kind(400, "ASPSP_ERROR"), SourceErrorKind::BankUnavailable); + assert_eq!( + kind(400, "WRONG_REQUEST_PARAMETERS"), + SourceErrorKind::Failed + ); + assert_eq!( + classify(400, "