Skip to content

Transactions (the ledger)

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.

ColumnMeaning
iduuid PK.
userIduuid → users.id.
holdingIduuid → holdings.id. Primary relational anchor — the position this event belongs to. Migration 0054 keyed the ledger on holdingId (was (account_id, token_id)) to allow multi-lot per account.
tokenIduuid → tokens.id. Denormalised from the holding for query ergonomics; ingesters must keep it in sync with the referenced holding’s token.
kindtext. Intentionally loose — see Kinds.
quantitySigned Decimal string. Negative for outflows (sell, withdraw, fee).
priceNativePer-unit price at the time of the event, in its native quote currency.
priceNativeTokenIdThe token of the native quote (e.g. EUR for a Kraken BTC/EUR trade). ON DELETE SET NULL.
counterTokenIdFor trades/swaps: the other side of the transaction. ON DELETE SET NULL.
counterQuantity / counterPriceNative / counterPriceNativeTokenIdThe other-side details.
feeQuantity / feeTokenIdFees in their native token.
occurredAtWhen the event happened per the source — not Scani’s ingest time.
externalIdChain tx hash, exchange trade ID, bank tx ID — the dedup key. NOT NULL so the unique constraint on (holdingId, source, externalId) is meaningful. Ingesters synthesise a stable id when the source doesn’t provide one.
swapGroupIdLinks both legs of a swap.
transferGroupIdLinks a CEX withdraw to a wallet deposit. Populated by the transfer-linking job.
source'binance-api', 'etherscan', 'statement-csv', 'screenshot', 'user-entered', 'reconciliation-opening', ….
sourceMetadata (jsonb)Provider-specific payload context.
rawPayload (jsonb)Original payload for forensics / re-parse after normaliser improvements.
createdAt / updatedAt

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:

KindMeaning
buyTrade where the holding’s token was acquired in exchange for counterToken.
sellTrade where the holding’s token was sold for counterToken.
depositAn inflow from outside the tracked system (or with no matching paired transfer yet).
withdrawAn outflow to outside the tracked system.
transfer_in / transfer_outPaired flows between two tracked accounts. Get a shared transferGroupId when linked.
swap_in / swap_outBoth legs of a single swap. Share a swapGroupId.
feeA fee in the holding’s token.
rewardStaking reward, mining reward, liquidity-mining drop.
interestYield from APY config payouts (see APY & yield).
airdropFree token grant.
opening_balanceSynthesised by the reconciliation flow. The “true starting point” that makes the ledger reconcile with holdings.balance.
unknownFallback when an ingester can’t classify.

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.

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.

  • The api / worker / data-provider all INSERT, never UPDATE, on holding_transactions in the happy path. The only exception is populating transferGroupId / swapGroupId after 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.
  • rawPayload is kept indefinitely so the team can re-parse old data when normaliser logic improves.