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.

9 min readUpdated 11 August 2026wallets, balances, ledger, disputes, runbook

"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

  1. Read the wallet row: balance and inOrder. Filter /admin/finance/wallet to 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.

  2. 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.

  3. Check for open orders. Held funds are the commonest explanation and they are invisible on the wallet row's balance.

  4. Check pending deposits and withdrawals. Money on its way in has not arrived; money on its way out has already left the balance.

  5. 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.

The balance ledger. Filter with {"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: previousBalancenewBalance and previousInOrdernewInOrder, 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 balanceinOrder
RELEASE inOrderbalance
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:

  • status takes ten values: PENDING, COMPLETED, FAILED, CANCELLED, EXPIRED, REJECTED, REFUNDED, FROZEN, PROCESSING, TIMEOUT. Only COMPLETED means the money moved as described. A PENDING withdrawal has nonetheless already reduced the balance.
  • metadata carries 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 — a PENDING deposit 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 — a PENDING or PROCESSING withdrawal 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:

  • balance summed 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.status before 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.