Transaction types and statuses

The single table behind every money screen — all 44 transaction types, the ten statuses, which queue acts on which, and the column behaviours that trip operators up.

7 min readUpdated 6 August 2026transactions, ledger, statuses, fees, reference

Every money screen in the platform is a filtered view of one table. Deposits, withdrawals, internal transfers, binary orders, staking, NFT sales, marketplace purchases and your own platform fees are all rows in transaction, separated by one column: type.

That is why the vocabulary is large, and why it is worth learning. An operator reading PLATFORM_FEE and TRADING_FEE as the same thing double-counts their revenue. One reading ORDER_PASSTHROUGH as revenue reports money that belongs to a courier and a tax authority.

The screen

Finance → Transaction Management → Transaction Logs (/admin/finance/transaction) is the unfiltered view.

It is read and delete only. view.transaction loads it, delete.transaction removes rows, and there is no create path at all — no endpoint, no button. Money enters this table by being moved, never by being typed.

The global list. Excludes eight types that have their own screens.
One row.
Soft-delete one. Refuses a COMPLETED row.
Soft-delete a selection. Refuses the whole batch if any row is COMPLETED.

Both delete paths destroy the adminProfit row linked to the transaction before removing it. That is correct — a fee cannot survive the movement it was taken from — but it means a careless clean-up silently reduces your reported revenue.

COMPLETED rows are refused outright: Cannot delete a completed financial transaction. Their balance effect has already happened, so removing the ledger row would leave a balance nothing explains. The bulk endpoint refuses the entire batch if any selected row is COMPLETED, so you are never left guessing which ids went through.

Eight types are hidden from the global list

The list deliberately excludes DEPOSIT, WITHDRAW, INCOMING_TRANSFER, BINARY_ORDER, EXCHANGE_ORDER, FOREX_DEPOSIT, FOREX_WITHDRAW and ICO_CONTRIBUTION, because each has a dedicated admin screen and leaving them in would make this page a duplicate of six others.

The exclusion is lifted when the list is scoped to one user. The customer profile at /admin/crm/user/<id> mounts the same endpoint with a userId filter and is headed "complete transaction history", so it shows everything — there is no per-user Deposits page for the hidden rows to live on instead.

INCOMING_TRANSFER is on that list, which is why an internal transfer appears once here (as its OUTGOING_TRANSFER row) rather than twice.

The type vocabulary

The column accepts 44 values. Grouped by what they mean rather than alphabetically.

Money in and out

Type Meaning
DEPOSIT Funds arriving — gateway, manual or on-chain
WITHDRAW Funds leaving
REFUND A reversal credited back to a customer, including a rejected withdrawal or transfer
PAYMENT The historical catch-all — see the note below
INVOICE Invoice settlement
FAILED A movement that did not happen

PAYMENT used to be the wallet service's fallback for any operation with no type of its own, which is why it simultaneously held customer payments, platform fees, treasury payouts, pass-throughs and bookkeeping anchors on older installs.

It is still the fallback, but it is now loud: an unmapped operation type writes a PAYMENT row and logs Unmapped wallet operationType "<name>" — recording the ledger row as PAYMENT under the WALLET module. A row typed PAYMENT today is a bug report with the name of the missing mapping in it, and it will be invisible to every type-filtered admin view until that is fixed.

Internal transfers

Type Meaning
OUTGOING_TRANSFER The sender's debit leg
INCOMING_TRANSFER The recipient's credit leg

These always come as a pair. One transfer writes both. The outgoing row is written when the transfer is created; the incoming row is written when it settles, which is why a PENDING transfer has an outgoing row and no incoming one. Reports that count both legs double-count every completed transfer. See Internal transfers.

Trading and products

Type Meaning
BINARY_ORDER A binary option stake or payout
EXCHANGE_ORDER Spot order leg — placement, fill or cancel
FUTURES_ORDER Futures order leg
INVESTMENT · INVESTMENT_ROI General investment principal and return
AI_INVESTMENT · AI_INVESTMENT_ROI AI investment addon
FOREX_DEPOSIT · FOREX_WITHDRAW · FOREX_INVESTMENT · FOREX_INVESTMENT_ROI Forex addon
FX_TRADING_DEPOSIT · FX_TRADING_WITHDRAW FX trading accounts
STAKING · STAKING_REWARD Staking principal and yield
ICO_CONTRIBUTION Token-sale contribution
REFERRAL_REWARD Affiliate payout

Marketplaces

Type Meaning
P2P_OFFER_TRANSFER · P2P_TRADE P2P escrow movements
NFT_PURCHASE · NFT_SALE · NFT_MINT · NFT_BURN · NFT_TRANSFER · NFT_AUCTION_BID · NFT_AUCTION_SETTLE · NFT_OFFER NFT marketplace
ECOMMERCE_PURCHASE A store order
MARKETPLACE_PURCHASE · MARKETPLACE_SALE Buyer and seller sides of a marketplace item (trading-bot strategies today)
GATEWAY_PAYMENT A customer paying a merchant through the gateway checkout

The fee family, and the direction of each

This is the group operators misread. All five used to be written as PAYMENT, which is why SUM(amount) WHERE type = 'PAYMENT' on an older install is a number with no meaning: it mixed the payer's debit, the collector's credit, treasury payouts and shipping pass-throughs into one bucket.

Type Direction Whose money
TRADING_FEE Debit A fee paid by a user — exchange and bot trading fees, copy-trading performance fees. It leaves the customer's wallet
PLATFORM_FEE Credit A fee collected by the platform into the Super Admin's wallet. This is the row an adminProfit record points at
PLATFORM_LOSS Debit The treasury paying out — the house funding a customer's win or eating a shortfall. Booked as a negative adminProfit
ORDER_PASSTHROUGH Credit Shipping and tax collected on the operator's behalf. Money you hold, explicitly not profit
ADJUSTMENT_ANCHOR Neither Not a money movement. See below

It lands in the Super Admin's wallet like a fee does, so it inflates the wallet balance on the revenue screen — but it is shipping and tax you owe onward. The row's metadata says so in as many words: shipping and tax collected on behalf of the operator; not profit.

It is deliberately not written to adminProfit, so the booked totals on /admin/finance/profit are right. Only the live wallet balance includes it. Reconcile store orders before you treat that balance as spendable.

ADJUSTMENT_ANCHOR is bookkeeping scaffolding, not a payment. When an admin balance adjustment arrives with no client-supplied idempotency token, the endpoint mints a short-lived PENDING row purely so its id can seed a stable key, then soft-deletes it once the real credit or debit lands. A PENDING ADJUSTMENT_ANCHOR you can still see is the fingerprint of a request that crashed between the two — check the wallet's balance ledger to find out whether the adjustment actually happened, then delete the orphan. It is typed distinctly so it can never be mistaken for a payment the customer never made.

The filter dropdown is not the full list

The Type filter on /admin/finance/transaction offers 24 options and stops at P2P_TRADE. The fee family, the eight NFT types, ECOMMERCE_PURCHASE, FUTURES_ORDER, FX_TRADING_DEPOSIT, FX_TRADING_WITHDRAW, the marketplace pair and ADJUSTMENT_ANCHOR are not in the dropdown even though rows carry them.

The column is searchable, so type the value into search to reach those rows, or call the API with an explicit filter. A type missing from the picker does not mean the platform never writes it.

The ten statuses

Status What it means Who acts on it
PENDING Waiting on a decision or a confirmation. Nothing has moved for a deposit; money has already left the sender for a transfer Deposit, withdrawal and transfer queues
PROCESSING Accepted and in flight — typically a payout mid-broadcast The withdrawal queue only
COMPLETED Settled. Balances have moved Nobody. It cannot be edited or deleted
FAILED The pipeline gave up Nobody
CANCELLED Called off before settlement Nobody
EXPIRED Aged out of its window Nobody
REJECTED Refused by an operator, with a stored reason Nobody
REFUNDED Reversed back to the customer Nobody
FROZEN Held; written by some payment-gateway status mappers No core queue decides one
TIMEOUT An on-chain outcome that never resolved The ecosystem withdrawal reject path accepts it

What a queue will still act on, precisely:

  • Deposits (/admin/finance/deposit/log) — PENDING only.
  • Transfers (/admin/finance/transfer) — PENDING only.
  • Withdrawals (/admin/finance/withdraw/log) — PENDING and PROCESSING. PROCESSING is included on purpose: a payout stuck mid-flight is exactly the row an operator needs to resolve, and excluding it is what used to freeze those indefinitely.

Every settlement handler re-checks the status inside a row lock before it moves money, so two operators approving the same row concurrently cannot both settle it. The loser gets Only pending transactions can be updated.

PUT /api/admin/finance/transaction/{id} writes columns; it does not move money. Marking a DEPOSIT row COMPLETED here would tell the customer their deposit landed, credit them nothing, and permanently lock the real approval route — which refuses any row that is no longer PENDING.

The route now refuses it: a DEPOSIT or WITHDRAW can only be settled from /admin/finance/deposit/log or /admin/finance/withdraw/log.

Column behaviours worth knowing

amount and fee are strings

Both are DECIMAL(36,18). The MySQL driver returns every DECIMAL as a string to avoid floating-point loss, so they reach application code — and any API client you write — as "12.50", not 12.5.

Consequences you will actually meet:

  • Adding two of them concatenates. "10" + "5" is "105".
  • Sorting them without declaring them numeric is lexicographic: "9" sorts after "10".
  • Comparing one against 0 is a string comparison.

The admin screens coerce before they format. If you export or script against this table, do the same.

referenceId is UNIQUE across the whole table

There is a unique index on referenceId, and it is not scoped to a type, a user or a wallet. idempotencyKey carries the same constraint.

The classic failure: a payment intent is recorded with the processor's reference, then the settlement row is written with the same reference. The insert violates the unique index, the request 500s, and the customer is never credited even though their money arrived.

Derive a distinct reference for each row — the platform's own settlement paths append a suffix (<reference>_fee, and the transfer refund keys off the transaction id) for exactly this reason.

metadata is TEXT holding JSON

The column is DataTypes.TEXT, not a JSON type, and the model defines no getter. It arrives as a raw string that every consumer parses by hand.

Two things follow:

  1. Filters and queues cannot look inside it. Every list, count and SLA query in the admin panel matches on real columns — type, status, createdAt, walletId. A rule that depends on something living only in metadata cannot be expressed as a queue filter. If you need to select on a value, it has to be a column. (The one place the platform does reach in is a raw LIKE on the serialised text, hunting for an in-flight adjustment anchor. That is a substring match on a string, not a JSON query, and it does not generalise.)
  2. Malformed JSON degrades to empty, not to an error. Parsers here return {} on anything unparseable, so a corrupted blob makes fields quietly disappear rather than throwing. The view dialog is the exception — it prints Invalid metadata in the destructive colour when it cannot parse.

What lives in there is genuinely load-bearing: a pending transfer's destination wallet and converted amount, the original debit a refund is capped against, and an operator's rejection reason at metadata.message — which is the field the customer's email is composed from.

Other columns

Column Notes
userId · walletId Both required. walletId is the wallet the row acted on — for a transfer debit that is the source wallet
trxId Blockchain transaction hash, where one exists
txHashPending A pre-broadcast hash persisted before confirmation, so a crash mid-broadcast is recoverable
description Free text, shown to the customer
deletedAt The table is soft-delete. A deleted row is hidden from every query but still present

Where to go next