Files
kvitt/contracts/schema/migrations/0004_card_purchases.sql
T
jacobh2 671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
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
2026-10-24 16:43:26 +02:00

84 lines
4.4 KiB
SQL

-- 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';