Skip to content

07 — Data Model

As of: 2026-05-28 Source of truth: packages/db/prisma/schema.prisma (693 lines, 24 models) Database: PostgreSQL (production: RDS db.t3.medium, dev: docker-compose)

This is the canonical reference for the QuantaTrade Postgres schema as it ships today. Read alongside 03-ledger-accounting.md (ledger semantics) and 01-architecture.md (which services own which tables).


TL;DR

  • 24 models, grouped into 9 clusters. Identity + Trading + Ledger are the load-bearing cores; everything else is supporting (margin, staking, fiat, custody) or operational (audit, reports, proofs).
  • All money is Decimal(36, 18) except fiat-Pix amounts which use Decimal(36, 2). Decimal precision is enforced at the DB layer — no floats anywhere.
  • Idempotency is modelled twice: per-domain via unique idempotencyKey columns (Pix, custody), and globally via the IdempotencyKey table (HTTP-layer dedup in api-gateway).
  • Status fields are Postgres enums — 14 of them, one per state machine. Drift between enum values and TypeScript union types is a real maintenance risk; the ledger entry-type union/enum is the most-touched.
  • Populated-today vs. scaffold: the Identity, Trading, Ledger, Custody (mostly) clusters are populated by running services. Margin, Staking, Insurance Fund, ProofOfReserve are scaffold for M5/M8.

1. Clusters at a glance

Cluster Models Owner service Populated today?
Identity User, KycRecord, ApiKey, MfaSecret api-gateway, kyc-service (planned) 🟢 Yes (User, ApiKey, MfaSecret) / 🟡 KycRecord scaffold
Trading Market, Order, Trade api-gateway → order-router → matching-engine → Postgres 🟢 Yes
Ledger Account, LedgerEntry ledger-service 🟢 Yes (deposit + trade paths)
Idempotency (global) IdempotencyKey api-gateway 🟢 HTTP layer only
Margin MarginAccount, MarginPosition, MarginLoan (none yet) 🔴 Scaffold for M8
Staking StakingPosition, Validator (none yet) 🔴 Scaffold for M5
Fiat (Pix) PixDeposit, PixWithdrawal fitbank-adapter (planned) 🔴 Scaffold for M3
Custody CustodyTransaction custody-service (planned) 🟡 Schema in use; populating service is M2
Compliance & reporting AuditLog, Report, ProofOfReserve, UserBalanceSnapshot report-service (planned) 🔴 Scaffold
Insurance fund InsuranceFund, InsuranceFundTransaction (none yet) 🔴 Scaffold for M8

2. Identity cluster

Userschema.prisma:14-44

Field Type Notes
id cuid, PK
email string, @unique
passwordHash string?, nullable Null when Keycloak is the auth source
keycloakId string?, @unique Sub-claim from JWT
userType enum UserType normal, corporate, admin_ro, admin
status enum UserStatus active, suspended, banned
kycLevel enum KycLevel none, basic, intermediate, advanced
marginEnabled bool Gate for M8 margin features
stakingEnabled bool Gate for M5 staking
parentUserId string?, FK self Corporate sub-account relation — see §7.1
createdAt / updatedAt timestamps

Relations: 1:N to Account, Order, Trade, KycRecord, PixDeposit, PixWithdrawal, StakingPosition, MarginAccount, ApiKey; 1:1 to MfaSecret; self-1:N (subAccounts).

Note: User has no index on kycLevel or status despite UI flows that filter by these. With low user counts this is fine; as we grow past a few thousand active users, consider @@index([status, kycLevel]) for admin-panel queries.

KycRecordschema.prisma:70-83

Stores Sumsub applicant linkage and review state. sumsumApplicantId (note the typo — sumsum, not sumsub; preserve until a migration corrects it) is the join key. reviewResult is freeform JSON for Sumsub's webhook payload.

ApiKeyschema.prisma:538-557

Field Type Notes
publicKey string @unique Returned to user; used as HMAC keyId
hashedSecret string bcrypt of the secret; secret is shown once on creation
scopes string[] Default ["READ"]; valid values: READ, TRADE, WITHDRAW
ipWhitelist string[] Empty = no IP restriction
expiresAt timestamp? Optional expiry
lastUsedAt timestamp? Set on each auth

MfaSecretschema.prisma:563-576

Field Type Notes
userId string @unique 1:1 with User
secret string Encrypted TOTP secret
iv string AES-GCM IV
backupCodes string[] Hashed
enabled bool False until user verifies one TOTP code

3. Trading cluster

Marketschema.prisma:160-179

Field Type Notes
symbol string @unique e.g. BTC-USDT
baseAsset, quoteAsset string Derived from symbol but stored for query
pricePrecision, quantityPrecision int Decimal places allowed
minQuantity, maxQuantity, minNotional Decimal(36,18) Pre-trade validation in risk-checker
status enum MarketStatus active, halted, maintenance
marginEnabled bool Gate for cross/isolated margin per symbol

Orderschema.prisma:191-224

Field Type Notes
id cuid App-side ID
internalOrderId BigInt? @unique Matching engine's internal ID — null for orders that never reached the engine (e.g. rejected pre-trade)
clientOrderId string? Customer-supplied idempotency hint
side enum OrderSide buy, sell
type enum OrderType market, limit, stop_limit, stop_market
status enum OrderStatus pending, open, partially_filled, filled, cancelled, rejected
quantity, price, stopPrice, filledQuantity, avgFillPrice Decimal(36,18)
timeInForce enum TimeInForce gtc, ioc, fok
marginMode enum MarginMode? cross, isolated, or null (spot)

Indexes (schema.prisma:215-222):

@@index([userId, status])               // open-orders panel
@@index([marketId, status])             // admin / market-data
@@index([clientOrderId])                // client-side idempotency lookup
@@index([userId, createdAt(sort: Desc)]) // order history
@@index([marketId, createdAt(sort: Desc)])
@@index([userId, marketId, status])     // user's open orders for a market

Status state machine:

pending → open → partially_filled → filled
              \                  /
               → cancelled / rejected

pending is the brief window between API accept and engine acknowledge. open is the common resting state. Market and IOC orders skip open and go straight to filled/partially_filled or cancelled.

Tradeschema.prisma:262-287

One row per side per fill. A single matching engine fill produces two Trade rows (one for buyer, one for seller).

Field Type Notes
id cuid
orderId FK → Order The order on this side
userId FK → User Denormalised for query speed
marketId FK → Market Denormalised
side enum OrderSide This side's side
price, quantity, fee Decimal(36,18)
feeAsset string Base for sell, quote for buy (per 03-ledger-accounting.md §3.5)
isMaker bool For fee-tier accounting

Indexes: (userId, createdAt DESC), (marketId, createdAt DESC), (orderId), (userId, marketId, createdAt DESC). Solid coverage for the trade-history queries we run today.

Note: there is no tradeId/matching-engine-trade-id linking the two sides of a fill. Reconstruction of "who traded with whom" today requires joining on (marketId, price, quantity, createdAt) and matching buy with sell. This is a real reporting wart — consider adding engineTradeId as a non-unique indexed field that both sides share.


4. Ledger cluster

Accountschema.prisma:97-112

Field Type Notes
userId, asset, type composite Unique together (@@unique([userId, asset, type]))
type enum AccountType spot, margin_cross, margin_isolated, staking
available, locked Decimal(36,18) Both default 0

Only spot accounts are populated today. margin_* accounts await M8; staking accounts await M5.

The unique constraint on (userId, asset, type) is the basis for getOrCreateAccount race-safety — see 03 §7.

LedgerEntryschema.prisma:121-138

Full reference in 03-ledger-accounting.md §2.

Field Type Notes
transactionId string, indexed but not unique App-level idempotency key (see 03 §4.1)
accountId FK → Account
amount Decimal(36,18) Signed: positive=credit, negative=debit
balance Decimal(36,18) Running balance after entry (caveats in 03 §8)
entryType enum LedgerEntryType 13 values, see below
referenceType, referenceId string The business event (e.g. 'trade', 'tradeId123')

13 LedgerEntryType enum values (schema.prisma:140-154):

deposit, withdrawal, trade, fee, transfer,
margin_borrow, margin_repay, margin_interest, liquidation,
staking_lock, staking_unlock, staking_reward,
adjustment

Today only deposit, trade, fee are written.

Indexes:

@@index([transactionId])
@@index([accountId, createdAt])
@@index([referenceType, referenceId])

The [accountId, createdAt] index supports per-account running-balance reads. [referenceType, referenceId] supports "all entries for trade X" queries — the recommended fix in 03 §4.2 leverages this index.

Missing index worth flagging: there is no index on entryType alone. A query for "all fee entries in the last 24h" would full-scan. Acceptable today, becomes a problem at scale.

ER diagram — Trading + Ledger

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
erDiagram
  USER ||--o{ ACCOUNT     : owns
  USER ||--o{ ORDER       : places
  USER ||--o{ TRADE       : has
  MARKET ||--o{ ORDER     : on
  MARKET ||--o{ TRADE     : on
  ORDER ||--o{ TRADE      : produces
  ACCOUNT ||--o{ LEDGER_ENTRY : records

  USER {
    string id PK
    string email UK
    enum   userType
    enum   status
    enum   kycLevel
    string parentUserId FK "self → sub-accounts"
  }
  ACCOUNT {
    string id PK
    string userId FK
    string asset
    enum   type
    decimal available
    decimal locked
  }
  LEDGER_ENTRY {
    string  id PK
    string  transactionId "indexed, not unique"
    string  accountId FK
    decimal amount   "signed"
    decimal balance  "running"
    enum    entryType
    string  referenceType
    string  referenceId
    timestamp createdAt
  }
  ORDER {
    string  id PK
    string  userId FK
    string  marketId FK
    bigint  internalOrderId UK "matching engine id"
    enum    side
    enum    type
    enum    status
    decimal quantity
    decimal price
  }
  TRADE {
    string  id PK
    string  orderId FK
    string  userId FK
    string  marketId FK
    enum    side
    decimal price
    decimal quantity
    decimal fee
    string  feeAsset
    bool    isMaker
  }
  MARKET {
    string  id PK
    string  symbol UK
    string  baseAsset
    string  quoteAsset
    enum    status
  }

5. Idempotency model

Two separate idempotency surfaces:

5.1 IdempotencyKey — HTTP-layer

schema.prisma:683-693. Populated by services/api-gateway/src/guards/idempotency.guard.ts via the Idempotency-Key header on POST/PUT/DELETE.

Field Type Notes
key string @unique Client-supplied UUID/string
response Json? Cached response body
statusCode int? Cached HTTP status
expiresAt timestamp TTL (typically 24h)

The guard actually uses Redis as the primary store (buildKey('exchange:idempotency:{userId}:{key}')api-gateway/src/guards/idempotency.service.ts:101-102). The Postgres table appears to be a longer-term backup or audit surface, but isn't populated by the live request path. Verify before relying on it.

5.2 LedgerEntry.transactionId — service-layer

App-level guard inside ledger.credit / ledger.debit (see 03 §4.1). Not enforced by a DB unique constraint, which is a real correctness gap if ledger-service is ever scaled horizontally.

5.3 Domain-specific idempotencyKey columns

Three tables embed their own idempotencyKey String @unique column:

  • PixDeposit.idempotencyKey (schema.prisma:417)
  • PixWithdrawal.idempotencyKey (schema.prisma:443)
  • CustodyTransaction.idempotencyKey (schema.prisma:486)

These are populated by the originating service when issuing the request to the external provider (FitBank, BitGo) and prevent duplicate external calls. They are independent of the HTTP-layer table above.


6. Indexes — what's there, what's missing

Notable indexes in the schema

ORDERS:        (userId, status), (marketId, status), (clientOrderId),
               (userId, createdAt desc), (marketId, createdAt desc),
               (userId, marketId, status)
TRADES:        (userId, createdAt desc), (marketId, createdAt desc),
               (orderId), (userId, marketId, createdAt desc)
LEDGER_ENTRY:  (transactionId), (accountId, createdAt), (referenceType, referenceId)
STAKING_POS:   (userId, status)
PIX_DEPOSIT:   (userId, status), (fitbankTransactionId)
PIX_WITHDRAW:  (userId, status), (fitbankTransactionId)
CUSTODY_TX:    (userId, status), (txHash)
AUDIT_LOG:     (userId, createdAt), (entityType, entityId), (action, createdAt)
REPORT:        (type, date)
POR:           (timestamp), (status)
USER_BAL_SNAP: (porId), (userId)
INS_FUND_TX:   (fundId, createdAt)
IDEMPOTENCY:   (expiresAt)        -- for TTL sweeper

Missing-but-arguable indexes

  1. LedgerEntry.entryType — no index. Fee-revenue and per-type analytics queries full-scan. Add @@index([entryType, createdAt]) if those queries become hot.
  2. User.status — no index. Admin-panel /admin/users?status=suspended full-scans. Tiny today, will matter at 100k+ users.
  3. Order.internalOrderId is @unique, which implies a unique index. ✅ Good — order-router uses this for engine-event → DB lookups.
  4. CustodyTransaction.custodyId is @unique. ✅ Good.
  5. LedgerEntry.transactionId — indexed but NOT unique. Real gap. See 03 §4.1.

7. Notable nullable fields & their meaning

Distinguishing "optional by design" from "missing data" matters for correct rendering and analytics.

7.1 Corporate sub-accounts (self-relation)

User.parentUserId (schema.prisma:24, nullable) — when set, this user is a sub-account of parentUserId (a corporate userType). Self-relation defined in schema.prisma:40-41:

parentUser   User?  @relation("CorporateSubAccounts", fields: [parentUserId], references: [id])
subAccounts  User[] @relation("CorporateSubAccounts")

Null = top-level user. Not yet exposed in the API; M9 institutional deliverable.

7.2 Other notable nullables

Field Nullable means
User.passwordHash Null when Keycloak owns auth — user logs in via SSO only
User.keycloakId Null when password-only — legacy or M1 dev accounts
Order.price Null on market orders (no limit price)
Order.stopPrice Null unless type is stop_*
Order.avgFillPrice Null until at least one fill
Order.internalOrderId Null if pre-engine reject (risk-check failure)
Order.marginMode Null for spot orders
MarginAccount.symbol Null for cross margin; set for isolated
MarginAccount.liquidationPrice Null when zero positions
StakingPosition.unbondingEndsAt Null until unbonding starts
CustodyTransaction.{sourceAddress, destinationAddress, txHash, completedAt} Set incrementally as the tx progresses through pendingbroadcastingconfirmingcompleted
KycRecord.sumsumApplicantId Null until Sumsub assigns one
KycRecord.reviewResult Null until Sumsub returns a decision

7.3 Optional-by-design vs missing-data

Per CLAUDE.md rule 19: a backend null should render as "Unknown" or "—" in the UI, not as false or 0. The fields above are expected to be null in certain states; rendering must distinguish "not yet" from "no".


8. Status fields (Postgres enums)

Enum Values Used by
UserType normal, corporate, admin_ro, admin User
UserStatus active, suspended, banned User
KycLevel none, basic, intermediate, advanced User, KycRecord
KycVerificationStatus not_started, pending, approved, rejected, retry KycRecord
AccountType spot, margin_cross, margin_isolated, staking Account
LedgerEntryType 13 values (see §4) LedgerEntry
MarketStatus active, halted, maintenance Market
OrderSide buy, sell Order, MarginPosition, Trade
OrderType market, limit, stop_limit, stop_market Order
OrderStatus pending, open, partially_filled, filled, cancelled, rejected Order
TimeInForce gtc, ioc, fok Order
MarginMode cross, isolated Order, MarginAccount
LoanStatus active, repaid, liquidated MarginLoan
StakingStatus active, unbonding, unstaked StakingPosition
ValidatorStatus active, inactive, jailed Validator
PixDepositStatus pending, confirmed, expired, failed PixDeposit
PixWithdrawalStatus pending, processing, completed, failed, cancelled PixWithdrawal
PixKeyType cpf, cnpj, email, phone, random PixWithdrawal
CustodyTransactionType deposit, withdrawal, internal CustodyTransaction
CustodyTransactionStatus pending, queued, pending_authorization, broadcasting, confirming, completed, failed, cancelled CustodyTransaction
PoRStatus pending, processing, verified, published, failed ProofOfReserve
InsuranceFundTxType funding, liquidation_profit, liquidation_loss, claim InsuranceFundTransaction

Operational note: adding a value to a Postgres enum requires ALTER TYPE ... ADD VALUE, which is non-transactional in older Postgres versions. Plan migrations carefully. Removing a value requires renaming + table-rewrite.


9. Margin cluster (scaffold for M8)

🔴 No live writers today. Schema is laid out so M8 work doesn't need migrations.

MarginAccountschema.prisma:293-310

One per user per (mode, symbol?). mode = cross → one shared account (symbol null). mode = isolated → one account per symbol. Composite @@unique([userId, mode, symbol]).

Field Notes
equity Decimal(36,18) — total account value at current marks
debt Decimal(36,18) — outstanding borrowed asset
liquidationPrice Decimal(36,18)? — current mark-to-liquidation distance

MarginPositionschema.prisma:312-327

One row per open position. M8 derivatives-only fields (leverage, liquidationPrice).

MarginLoanschema.prisma:329-343

One row per borrow event. interest accrues over time (no compounding modelled in schema; will be a margin_interest LedgerEntry stream). statusactive/repaid/liquidated.


10. Staking cluster (scaffold for M5)

🔴 No live writers. Schema supports the M5 design in docs/staking-system-spec.md.

StakingPositionschema.prisma:355-373

One per user per staking event. apy snapshotted at stake time. rewardsEarned accumulates as staking_reward LedgerEntries are written.

unbondingEndsAt is null until status transitions to unbonding.

Validatorschema.prisma:381-394

External validators (Lido, Coinbase Custody, etc.) the platform stakes through. commission is their take; apy is gross.


11. Fiat (Pix) cluster — Brazilian rail

🔴 Scaffold for M3. Brazilian-market-only — Pix is Brazil's instant-payment rail; FitBank is the gateway.

PixDepositschema.prisma:406-426

Field Notes
amount Decimal(36, 2) — fiat precision, not crypto
qrCode, qrCodeBase64 Generated QR for the user to scan
expiresAt Pix QRs expire (typically 30 min)
fitbankTransactionId Set on FitBank confirmation
idempotencyKey @unique — domain-specific guard

Status state machine: pendingconfirmed | expired | failed.

PixWithdrawalschema.prisma:435-453

Outbound Pix. pixKeyTypecpf/cnpj/email/phone/random (Brazilian Pix-key conventions). Status: pendingprocessingcompleted | failed | cancelled.


12. Custody (BitGo) cluster

CustodyTransactionschema.prisma:475-494

Field Notes
custodyId @unique — BitGo's transaction ID
type deposit, withdrawal, internal
txHash On-chain hash (null until broadcast)
sourceAddress, destinationAddress Set as known
idempotencyKey @unique — for the BitGo API request
status 8-state machine (see §8)

Indexed on txHash — supports the deposit-sync handler's lookup-by-hash pattern (see 03 §5).

🟡 Schema is in use; the custody-service that fills it is M2 work. Today, deposit-sync tests publish synthetic custody.deposit.confirmed NATS events without writing to CustodyTransaction — a real gap closed by M2.


13. Compliance & reporting cluster

🔴 All scaffold; no live writers today.

AuditLogschema.prisma:517-532

Field Notes
userId Nullable — system actions have no user
action Free-form string ('order.cancel', 'kyc.upgrade', …)
entityType, entityId What was acted upon
metadata Json — additional context
ipAddress, userAgent For security-event correlation

Three indexes: (userId, createdAt), (entityType, entityId), (action, createdAt). Good coverage; expected hot reads are per-user history and per-entity history.

Reportschema.prisma:582-593

Reporting outputs (daily summary, PnL, PoR, etc.). UUID PK (the only model using UUID instead of cuid — preserved as-is, possibly a holdover from an earlier service). Indexed on (type, date).

ProofOfReserve + UserBalanceSnapshotschema.prisma:599-639

PoR Merkle-tree model. One ProofOfReserve row = one snapshot in time, with the Merkle root + total assets/liabilities/reserve-ratio. Each user's leaf is stored in UserBalanceSnapshot with the merkleProof array.

Status: pendingprocessingverifiedpublished (or failed).

🔴 Generator is not implemented. M5+ deliverable; spec in docs/m5-token-utility-plan.md.


14. Insurance fund (scaffold for M8)

InsuranceFund — one row per asset. balance vs targetBalance drives top-up logic. InsuranceFundTransaction — append-only event stream into/out of the fund. Types:

funding             — manual top-up
liquidation_profit  — profit from a forced liquidation
liquidation_loss    — fund covers a loss
claim               — manual payout

Indexed on (fundId, createdAt).

🔴 No live writers. M8 work.


15. Populated today vs. scaffold — at a glance

Model Status First populated by
User 🟢 api-gateway signup
ApiKey 🟢 api-gateway /api-keys
MfaSecret 🟢 api-gateway /mfa/setup
KycRecord 🟡 scaffold (Sumsub wiring is M4)
Account 🟢 ledger-service auto-create on first credit
LedgerEntry 🟢 ledger-service (deposit + trade paths)
Market 🟢 seeded via Prisma migration
Order 🟢 order-router persists on engine ack
Trade 🟢 order-router persists on engine trade event
CustodyTransaction 🟡 schema only; custody-service M2
IdempotencyKey 🟡 guard uses Redis primarily; table populated optionally
PixDeposit/Withdrawal 🔴 scaffold (M3)
MarginAccount/Position/Loan 🔴 scaffold (M8)
StakingPosition/Validator 🔴 scaffold (M5)
AuditLog 🔴 scaffold
Report 🔴 scaffold
ProofOfReserve / UserBalanceSnapshot 🔴 scaffold (M5+)
InsuranceFund / InsuranceFundTransaction 🔴 scaffold (M8)

  • 01-architecture.md — which services own which tables.
  • 03-ledger-accounting.md — ledger semantics, idempotency, deposit-sync flow.
  • 02-trading-system.md — order lifecycle that produces Order/Trade rows.
  • 08-event-bus.md — NATS event names that mirror these tables.
  • docs/staking-system-spec.md — M5 staking design.
  • docs/m5-token-utility-plan.md — M5 PoR + buyback + revenue router.

Last updated 2026-05-28. Update whenever packages/db/prisma/schema.prisma changes — bump the file's "as of" date and refresh the affected cluster.