Transactions (the ledger)
Summary
Section titled “Summary”holding_transactions is the append-only ledger of every economic
event Scani has ingested from any source: chain transactions, CEX
trades, statement lines, screenshot extractions, manual entries, plus
synthesised opening_balance rows from
reconciliation. It is strictly additive —
the ledger never overrides holdings.balance, and rows are never
updated or deleted in normal operation. The ledger is what makes
cost-basis math, historical reconstruction, and cross-venue transfer
linking possible.
Schema
Section titled “Schema”Uniqueness: (holdingId, source, externalId). Indexes on
(userId, occurredAt desc), (holdingId, occurredAt desc),
transferGroupId, swapGroupId.
The kind column is intentionally loose — not a Postgres enum —
so new ingesters can introduce new kinds (rebase, slash,
liquidation, …) without a schema migration. Readers must tolerate
unknown kinds.
The current live set, declared as the HoldingTransactionKind union:
Dedup contract
Section titled “Dedup contract”Every ingester must produce a stable externalId per source.
Chain-based ingesters use the transaction hash; exchange ingesters use
the trade ID; statement-CSV ingesters synthesise an id by hashing a
canonical representation of the line. The unique constraint
(holdingId, source, externalId) is what makes re-ingesting safe:
re-running the Binance importer on overlapping date ranges produces
zero duplicates.
Native vs converted prices
Section titled “Native vs converted prices”priceNative is the trade price in the actual quote currency — a
Kraken BTC/EUR fill has priceNative = "67000", priceNativeTokenId = EUR-token-uuid. There is no auto-conversion to USD at write
time. Conversion happens at read time, through the
price graph, so the original quote currency
remains available forever for FX-aware queries.
Append-only in practice
Section titled “Append-only in practice”- The api / worker / data-provider all
INSERT, neverUPDATE, onholding_transactionsin the happy path. The only exception is populatingtransferGroupId/swapGroupIdafter a match — metadata fields that don’t alter the economic event. - Ingesters that re-discover a transaction with a changed payload treat it as a separate (rejected) row rather than overwriting — preserving the audit trail.
rawPayloadis kept indefinitely so the team can re-parse old data when normaliser logic improves.