Skip to main content

Database Design

Database Design

Version: 1.0 Date: 2026-02-21 Status: Approved Owner: Database Architect


Design Philosophy

Drop's database schema is designed around three principles:

  1. Simplicity over abstraction. 1925 tables for a well-scoped fintech app. No generic "entities" table, no EAV patterns. Each table maps to a clear domain concept.
  2. Compliance by design. 7 of the original 19 tables exist solely for regulatory requirements (GDPR, AML, PSD2). They6 additional operational and compliance tables were added asto aaddress compliancereconciliation, infrastructurecircuit layer,breaker notstate, retrofitted.webhook processing, and dispute handling requirements.
  3. PostgreSQL-native. Schema targets PostgreSQL 16 exclusively (ADR-014 supersedes ADR-006/010). PostgreSQL-specific features (arrays, JSONB, partial indexes, TIMESTAMPTZ) are available where beneficial. No SQLite compatibility constraints.

Why 1925 Tables

The table count reflects the actual domain:

  • 12 core tables cover the business logic: users, their bank accounts, recipients, merchants, transactions, exchange rates, cards, sessions, notifications, settings, spending limits, and rate limits.
  • 7 compliance tables were added as a single compliance infrastructure layer: audit logging, AML alerts, STR reports, sanctions screening, consent tracking, data access requests, and complaints.
  • 6 operational tables were added to address critical operational requirements (FR-073 through FR-077): reconciliation reporting, discrepancy tracking, circuit breaker state persistence, webhook event logging, dead-letter queue management, and payment dispute handling.

No table is redundant. No table combines unrelated concerns.


Complete Schema ERD

erDiagram
    users ||--|| settings : "1:1 preferences"
    users ||--o{ bank_accounts : "1:N linked accounts"
    users ||--o{ cards : "1:N payment cards"
    users ||--o{ recipients : "1:N saved recipients"
    users ||--o{ transactions : "1:N financial ops"
    users ||--o{ sessions : "1:N auth sessions"
    users ||--o{ notifications : "1:N alerts"
    users ||--o{ spending_limits : "1:N limits"
    users ||--o{ merchants : "1:N merchant profiles"
    users ||--o{ audit_log : "1:N audit entries"
    users ||--o{ aml_alerts : "1:N AML flags"
    users ||--o{ str_reports : "1:N STR filings"
    users ||--o{ screening_results : "1:N screenings"
    users ||--o{ consents : "1:N consents"
    users ||--o{ data_access_requests : "1:N DSARs"
    users ||--o{ complaints : "1:N complaints"

    transactions }o--o| recipients : "remittance target"
    transactions }o--o| merchants : "QR payment target"
    transactions ||--o{ aml_alerts : "triggers alert"
    aml_alerts ||--o{ str_reports : "escalates to STR"
    cards ||--o{ spending_limits : "card-level limits"

    reconciliation_reports ||--o{ reconciliation_discrepancies : "has discrepancies"
    transactions }o--o| reconciliation_discrepancies : "referenced in discrepancy"
    transactions ||--o{ webhook_events : "status updates"
    webhook_events ||--o{ webhook_dlq : "dead-lettered"
    transactions ||--o{ disputes : "disputed by user"
    users ||--o{ disputes : "submits dispute"
    transactions }o--o| disputes : "refund transaction"

    users {
        text id PK "usr_ + 16 hex"
        text email UK "NOT NULL"
        text password_hash "NOT NULL, default EIDONLY"
        text auth_provider "default bankid"
        text first_name "NOT NULL"
        text last_name "NOT NULL"
        text phone "nullable"
        text date_of_birth "nullable"
        text kyc_status "CHECK pending|approved|rejected"
        text role "CHECK user|merchant"
        text risk_level "CHECK low|medium|high"
        text pep_status "CHECK not_checked|clear|match|pending_review"
        integer sanctions_cleared "default 0"
        text kyc_method "CHECK bankid|document|simplified"
        text kyc_verified_at "nullable"
        text national_id_hash "nullable, indexed WHERE NOT NULL"
        text deleted_at "nullable, soft delete"
        text created_at "default datetime now"
    }

    transactions {
        text id PK
        text user_id FK "NOT NULL"
        text type "CHECK remittance|qr_payment"
        text status "CHECK processing|completed|failed"
        integer amount "NOT NULL, in minor units"
        text currency "default NOK"
        integer fee "default 0"
        text recipient_id FK "nullable"
        text merchant_id FK "nullable"
        integer send_amount "nullable"
        text send_currency "nullable"
        integer receive_amount "nullable"
        text receive_currency "nullable"
        real exchange_rate "nullable"
        text purpose_code "nullable"
        text idempotency_key "UNIQUE WHERE NOT NULL"
        text created_at "default datetime now"
        text completed_at "nullable"
    }

    bank_accounts {
        text id PK
        text user_id FK "NOT NULL"
        text bank_name "NOT NULL"
        text account_number "NOT NULL"
        text iban "nullable"
        integer balance "default 0, cached AISP"
        text balance_synced_at "nullable"
        text currency "default NOK"
        integer is_primary "default 0"
        text connected_at "default datetime now"
    }

    merchants {
        text id PK
        text user_id FK "NOT NULL"
        text business_name "NOT NULL"
        text org_number "UNIQUE NOT NULL"
        text address "nullable"
        text bank_account "NOT NULL"
        real fee_rate "default 0.01"
        text status "default active"
        text qr_hmac_key "NOT NULL, random 32 bytes"
        text created_at "default datetime now"
    }

    recipients {
        text id PK
        text user_id FK "NOT NULL"
        text name "NOT NULL"
        text country "NOT NULL"
        text currency "NOT NULL"
        text bank_account "NOT NULL"
        text bank_name "nullable"
        text created_at "default datetime now"
    }

Table-by-Table Design Rationale

Core Tables

users

Normalization: 3NF. All columns are functionally dependent on the primary key.

Design decisions:

  • id uses usr_ prefix + 16 hex chars for readability and collision avoidance across distributed systems.
  • password_hash defaults to 'EIDONLY' sentinel value -- BankID-only users have no password. This avoids nullable password fields that complicate auth logic.
  • auth_provider tracks how the user registered (bankid). Supports future Vipps Login without schema changes.
  • national_id_hash stores SHA-256 of Norwegian fodselsnummer. Enables user deduplication across auth providers without storing the raw national ID.
  • deleted_at enables soft delete for GDPR erasure while retaining records for AML legal obligations (5-year retention).
  • risk_level, pep_status, sanctions_cleared are denormalized onto the user for fast access during transaction authorization -- these are checked on every financial operation.
  • KYC fields (kyc_status, kyc_method, kyc_verified_at) are on the user table rather than a separate KYC table because there is a 1:1 relationship and the fields are accessed on every authenticated request.

transactions

Normalization: 3NF with intentional denormalization.

Design decisions:

  • amount, fee, send_amount, receive_amount are stored as integers in minor units (ore for NOK, para for RSD, etc.) to avoid floating-point precision issues.
  • Polymorphic reference: recipient_id is set for remittances, merchant_id for QR payments. Never both. This avoids a separate join table for a simple either/or relationship.
  • exchange_rate is denormalized (snapshot at transaction time) because rates change. The rate at execution time must be preserved for audit and dispute resolution.
  • idempotency_key with a unique partial index (WHERE idempotency_key IS NOT NULL) prevents duplicate transaction submission without requiring every transaction to have a key.
  • purpose_code supports remittance regulatory requirements (some corridors require a transfer purpose).
  • completed_at is separate from created_at to track processing duration.

bank_accounts

Normalization: 3NF.

Design decisions:

  • balance is a cached read-only value from AISP, not a Drop-held balance. This is the most important design detail in the entire schema.
  • balance_synced_at tracks when the balance was last refreshed from the bank via Open Banking.
  • is_primary flag determines which account is used for transactions by default (1 = primary, 0 = secondary).
  • No unique constraint on account_number because the same bank account could theoretically appear under different user records (shared accounts).

recipients

Normalization: 3NF.

Design decisions:

  • Scoped to user (user_id FK) -- recipients are private, not shared.
  • country and currency stored as free text validated at the API layer (not as FK to a countries table). This avoids over-engineering for 5-6 supported corridors.
  • bank_account stores the full foreign account number. Format varies by country (IBAN for EU, local format for others).

merchants

Normalization: 3NF.

Design decisions:

  • org_number is UNIQUE -- one merchant registration per Norwegian organization number (9 digits).
  • qr_hmac_key is generated server-side (hex(randomblob(32))) for QR code integrity verification. Each merchant gets a unique key.
  • fee_rate defaults to 0.01 (1%). Stored per merchant to allow variable pricing in the future.
  • user_id FK links the merchant to the user who registered it. A user's role is upgraded to merchant upon registration.

exchange_rates

Normalization: 3NF.

Design decisions:

  • id uses INTEGER PRIMARY KEY AUTOINCREMENT -- the only auto-increment ID in the schema. Exchange rates are system-managed, not user-created, so prefixed IDs are unnecessary.
  • Only stores NOK-to-X rates (6 corridors). Inverse rates are calculated at runtime.
  • No historical rate tracking in this table. Transaction records snapshot the rate at execution time.

sessions

Normalization: 3NF.

Design decisions:

  • token_hash stores SHA-256 of the JWT, not the JWT itself. This prevents session hijacking even if the database is compromised.
  • revoked flag (0/1) enables server-side session invalidation without waiting for JWT expiry.
  • Multiple active sessions per user are allowed (different devices).

settings

Normalization: 3NF. 1:1 with users.

Design decisions:

  • user_id as PRIMARY KEY enforces the 1:1 relationship at the database level.
  • Created lazily on first GET /api/settings (INSERT default if not exists).
  • Defaults: currency='NOK', language='nb', push_enabled=1, email_enabled=1.

notifications

Normalization: 3NF.

Design decisions:

  • read flag (0/1) for marking notifications as read in batch.
  • No foreign key to the triggering entity (transaction, system event) -- type field categorizes the notification source.
  • Designed for high volume with eventual cleanup (no retention policy yet).

cards (FUTURE)

Normalization: 3NF.

Design decisions:

  • Feature-flagged. Table exists in schema but endpoints return 404 when disabled.
  • Only stores last_four and token_ref -- never full card number or CVV (PCI-DSS compliance).
  • pin_hash added via runtime migration for backward compatibility.
  • status supports freeze/unfreeze without deletion (active -> frozen -> active).

spending_limits (FUTURE)

Normalization: 3NF.

Design decisions:

  • card_id is nullable -- supports user-level limits (no specific card) or card-level limits.
  • limit_type values (daily, weekly, monthly, transaction) enforced at API level.
  • Limits are replaced, not accumulated (PUT semantics per limit type per card).

rate_limits

Normalization: 3NF (trivial -- 3 columns).

Design decisions:

  • key is the IP address (TEXT PK). Simple key-value store.
  • reset_at is a Unix timestamp. Expired entries are cleaned every 100 rate limit checks in middleware/rate-limit.ts.
  • Not a "real" domain table -- it is infrastructure. Could be replaced by Redis in production but works fine in SQLite/PostgreSQL.

Compliance Tables

audit_log

  • user_id is nullable because some audit events occur before authentication (e.g., failed login attempts).
  • resource_type + resource_id enable generic resource tracking without polymorphic FKs.
  • details is a TEXT field (JSON string) for flexible event-specific data.
  • request_id for correlating multiple audit entries from a single API request.
  • Two indexes: user_id and action for the primary query patterns. (Note: no timestamp index exists in the implementation — only idx_audit_log_user and idx_audit_log_action are created in db.ts.)

aml_alerts

  • severity (low/medium/high/critical) determines investigation priority and escalation timelines.
  • status workflow: open -> investigating -> resolved|escalated|filed.
  • reviewed_by and reviewed_at track the compliance officer's review.
  • transaction_id FK links to the specific transaction that triggered the alert.

str_reports

  • Filed with Okokrim/EFE (Norwegian financial intelligence unit).
  • reference_number stores the authority-assigned reference after submission.
  • alert_id links back to the originating AML alert.
  • Immutable after status = 'submitted' -- regulatory requirement.

screening_results

  • screening_type (pep/sanctions/adverse_media) supports multiple screening categories.
  • provider tracks which screening service was used (future: Sumsub, Refinitiv, etc.).
  • match_details stores full match information as TEXT (JSON) for review.
  • Multiple results per user (periodic rescreening).

consents

data_access_requests

  • request_type covers GDPR data subject rights: export (Art. 15), erasure (Art. 17), rectification (Art. 16), restriction (Art. 18).
  • download_url for data export files (temporary signed URLs).
  • status workflow: pending -> processing -> completed|rejected.

complaints

  • Required by Finansavtaleloven section 3-53 (15 business day response requirement).
  • category (transaction/service/fees/privacy/technical/other) for routing and reporting.
  • resolution text field filled when complaint is resolved.
  • resolved_at timestamp for SLA compliance tracking.

Operational Tables

reconciliation_reports

Source: FR-073 (Daily Transaction Reconciliation).

Design decisions:

  • One record per reconciliation run. The report_date (DATE) identifies which day's transactions were compared, not when the job ran. This is the natural primary query key for the admin dashboard.
  • status follows the lifecycle: pending (job started) → matched (clean run, no discrepancies) → discrepancies_found (at least one discrepancy detected). A failed variant should be handled at the application layer by leaving status as pending and recording error details in report_data.
  • total_drop_transactions and total_partner_transactions are separate counts — their difference is a quick audit signal that one side is missing records entirely.
  • matched_count and discrepancy_count should sum to total_drop_transactions when all Drop records are accounted for.
  • report_data (JSONB) stores the full machine-readable reconciliation output — all matched pairs, all discrepancy details — for audit trail purposes. This is retained for the 5-year AML record-keeping obligation (Hvitvaskingsloven § 30). Querying individual discrepancies uses the reconciliation_discrepancies table; the JSONB is the archival copy.
  • completed_at is nullable — it is null while the job is running and set when the job finishes.
ColumnTypeConstraintDescription
idUUIDPRIMARY KEYInternal report ID
report_dateDATENOT NULLThe transaction date being reconciled (T-1)
statusTEXTCHECK IN ('pending','matched','discrepancies_found')Job lifecycle status
total_drop_transactionsINTEGERNOT NULL, DEFAULT 0Count of Drop transactions for this date
total_partner_transactionsINTEGERNOT NULL, DEFAULT 0Count of ZTL transactions for this date
matched_countINTEGERNOT NULL, DEFAULT 0Count of matched (clean) transactions
discrepancy_countINTEGERNOT NULL, DEFAULT 0Count of discrepancies found
report_dataJSONBNULLABLEFull reconciliation detail — all records, matches, discrepancies
created_atTIMESTAMPTZDEFAULT now()When the job started
completed_atTIMESTAMPTZNULLABLEWhen the job completed

reconciliation_discrepancies

Source: FR-073 (Daily Transaction Reconciliation).

Design decisions:

  • One record per discrepancy found in a reconciliation run. Linked to the parent reconciliation_reports record via report_id.
  • transaction_id is nullable — a missing_in_drop discrepancy, by definition, has no corresponding Drop transaction record. In this case, partner_reference is the only identifier.
  • partner_reference stores the ZTL settlement record's reference. For discrepancies where both sides exist, both transaction_id and partner_reference are populated, enabling cross-referencing.
  • discrepancy_type is a closed enum: missing_in_partner, missing_in_drop, amount_mismatch, status_mismatch. No catch-all "other" — every discrepancy must be categorised.
  • drop_amount and partner_amount are stored in minor units (ore), matching the transactions table convention. Nullable because the relevant amount is absent when a record is missing on one side.
  • resolution_status workflow: open → investigating → resolved (discrepancy explained and accepted) or accepted (discrepancy accepted as a known tolerable difference, e.g., fee rounding).
  • resolved_by and resolved_at track which compliance officer resolved the discrepancy. notes captures the explanation for audit purposes.
ColumnTypeConstraintDescription
idUUIDPRIMARY KEYInternal discrepancy ID
report_idUUIDFK → reconciliation_reports(id), NOT NULLParent reconciliation report
transaction_idUUIDFK → transactions(id), NULLABLEDrop transaction (null for missing_in_drop)
partner_referenceTEXTNULLABLEZTL settlement record reference
discrepancy_typeTEXTCHECK IN ('missing_in_partner','missing_in_drop','amount_mismatch','status_mismatch'), NOT NULLCategory of discrepancy
drop_amountBIGINTNULLABLEAmount in Drop's records (minor units)
partner_amountBIGINTNULLABLEAmount in ZTL's records (minor units)
drop_statusTEXTNULLABLETransaction status in Drop's records
partner_statusTEXTNULLABLETransaction status in ZTL's records
resolution_statusTEXTCHECK IN ('open','investigating','resolved','accepted'), DEFAULT 'open'Workflow status
resolved_byTEXTNULLABLEAdmin identifier who resolved
resolved_atTIMESTAMPTZNULLABLEResolution timestamp
notesTEXTNULLABLEFree-text explanation of resolution
created_atTIMESTAMPTZDEFAULT now()When the discrepancy was logged

circuit_breaker_state

Source: FR-075 (Circuit Breaker and Fallback Strategy).

Design decisions:

  • One record per external dependency. The dependency_name column has a UNIQUE constraint — it is the natural primary lookup key. The auto-increment id exists for FK references and ORM compatibility.
  • dependency_name values: bankid, ztl, fx_provider, compliance, push. These are the 5 external systems identified in FR-075. No other values are valid; enforced at the application layer.
  • state values: closed (normal operation), open (short-circuiting, fallback active), half_open (recovery probe in flight). Lowercase matches the circuit breaker state machine terminology in FR-075.
  • failure_count tracks consecutive failures in CLOSED state. Reset to 0 on any success or on transition to CLOSED from HALF-OPEN.
  • last_failure_at and last_success_at are maintained for the error-rate calculation window and for the operations dashboard.
  • opened_at records when the circuit last opened — used to calculate downtime duration in the recovery Slack alert (FR-075.6).
  • half_open_at records when the circuit entered HALF-OPEN — used to determine if the 30-second recovery window has elapsed.
  • updated_at is a general last-modified timestamp, updated on every state change or counter update. Required because multiple App Runner instances share this state via database polling.
  • This table is the single source of truth for circuit state across all application instances (FR-075.5). No in-memory circuit state survives a restart.
ColumnTypeConstraintDescription
idINTEGERPRIMARY KEY AUTOINCREMENTInternal ID
dependency_nameTEXTUNIQUE, NOT NULLExternal dependency identifier (bankid, ztl, fx_provider, compliance, push)
stateTEXTCHECK IN ('closed','open','half_open'), NOT NULL, DEFAULT 'closed'Current circuit state
failure_countINTEGERNOT NULL, DEFAULT 0Consecutive failure count (reset on success)
last_failure_atTIMESTAMPTZNULLABLETimestamp of most recent failure
last_success_atTIMESTAMPTZNULLABLETimestamp of most recent success
opened_atTIMESTAMPTZNULLABLEWhen the circuit last transitioned to OPEN
half_open_atTIMESTAMPTZNULLABLEWhen the circuit last transitioned to HALF-OPEN
updated_atTIMESTAMPTZNOT NULL, DEFAULT now()Last modified timestamp (any state or counter change)

webhook_events

Source: FR-076 (Webhook Handling Specification).

Design decisions:

  • Every webhook received is logged here, regardless of processing outcome. This is an append-only audit log — records are never updated after the final processing_status is set.
  • webhook_id carries a UNIQUE constraint — this is the database-level deduplication guard for duplicate webhook delivery (FR-076.4). The application checks this column before processing; the constraint prevents race conditions.
  • source is always banking_partner in the current schema (ZTL is the only webhook source). This field is included for forward compatibility with additional webhook sources (e.g., a future push notification provider).
  • payload (JSONB) stores the raw validated webhook payload. Storing the raw payload is essential for debugging, DLQ reprocessing, and dispute investigation (a dispute may reference what the banking partner sent).
  • signature stores the X-ZTL-Signature header value. Retained for audit — allows post-incident verification of which webhooks were accepted as authentic.
  • processing_status lifecycle: received (persisted, acknowledgement sent) → processing (background worker started) → completed (state machine updated, notifications sent) or failed (processing error, retry eligible) or dlq (moved to dead-letter queue after 3 failures).
  • processing_attempts tracks retry count. Combined with last_attempt_at, this determines retry eligibility.
  • processing_latency_ms measures from received_at to final HTTP response — the FR-076.8 requirement is ≤5 seconds. This enables alerting on SLA violations.
  • transaction_id is nullable — rejected webhooks (invalid signature, unknown source) do not have a resolvable transaction ID.
  • error_message is null on success, populated on failure.
ColumnTypeConstraintDescription
idUUIDPRIMARY KEYInternal event ID
webhook_idUUIDUNIQUE, NOT NULLwebhook_id from payload — deduplication key
sourceTEXTNOT NULL, DEFAULT 'banking_partner'Webhook origin identifier
event_typeTEXTNOT NULLWebhook event type (e.g., payment.completed, payment.failed)
payloadJSONBNOT NULLRaw validated webhook payload
signatureTEXTNULLABLEX-ZTL-Signature header value as received
received_atTIMESTAMPTZNOT NULL, DEFAULT now()When the webhook arrived at the endpoint
processing_statusTEXTCHECK IN ('received','processing','completed','failed','dlq'), NOT NULL, DEFAULT 'received'Processing lifecycle status
processing_attemptsINTEGERNOT NULL, DEFAULT 0Number of processing attempts made
last_attempt_atTIMESTAMPTZNULLABLETimestamp of most recent processing attempt
processing_latency_msINTEGERNULLABLETime from receipt to final response (milliseconds)
transaction_idUUIDFK → transactions(id), NULLABLEReferenced transaction (null for rejected/unknown webhooks)
error_messageTEXTNULLABLEError detail on failure; null on success
created_atTIMESTAMPTZNOT NULL, DEFAULT now()Record creation timestamp

webhook_dlq

Source: FR-076 (Webhook Handling Specification — Dead Letter Queue).

Design decisions:

  • Separate table from webhook_events by design. The DLQ is for human review; it must be queryable independently without scanning the full event log.
  • webhook_event_id FK links back to the original webhook_events record — all payload, signature, and retry history is accessible via the parent record.
  • reason records the final error message that caused the 3rd retry failure. This is the primary information for manual triage.
  • moved_at is when the event was moved to DLQ — distinct from webhook_events.created_at (original receipt time).
  • reviewed_by and reviewed_at track operator review. An unreviewed DLQ entry has reviewed_by = NULL.
  • resolution workflow: pending (awaiting review) → reprocessed (operator triggered reprocessing and it succeeded) → discarded (operator determined the webhook is invalid and should not be reprocessed).
  • notes captures the operator's explanation — required for audit trail when a payment-related webhook is discarded.
ColumnTypeConstraintDescription
idUUIDPRIMARY KEYInternal DLQ entry ID
webhook_event_idUUIDFK → webhook_events(id), NOT NULLParent webhook event
reasonTEXTNOT NULLFinal error message that caused DLQ promotion
moved_atTIMESTAMPTZNOT NULL, DEFAULT now()When the event was moved to DLQ
reviewed_byTEXTNULLABLEOperator identifier who reviewed
reviewed_atTIMESTAMPTZNULLABLEWhen the operator reviewed
resolutionTEXTCHECK IN ('pending','reprocessed','discarded'), NOT NULL, DEFAULT 'pending'Operator resolution status
notesTEXTNULLABLEOperator explanation for resolution

disputes

Source: FR-077 (Dispute and Refund Process).

Design decisions:

  • One record per dispute. A single transaction can theoretically have multiple disputes (e.g., first dispute denied, user files a new report with additional evidence) — no unique constraint on transaction_id.
  • dispute_reference is a human-readable reference generated at submission time (e.g., DISP-20260226-00001). UNIQUE — used in user communications, Finansklagenemnda referrals, and operator lookup. The format must be deterministic and sortable.
  • status follows the dispute lifecycle state machine from FR-077: submitted → acknowledged → investigating → decided_refund / decided_partial / decided_denied → refund_processing (for refund decisions) → closed.
  • description is the user's free-text description (20-1000 chars per FR-077.2). Stored as-is — not sanitised for search; operators read it directly.
  • date_noticed is the date the user states they noticed the issue. DATE type (not TIMESTAMPTZ) — the user selects a calendar date.
  • evidence_urls (JSONB array) stores S3 URLs for uploaded evidence files (max 3 per FR-077.2). JSONB array chosen over a separate dispute_evidence table because evidence is always queried with the dispute and is bounded (max 3 items).
  • assigned_to stores the compliance officer identifier for the current investigator. Nullable — not assigned until after auto-acknowledgement.
  • investigation_notes and decision_justification are TEXT fields for free-form officer notes. These are legally required documentation under Betalingstjenesteloven § 4-21 (decision must be substantiated).
  • refund_amount is the amount actually refunded in minor units. May differ from the transaction amount (partial refund, liability cap of 450 NOK per FR-077.7).
  • liability_amount is the portion charged to the user (0 for full refund, up to 450 NOK for negligence cases).
  • refund_transaction_id is an FK to the transactions table for the reverse PISP transaction created for the refund. Nullable — only populated after a refund is initiated.
  • finansklagenemnda_referral_sent (BOOLEAN) tracks whether the user was provided the Finansklagenemnda information in the denial notification. Required for compliance with Betalingstjenesteloven § 4-32.
  • Timestamps follow the dispute lifecycle: submitted_at (form submitted), acknowledged_at (auto-acknowledgement sent ≤1h), decided_at (officer decision recorded), closed_at (case fully resolved).
  • do_not_delete (BOOLEAN, DEFAULT TRUE) is set on all dispute records. Data retention jobs MUST check this flag before deletion. Disputes are retained 5 years from closed_at per Betalingstjenesteloven § 3-15 and FR-077.10.
ColumnTypeConstraintDescription
idUUIDPRIMARY KEYInternal dispute ID
transaction_idUUIDFK → transactions(id), NOT NULLThe disputed transaction
user_idUUIDFK → users(id), NOT NULLThe user who filed the dispute
dispute_referenceTEXTUNIQUE, NOT NULLHuman-readable reference (e.g., DISP-20260226-00001)
statusTEXTCHECK IN ('submitted','acknowledged','investigating','decided_refund','decided_partial','decided_denied','refund_processing','closed'), NOT NULL, DEFAULT 'submitted'Lifecycle status
descriptionTEXTNOT NULLUser's description of the issue (20–1000 chars)
date_noticedDATENOT NULLDate user first noticed the unauthorized transaction
evidence_urlsJSONBNULLABLEArray of S3 URLs for uploaded evidence files (max 3)
assigned_toTEXTNULLABLECompliance officer identifier
investigation_notesTEXTNULLABLEOfficer's investigation notes
decision_justificationTEXTNULLABLEOfficer's written justification for decision (required on close)
refund_amountBIGINTNULLABLEAmount refunded in minor units
liability_amountBIGINTNULLABLEAmount charged to user in minor units (0 for full refund, ≤45000 ore for negligence)
refund_transaction_idUUIDFK → transactions(id), NULLABLEFK to the reverse PISP transaction for the refund
finansklagenemnda_referral_sentBOOLEANNOT NULL, DEFAULT FALSEWhether the Finansklagenemnda referral was included in denial notification
submitted_atTIMESTAMPTZNOT NULL, DEFAULT now()When the dispute form was submitted
acknowledged_atTIMESTAMPTZNULLABLEWhen the auto-acknowledgement was sent
decided_atTIMESTAMPTZNULLABLEWhen the officer recorded their decision
closed_atTIMESTAMPTZNULLABLEWhen the case was fully resolved
created_atTIMESTAMPTZNOT NULL, DEFAULT now()Record creation timestamp
updated_atTIMESTAMPTZNOT NULL, DEFAULT now()Last modified timestamp
do_not_deleteBOOLEANNOT NULL, DEFAULT TRUERetention guard — data purge jobs must not delete records with this flag

Constraint Inventory

Table Constraint Type Column(s) Value
users PRIMARY KEY id -
users UNIQUE email -
users NOT NULL email, password_hash, first_name, last_name -
users CHECK kyc_status IN ('pending','approved','rejected')
users CHECK role IN ('user','merchant')
users CHECK risk_level IN ('low','medium','high')
users CHECK pep_status IN ('not_checked','clear','match','pending_review')
users CHECK kyc_method IN ('bankid','document','simplified')
transactions PRIMARY KEY id -
transactions NOT NULL user_id, type, amount -
transactions FK user_id users(id)
transactions FK recipient_id recipients(id)
transactions FK merchant_id merchants(id)
transactions CHECK type IN ('remittance','qr_payment')
transactions CHECK status IN ('processing','completed','failed')
transactions UNIQUE (partial) idempotency_key WHERE idempotency_key IS NOT NULL
merchants PRIMARY KEY id -
merchants UNIQUE org_number -
merchants NOT NULL user_id, business_name, org_number, bank_account, qr_hmac_key -
merchants FK user_id users(id)
bank_accounts PRIMARY KEY id -
bank_accounts NOT NULL user_id, bank_name, account_number -
bank_accounts FK user_id users(id)
recipients PRIMARY KEY id -
recipients NOT NULL user_id, name, country, currency, bank_account -
recipients FK user_id users(id)
sessions PRIMARY KEY id -
sessions NOT NULL user_id, token_hash, expires_at -
sessions FK user_id users(id)
cards PRIMARY KEY id -
cards NOT NULL user_id, last_four, expiry -
cards FK user_id users(id)
cards CHECK type IN ('virtual','physical')
cards CHECK status IN ('active','frozen','cancelled')
settings PRIMARY KEY user_id 1:1 with users
settings FK user_id users(id)
exchange_rates PRIMARY KEY id AUTOINCREMENT
exchange_rates NOT NULL to_currency, rate -
notifications PRIMARY KEY id -
notifications NOT NULL user_id, type, title, body -
notifications FK user_id users(id)
spending_limits PRIMARY KEY id -
spending_limits NOT NULL user_id, limit_type, amount -
spending_limits FK user_id, card_id users(id), cards(id)
rate_limits PRIMARY KEY key IP address
audit_log PRIMARY KEY id -
audit_log NOT NULL action -
audit_log FK user_id users(id) (nullable)
aml_alerts PRIMARY KEY id -
aml_alerts NOT NULL user_id, alert_type, severity -
aml_alerts FK user_id, transaction_id users(id), transactions(id)
aml_alerts CHECK severity IN ('low','medium','high','critical')
aml_alerts CHECK status IN ('open','investigating','resolved','escalated','filed')
str_reports PRIMARY KEY id -
str_reports NOT NULL user_id, report_type -
str_reports FK user_id, alert_id users(id), aml_alerts(id)
str_reports CHECK status IN ('draft','submitted','acknowledged')
screening_results PRIMARY KEY id -
screening_results NOT NULL user_id, screening_type, result -
screening_results FK user_id users(id)
screening_results CHECK screening_type IN ('pep','sanctions','adverse_media')
screening_results CHECK result IN ('clear','match','potential_match','error')
consents PRIMARY KEY id -
consents NOT NULL user_id, consent_type, granted -
consents FK user_id users(id)
data_access_requests PRIMARY KEY id -
data_access_requests NOT NULL user_id, request_type -
data_access_requests FK user_id users(id)
data_access_requests CHECK request_type IN ('export','erasure','rectification','restriction')
data_access_requests CHECK status IN ('pending','processing','completed','rejected')
complaints PRIMARY KEY id -
complaints NOT NULL user_id, category, subject, description -
complaints FK user_id users(id)
complaints CHECK status IN ('received','investigating','resolved','escalated')
reconciliation_reportsPRIMARY KEYid-
reconciliation_reportsNOT NULLreport_date, status, total_drop_transactions, total_partner_transactions, matched_count, discrepancy_count-
reconciliation_reportsCHECKstatusIN ('pending','matched','discrepancies_found')
reconciliation_discrepanciesPRIMARY KEYid-
reconciliation_discrepanciesNOT NULLreport_id, discrepancy_type-
reconciliation_discrepanciesFKreport_idreconciliation_reports(id)
reconciliation_discrepanciesFKtransaction_idtransactions(id) (nullable)
reconciliation_discrepanciesCHECKdiscrepancy_typeIN ('missing_in_partner','missing_in_drop','amount_mismatch','status_mismatch')
reconciliation_discrepanciesCHECKresolution_statusIN ('open','investigating','resolved','accepted')
circuit_breaker_statePRIMARY KEYidAUTOINCREMENT
circuit_breaker_stateUNIQUEdependency_name-
circuit_breaker_stateNOT NULLdependency_name, state, failure_count, updated_at-
circuit_breaker_stateCHECKstateIN ('closed','open','half_open')
webhook_eventsPRIMARY KEYid-
webhook_eventsUNIQUEwebhook_id-
webhook_eventsNOT NULLwebhook_id, source, event_type, payload, received_at, processing_status, processing_attempts, created_at-
webhook_eventsFKtransaction_idtransactions(id) (nullable)
webhook_eventsCHECKprocessing_statusIN ('received','processing','completed','failed','dlq')
webhook_dlqPRIMARY KEYid-
webhook_dlqNOT NULLwebhook_event_id, reason, moved_at, resolution-
webhook_dlqFKwebhook_event_idwebhook_events(id)
webhook_dlqCHECKresolutionIN ('pending','reprocessed','discarded')
disputesPRIMARY KEYid-
disputesUNIQUEdispute_reference-
disputesNOT NULLtransaction_id, user_id, dispute_reference, status, description, date_noticed, submitted_at, created_at, updated_at, do_not_delete, finansklagenemnda_referral_sent-
disputesFKtransaction_idtransactions(id)
disputesFKuser_idusers(id)
disputesFKrefund_transaction_idtransactions(id) (nullable)
disputesCHECKstatusIN ('submitted','acknowledged','investigating','decided_refund','decided_partial','decided_denied','refund_processing','closed')
disputesDEFAULTdo_not_deleteTRUE

Naming Conventions

Convention Rule Examples
Table names Lowercase, plural, snake_case users, bank_accounts, aml_alerts
Column names Lowercase, snake_case user_id, first_name, created_at
Primary keys id (or entity-name for 1:1 tables like settings.user_id) users.id, settings.user_id
Foreign keys {referenced_table_singular}_id user_id, recipient_id, card_id
Timestamps {action}_at suffix created_at, completed_at, withdrawn_at
Boolean flags Descriptive name, INTEGER 0/1 revoked, read, is_primary, granted
Status columns status with CHECK constraint status CHECK(... IN (...))
Indexes idx_{table}_{column} (exception: idx_tx_idempotency uses abbreviation) idx_transactions_user, idx_sessions_token, idx_tx_idempotency
ID prefixes 3-letter prefix + underscore + 16 hex chars usr_, tx_, ba_, mer_, rec_, ses_, con_, cmp_

Normalization Analysis

All tables are in Third Normal Form (3NF) with documented exceptions:

Table NF Level Deviation Justification
users 3NF risk_level, pep_status, sanctions_cleared could be in a separate risk_profile table Accessed on every transaction check. Separate table would add a JOIN to the critical path. 1:1 relationship makes a separate table pointless.
transactions 3NF exchange_rate, send_amount, receive_amount denormalized from exchange_rates Rate at execution time must be preserved immutably. The exchange_rates table changes; the transaction record must not.
transactions 3NF Polymorphic FK (recipient_id OR merchant_id) Simple either/or. A join table or STI would add complexity for no benefit at this scale.
bank_accounts 3NF balance denormalized from external bank (AISP) This is a cache, not authoritative data. Drop cannot modify the real balance.
audit_log 3NF details is unstructured TEXT (JSON) Audit events have variable structure. A normalized schema would require dozens of event-specific tables.

No table violates 2NF (no partial key dependencies) because all tables use single-column primary keys.


Cross-References