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 useDecimal(36, 2). Decimal precision is enforced at the DB layer — no floats anywhere. - Idempotency is modelled twice: per-domain via unique
idempotencyKeycolumns (Pix, custody), and globally via theIdempotencyKeytable (HTTP-layer dedup inapi-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¶
User — schema.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.
KycRecord — schema.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.
ApiKey — schema.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 |
MfaSecret — schema.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¶
Market — schema.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 |
Order — schema.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 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.
Trade — schema.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¶
Account — schema.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.
LedgerEntry — schema.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:
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¶
LedgerEntry.entryType— no index. Fee-revenue and per-type analytics queries full-scan. Add@@index([entryType, createdAt])if those queries become hot.User.status— no index. Admin-panel/admin/users?status=suspendedfull-scans. Tiny today, will matter at 100k+ users.Order.internalOrderIdis@unique, which implies a unique index. ✅ Good — order-router uses this for engine-event → DB lookups.CustodyTransaction.custodyIdis@unique. ✅ Good.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 pending → broadcasting → confirming → completed |
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.
MarginAccount — schema.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 |
MarginPosition — schema.prisma:312-327¶
One row per open position. M8 derivatives-only fields (leverage, liquidationPrice).
MarginLoan — schema.prisma:329-343¶
One row per borrow event. interest accrues over time (no compounding modelled in schema; will be a margin_interest LedgerEntry stream). status ∈ active/repaid/liquidated.
10. Staking cluster (scaffold for M5)¶
🔴 No live writers. Schema supports the M5 design in docs/staking-system-spec.md.
StakingPosition — schema.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.
Validator — schema.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.
PixDeposit — schema.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: pending → confirmed | expired | failed.
PixWithdrawal — schema.prisma:435-453¶
Outbound Pix. pixKeyType ∈ cpf/cnpj/email/phone/random (Brazilian Pix-key conventions). Status: pending → processing → completed | failed | cancelled.
12. Custody (BitGo) cluster¶
CustodyTransaction — schema.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.
AuditLog — schema.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.
Report — schema.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 + UserBalanceSnapshot — schema.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: pending → processing → verified → published (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) |
16. Related reading¶
- 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.