A customer disputes their balance — reconstructing a wallet
Which column is the truth, why held funds are not missing funds, where the ledger actually is, and the five checks that answer "my balance is wrong" before you touch anything.
"My balance is wrong" is a weekly ticket and it is almost never wrong. It is usually held funds read as missing funds, a customer looking at a different wallet than the one they funded, or a deposit that has not finished landing.
The point of this runbook is to make you prove which of those it is before you adjust anything, because a hand adjustment made on a misreading is real money out of your float and there is no undo.
An adjustment commits immediately, with no approval step and no reversal. If the pipeline you did not check then completes on its own, the customer is credited twice — the adjustment and the deposit carry different idempotency keys and neither knows about the other.
Finish all five checks below. Only then read Refunding and adjusting, safely.
The two numbers, and which one the customer is quoting
Finance → Transaction Management → Wallets (/admin/finance/wallet,
access.wallet) lists every wallet: one row per customer, per currency, per
type (FIAT, SPOT, ECO, FUTURES, COPY_TRADING). You cannot create or
delete one from this screen. You can adjust a balance and you can freeze the
wallet, and that is all.
Two money columns, and they are different things:
| Column | Meaning |
|---|---|
balance |
Available. Spendable, withdrawable, and the only figure an admin SUBTRACT can draw on |
inOrder |
Held. Locked against an open order, an escrow or a payout in flight |
The total a customer sees is balance + inOrder. No column shows it; the
view dialog and the wallet detail page compute it. So a customer who says "I had
500 and now I have 200"
and an operator reading balance: 200 are frequently both right, with 300 sitting
in inOrder behind a resting order.
Reporting one as the other is the single most common misreading on this screen. Read both, every time.
balance and inOrder are DECIMAL(36,18), and the driver hands them back as
strings. "200" + "300" is "200300", not 500.
That matters the moment you leave the admin panel — a quick SQL check, a script, an API client. Parse before you add. The view dialog computes its Total correctly; your ad-hoc query will not unless you make it.
Wallet rows are created automatically on first use, so most of them are empty
shells. A customer with USDT on SPOT and on ECO has two rows that look
nearly identical in the table — check the type as well as the currency before
you conclude anything is missing.
The order to work in
-
Read the wallet row:
balanceandinOrder. Filter/admin/finance/walletto the customer, or open the Wallets tab on/admin/crm/user/{id}, which is the same table already scoped to them. Note the type and currency of every funded row, not just the one they mentioned. -
Read the ledger for that wallet — the balance ledger, not the transaction list. This is where the arithmetic is. View on the wallet row opens
/admin/finance/wallet/{id}; its Audit Trail tab is that ledger scoped to the one wallet, with the staff actions taken against it stacked above. Work from there for the rest of this runbook: if step 5 concludes an adjustment is owed, the control is in that page's header. -
Check for open orders. Held funds are the commonest explanation and they are invisible on the wallet row's
balance. -
Check pending deposits and withdrawals. Money on its way in has not arrived; money on its way out has already left the balance.
-
Only then conclude the balance is wrong. If the ledger's arithmetic is internally consistent and nothing is held or in flight, you have a real discrepancy and it needs a written explanation before it needs an adjustment.
Step 2 — the balance ledger is the record that answers this
Every balance change the wallet service has ever made is written to an append-only ledger, one row per operation.
{"walletId":"…"} for one wallet or {"userId":"…"} for a customer's whole trail. There is no write side.Each row carries the operation, the amount, and both before-and-after pairs:
previousBalance → newBalance and previousInOrder → newInOrder, plus the
linked transaction id and the idempotency key. The eight operations are the whole
vocabulary of money movement on this platform:
| Operation | Moves |
|---|---|
WALLET_CREATED |
Nothing — the row's birth |
CREDIT |
Into balance |
DEBIT |
Out of balance |
HOLD |
balance → inOrder |
RELEASE |
inOrder → balance |
EXECUTE_FROM_HOLD |
Out of inOrder — the held funds actually spent |
TRANSFER_OUT / TRANSFER_IN |
The two legs of an internal transfer |
Read it as arithmetic. Each row's previousBalance should be the previous row's
newBalance. A HOLD followed by no matching RELEASE or EXECUTE_FROM_HOLD is
your missing money, and it is not missing — it is locked.
You reach it from the Audit Trail tab of the wallet detail page
(/admin/finance/wallet/{id}), of any deposit, withdrawal or transfer detail
page, and of the customer's own page — each showing the administrative actions
taken against that record alongside this ledger with the arithmetic beside each
movement. The wallet page is the narrowest of them and the one to use here: one
wallet, one currency, and the adjustment control in the same header.
This is the only record that can answer "was this customer credited once or twice", because it is the only one that carries the balance before and after.
The transaction list is not the whole ledger
Finance → Transaction Management → Transaction Logs
(/admin/finance/transaction, access.transaction plus view.transaction) is
read and delete only — no create, no edit button. Its columns are id, user,
wallet.currency, type, status, amount, fee, description,
referenceId, createdAt and metadata.
DEPOSIT, WITHDRAW, INCOMING_TRANSFER, BINARY_ORDER, EXCHANGE_ORDER,
FOREX_DEPOSIT, FOREX_WITHDRAW and ICO_CONTRIBUTION are excluded from
/admin/finance/transaction by default, because each has its own dedicated
screen and leaving them in would make this table a duplicate of six others.
The exclusion is lifted only when the request is scoped to a single user. So for
a balance dispute, do not work from the global screen — open the
Transactions tab on /admin/crm/user/{id}, which mounts the same endpoint
with the customer's id and therefore returns their complete history, deposits
and withdrawals included.
This catches out even experienced operators, because a previous admin's manual
balance adjustment is filed as a DEPOSIT or a WITHDRAW — the wallet service
maps ADMIN_ADJUSTMENT_CREDIT onto DEPOSIT and ADMIN_ADJUSTMENT_DEBIT onto
WITHDRAW. So the one row most likely to explain an unexplained balance is
exactly the one the global screen hides.
Two columns to read carefully:
statustakes ten values:PENDING,COMPLETED,FAILED,CANCELLED,EXPIRED,REJECTED,REFUNDED,FROZEN,PROCESSING,TIMEOUT. OnlyCOMPLETEDmeans the money moved as described. APENDINGwithdrawal has nonetheless already reduced the balance.metadatacarries whatever the pipeline supplied — the gateway's own status, a chain and address, a reconciler's notes, the operator's rejection message.
The transaction table carries a unique index on referenceId, so the same
external reference cannot appear on two rows. If you find yourself looking at
what seems to be a duplicate payment sharing one reference, it is not two
payments — it is one row and a bug elsewhere, and crediting for it would mint
money.
idempotencyKey carries a unique index too, and it is what makes a retry a
no-op rather than a second credit.
Step 3 — open orders hold funds
An order that is still OPEN is holding the customer's money in inOrder. That
is the answer to a surprising share of these tickets, and the customer genuinely
cannot see it as "their" money on a balance figure.
| Screen | Path | Permission |
|---|---|---|
| Exchange orders | /admin/finance/order/exchange |
access.exchange.order |
| Ecosystem orders | /admin/finance/order/ecosystem |
access.ecosystem.order |
| Binary orders | /admin/finance/order/binary |
access.binary.order |
| Futures orders | /admin/finance/order/futures |
access.futures.order |
All four are read-only tables — no create, no edit, and only the exchange screen
is even filterable by status in the obvious way. Filter to OPEN and match
the held amount against inOrder.
Exchange and ecosystem orders store the same five statuses — OPEN, CLOSED,
CANCELED, EXPIRED, REJECTED — and both spell the cancelled one
CANCELED with one L, unlike the transaction table above, which spells its
own CANCELLED with two. The trap is the ecosystem screen's status filter: its
dropdown offers CANCELLED, which matches no stored row, so picking it returns
an empty table. That is the filter being wrong, not the orders being missing —
read the status column on the row instead.
Step 4 — money in flight
/admin/finance/deposit/log— aPENDINGdeposit has credited nothing yet. If the customer has paid and it is still pending, you are in A customer paid and the balance did not move, not in a balance dispute./admin/finance/withdraw/log— aPENDINGorPROCESSINGwithdrawal has already been debited. The wallet is reduced at submit time, not at approval. That is exactly the "my balance dropped and I got nothing" ticket, and the answer is A withdrawal is stuck.
Both queues open filtered to PENDING, oldest first, so clear the filter when
you are searching for one customer's row rather than working the queue.
What is not evidence
The customer's own dashboard shows a profit-and-loss figure derived from the
walletPnl history. Three separate code paths write that row — a scheduled
job, the wallet list endpoint and the wallet stats endpoint — and the row is a
daily snapshot valued in USD, not a movement record.
So a PnL figure that moved while the balance did not is ordinary: the price changed. A PnL figure that did not move while the balance did is also ordinary: the snapshot for the day already existed. Neither is evidence of a credit, a debit, or a missing one. Use the balance ledger, which records the balance before and after every operation, and nothing else.
Two more figures customers and operators both misread:
balancesummed across currencies is meaningless. One BTC and one USDT do not add to two of anything. Only figures explicitly converted to USD can be added, and on the wallet analytics strip that is Total custodial liability, Total balance and In order — not Funds in disabled wallets, which is a raw cross-currency sum. See Customer wallets and balance adjustments.- A frozen wallet's balance is untouched and still visible to the customer.
Freezing refuses every operation on the wallet; it does not take anything away.
If a customer reports that their funds are "locked", check
wallet.statusbefore you look for a hold.
When it really is wrong
If the ledger's arithmetic is consistent, nothing is held, nothing is in flight, and the customer's figure still does not reconcile — write down what you found first. The description you type on an adjustment is the only thing that will ever explain it to an auditor, to your accountant, or to you in six months.
Then go to Refunding and adjusting, safely and use one
of the three sanctioned mechanisms. Do not free-hand a balance change from a SQL
client: the wallet service is what writes the ledger row, and a direct UPDATE
on the wallet table changes the number while leaving no record of why, which is
how a reconcilable platform stops being one.
Related
- Customer wallets and balance adjustments — the wallet screen in full: the analytics tiles, freezing, and why a wallet cannot be deleted while it holds funds.
- Refunding and adjusting, safely — the three mechanisms and the rules around each.
- A withdrawal is stuck and A customer paid and the balance did not move — the two pipelines that most often present as a balance problem.
- The user desk — the customer's own page and what each of its tabs holds.