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.
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.
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) —PENDINGonly. - Transfers (
/admin/finance/transfer) —PENDINGonly. - Withdrawals (
/admin/finance/withdraw/log) —PENDINGandPROCESSING.PROCESSINGis 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
0is 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:
- 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 inmetadatacannot 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 rawLIKEon 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.) - 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
- Working the deposit queue and the withdrawal queue — the two settlement screens.
- Internal transfers — the paired-row queue.
- The wallet balance ledger — the append-only record of what each of these rows did to a balance.
- Reading the revenue screen — how
PLATFORM_FEErows become reported revenue.