K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Build Kvitt images / build (push) Failing after 8m17s
Build Kvitt images / build (push) Failing after 8m17s
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error codes and a 502 response, examples for every card body and error code, the match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py (157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists match_card_purchase as not ported until K3 ports it (orchestrator's allowance). Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions, transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping), the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day, de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk), CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45, 4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38 and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub (tests/cards_api/, 34 tests); the real API is never called. Infra: Pi compose mounts secrets/cards read-only and passes the two application ids; .env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases". Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design (contract-first landing; K2 lands next). Server, contracts-check and web gates are green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"server_version": "0.1.0",
|
||||
"schema_version": 3,
|
||||
"schema_version": 4,
|
||||
"db_ok": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "sj_prio_mastercard",
|
||||
"name": "SJ Prio Mastercard",
|
||||
"issuer": "SEB Kort",
|
||||
"image_key": "sj_prio_mastercard",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"provider_id": "sj_prio_mastercard",
|
||||
"history_days": 30,
|
||||
"client": "android"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"history_days": 7
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"expense_id": "0192e000-0000-7000-8000-000000000001",
|
||||
"ignored": false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "card_bank_unavailable",
|
||||
"message": "the card service or the bank did not answer or refused",
|
||||
"field": null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "card_consent_expired",
|
||||
"message": "the card connection has expired or is disconnected",
|
||||
"field": null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "cards_unavailable",
|
||||
"message": "the server has no card application for this user",
|
||||
"field": null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "expense_already_linked",
|
||||
"message": "the expense is linked from another card purchase",
|
||||
"field": "expense_id"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user