I need to refund or adjust someone — safely
The three mechanisms that move money back to a customer, which one is right, the idempotency and reason rules around each, and what each leaves in the ledger and the audit trail.
Manual balance adjustment is the one admin action that mints money. There is no approval step, no queue, no second signature and no undo. It commits the moment you press the button.
That is why this page exists, and why it starts with the decision rather than the button: most of the time the correct answer is not an adjustment, and the mechanism that already exists for the case in front of you refunds the exact right amount, emails the customer and writes its own ledger row without you having to get any of it right by hand.
Every mechanism here is safe only if the diagnosis is. Work A customer disputes their balance first — held funds read as missing funds and in-flight withdrawals read as disappeared money, and both produce a refund request that would be a straight loss.
Which mechanism
| Situation | Use | Moves money? |
|---|---|---|
| A withdrawal that has not settled, and should not | Reject it in the withdrawal queue | Yes — automatic refund |
| A deposit that has not been credited, and should not be | Reject it in the deposit queue | No — nothing was credited |
| Money genuinely owed that no pipeline will ever settle | Adjust the wallet balance | Yes — you are minting or burning |
| A record that says the wrong thing but the money is right | Edit the transaction | No |
| You are still investigating and want movement to stop | Freeze the wallet | No |
Work down that list. Reaching for the adjustment when a rejection would have done the job costs you the automatic refund of the exact amount, the automatic email and the ledger row linked to the original transaction — and gains you nothing.
(a) Reject a withdrawal — the platform refunds for you
/admin/finance/withdraw/log opens filtered to PENDING, oldest first. Both the
row ⋯ menu and the bulk menu offer Approve and Reject, and both are
built from one configuration so they can never disagree about which rows are
decidable.
From the queue, Reject accepts PENDING and PROCESSING, and refunds in both
cases. The refund is the row's amount plus its fee, credited back
through the wallet service, so it lands on the balance ledger like any other
movement.
A row that has aged into TIMEOUT cannot be decided from the queue. The row and
bulk menus are built from that same PENDING/PROCESSING configuration, so they
do not offer the buttons, and an id sent anyway comes back in failures as
Already TIMEOUT — if it was the only id, the whole request 400s. Open the
row's detail page instead. Its Reject posts straight to
POST /api/admin/finance/wallet/{id}/withdraw/reject, which accepts PENDING,
PROCESSING and TIMEOUT. That is the control that un-freezes a stranded
payout.
On an ecosystem wallet the refund is keyed per transaction
(admin_eco_refund_{transactionId}), so it is safe to repeat.
On a SPOT wallet it is not: the refund falls back to a key scoped to the
wallet and the operation type, not to the transaction. The wallet service
enforces idempotency on a single unique index across the whole transaction
table, so the second rejected SPOT withdrawal on the same wallet is treated as
a duplicate of the first and answers 409 Duplicate operation detected — and
the transaction row has already been flipped to REJECTED by that point.
The visible result is a rejection that errors, a row that says REJECTED, and a
customer who was not paid back. In a bulk reject it shows up as that id in the
failures list while the others succeed.
If you reject more than one withdrawal against the same customer's same wallet,
open the balance ledger afterwards and confirm one CREDIT per rejection. Where
one is missing, an adjustment (below) for the same amount + fee, with the
transaction id in the description, is the correct repair.
A third path exists and no button calls it: PUT /api/admin/finance/withdraw/log/{id}
with status: REJECTED is API-only, keyed withdraw_reject_{id}, and caps the
refund at the totalDebit the wallet service recorded on the original debit. If
you are scripting rejections rather than clicking them, that is the safer route.
Both reject dialogs — the queue's and the detail page's — keep their Confirm
button disabled until the reason reaches three characters, and the queue's
endpoint refuses the request anyway with
A reason is required when rejecting a withdrawal.
What you type is emailed to the customer and stored on the transaction, and it is the sentence that gets quoted back at you in a dispute. Write what you checked and what you concluded, not "declined".
The detail page (/admin/finance/withdraw/log/{id}) has its own Approve and
Reject, and they are not the same endpoints — worth knowing when a role can decide
from one screen and not the other:
| Action | Route | Permission |
|---|---|---|
| Approve / Reject from the queue (row or bulk) | PUT /api/admin/finance/withdraw/log/status |
edit.withdraw |
| Approve on the detail page, SPOT wallet | POST /api/admin/finance/wallet/{id}/withdraw/approve |
edit.wallet |
| Reject on the detail page (and behind the queue's Reject) | POST /api/admin/finance/wallet/{id}/withdraw/reject |
edit.wallet |
| Approve on the detail page, non-SPOT wallet | PUT /api/admin/finance/withdraw/log/{id} |
edit.withdraw |
Two things about that table. The {id} in the two wallet/…/withdraw/… paths is
the transaction id, not the wallet id, despite where the route sits — the
detail page passes transaction.id into it. And the queue's own endpoint
delegates each id to those same two handlers rather than reimplementing them, so
the wallet-type routing, the fee accounting and the customer email cannot drift
between the two surfaces.
The non-SPOT approve also requires a referenceId — a transaction hash or
wire reference — before it will complete a withdrawal, and refuses with
referenceId (transaction hash or wire reference) is required to complete a withdrawal without one. A settlement with no external reference is unprovable,
so the route will not record one.
The platform fee is booked at settlement, not at request time. That is deliberate: collecting it up front left the fee credited to the platform even when the withdrawal was later rejected and fully refunded, which minted money.
Full detail on what to check before deciding is in A withdrawal is stuck.
Deposits, for completeness
A PENDING deposit has credited nothing, so rejecting one takes nothing back —
it closes the row and emails the reason.
If the customer really paid and the row will never settle itself, reject it with a reason that says you are crediting manually, then adjust — never the other way round, and never both without the rejection first. See A customer paid and the balance did not move.
(b) Adjust a wallet balance
/admin/finance/wallet, the row's ⋯ menu, Adjust balance.
type is ADD or SUBTRACT.The dialog names the customer, the wallet type, the currency and the current
balance in its subtitle. Read that line before you type an amount — a customer
with USDT on SPOT and on ECO has two rows that look nearly identical in the
table.
| Field | Effect |
|---|---|
type |
ADD credits, SUBTRACT debits |
amount |
Positive. SUBTRACT is refused with Insufficient funds in wallet when it exceeds balance — held funds in inOrder are not available to it |
nonce |
Idempotency token, generated fresh each time the dialog opens |
description |
Optional, and it should not be. Written onto the ledger row and used as the transaction description |
notifyUser |
Defaults to on. Off means no email; the customer still sees the transaction |
It writes a wallet-service credit or debit under the key
admin_wallet_adjust_{walletId}_{type}_{amount}_nonce_{nonce}, with the operation
type ADMIN_ADJUSTMENT_CREDIT or ADMIN_ADJUSTMENT_DEBIT, plus a balance-ledger
row carrying the before and after balance. Where the resulting transaction row
turns up is not where you would expect — see What each mechanism leaves behind
below.
A fresh nonce per dialog open is what stops a double-click double-crediting: a retry of the same submit reuses the nonce and is de-duplicated, while two deliberate identical adjustments get different nonces and both land.
But the uniqueness is enforced by a single unique index on
transaction.idempotencyKey for the entire table — it is not scoped per wallet.
That is invisible from the dialog, which always includes the wallet id in the key.
It matters the moment you drive this endpoint from a script or an API client: if
you compute your own key from something that is not wallet-specific — an order id,
a ticket number, a batch reference — the second wallet you apply it to is
silently treated as a duplicate of the first and is never credited. Include the
wallet id in any key you generate.
Two refusals to expect:
- A frozen wallet cannot be adjusted. The adjustment goes through the same wallet service as everything else, so a wallet with its status off refuses the credit or debit. Unfreeze, adjust, re-freeze.
SUBTRACTcannot reach held funds. If the money you are trying to claw back is ininOrder, cancel the order or wait for it to settle first.
If you find a PENDING transaction of type ADJUSTMENT_ANCHOR in the log, that
is the fingerprint of an API-client adjustment that crashed between minting its
anchor row and completing the movement. It is not money. Check the wallet's ledger
to see whether the adjustment actually happened, then delete the orphan. Full
explanation in Customer wallets and balance adjustments.
(c) Edit the transaction record — which moves no money at all
This is the mechanism people reach for and it is almost never the right one.
The /admin/finance/transaction table ships with no edit button —
canCreate, canEdit are off; it is read and delete only. The edit dialog is
reachable from the Transactions tab on /admin/crm/user/{id}, and the route
is guarded twice:
-
Only pending transactions can be updated. A settled row is refused outright. There is no path here that rewrites aCOMPLETEDrecord. -
A
DEPOSITorWITHDRAWcannot be settled from here at all, with an explicit refusal naming the screen that owns it:A withdraw cannot be approved or rejected here — this screen edits the record but moves no money, and settling it would mark the transaction final while leaving the wallet untouched. Use /admin/finance/withdraw/log instead.That guard exists because the mistake it prevents is unrecoverable: marking a
DEPOSITCOMPLETEDfrom here credited the customer nothing and permanently locked the only route that could have paid them, since the real approval refuses any row that is no longerPENDING.
Use this to fix a description or a reference on a pending row. Do not use it to settle anything.
Never approve a settled row
A row that is already COMPLETED gets no decision entries in its menu — the
queue's own configuration strips them, because offering Approve on a completed
withdrawal is an invitation to pay twice.
That leaves one way for it to happen anyway: a stale selection. You tick forty rows, work through them, the list refreshes, and the bulk action fires at ids that have moved on. The route re-checks every id against the database before it touches money and reports the ones it refused:
{ "succeeded": 37, "failed": 3,
"failures": [{ "id": "…", "error": "Already COMPLETED" }] }Three things follow, and they are the reason bulk decisions need care:
- Partial success is reported, not rolled back. The 37 that succeeded have already moved money. Re-running the whole batch is not a retry — it is a second attempt at rows that are now in a different state.
- The request only fails outright when nothing at all succeeded, so a green toast does not mean every id went through. Read the response.
- Fix the named failures individually.
The queue deselects decided rows after a successful batch for exactly this reason.
What each mechanism leaves behind
| Mechanism | In /admin/finance/transaction |
In the balance ledger | |
|---|---|---|---|
| Withdrawal rejected | The original WITHDRAW row flips to REJECTED, reason in metadata.note, plus a new row typed REFUND for the credit |
A CREDIT with previousBalance → newBalance |
Yes, carrying the reason |
| Deposit rejected | The DEPOSIT row flips to REJECTED |
Nothing — nothing was credited | Yes |
| Balance adjustment | A new row typed DEPOSIT (for ADD) or WITHDRAW (for SUBTRACT), description = what you typed |
A CREDIT or DEBIT with previousBalance → newBalance |
Unless you turn it off |
| Transaction edit | The same row, with changed columns | Nothing | No |
| Wallet freeze | Nothing | Nothing | No |
The wallet service maps its own operation types onto the database's transaction
type enum, and ADMIN_ADJUSTMENT_CREDIT lands as DEPOSIT while
ADMIN_ADJUSTMENT_DEBIT lands as WITHDRAW.
Both of those are among the eight types the global
/admin/finance/transaction list hides by default, so your adjustment will not
appear on that screen at all. Look for it on the Transactions tab of
/admin/crm/user/{id}, which is scoped to one customer and therefore shows
everything, or in the deposit and withdrawal logs alongside the real ones.
The refund from a rejected withdrawal is typed REFUND, which is not hidden —
so a refund shows on the global list and an adjustment does not. Do not read that
difference as one of them having failed.
description is optional on the endpoint, and the dialog lets you submit without
it. What you get is a permanent ledger row that says money moved, by whom, and
nothing about why.
Six months later that row is indistinguishable from a mistake. Put the external transaction hash, the bank reference or the ticket number in it — the thing an auditor could follow out of this system and back again.
Freezing instead of adjusting
The row menu's Disable wallet / Enable wallet entry flips wallet.status.
A frozen wallet refuses every wallet-service operation — credit, debit, hold, release, execute-from-hold, both legs of a transfer, and the ecosystem paths. In practice: deposits into it fail, withdrawals out of it fail, orders that need to lock funds fail, and your own adjustments fail. The balance is untouched and stays visible to the customer.
That makes it the right control while you investigate a suspected fraud, and the wrong one to leave in place. Customer money sitting in a disabled wallet is a support ticket and, past a threshold, a regulatory problem. The wallet analytics strip has a Funds in disabled wallets tile precisely because this is easy to forget — though note that tile is a raw cross-currency sum, so read it as a signal that something is frozen and use the filter to find what.
Everything here is on the audit trail
Every route on this page declares logModule: "ADMIN_FIN", so each writes one
append-only row to System → System Monitoring → Audit Trail
(/admin/system/audit) carrying your userId, the target's id (targetId, plus
every id in targetIds for a bulk decision), the method and path, SUCCESS or
ERROR, the duration, the request id, your IP, and the step-by-step narrative of
what the handler actually did — the claim, the exchange call, the email.
The audit writer looks for the operator's justification in reason, message,
adminNotes or metadata.message. The queue decision dialogs send reason
and the withdrawal reject dialog sends message, so those land in the audit row's
reason column.
The balance-adjustment dialog sends description, which is none of those. Its
audit row therefore has an empty reason — the text you typed goes to the balance
ledger row and the transaction description instead, and that is the only place it
exists.
So: an adjustment is attributed to you in the audit trail, but explained only in the ledger. Write the description as though it is the whole record, because for that purpose it is.
Permissions
| Key | Grants |
|---|---|
edit.wallet |
Adjusting a balance, freezing a wallet, and the detail-page approve/reject on a withdrawal |
edit.withdraw |
Deciding the withdrawal queue, single and bulk |
edit.deposit |
Deciding the deposit queue |
edit.transaction |
Editing a pending transaction record |
delete.transaction |
Deleting a transaction row |
access.wallet |
Reading the wallet list and the balance ledger |
edit.wallet is the one to be careful with. It is the permission that mints
money, and it is separate from edit.withdraw on purpose — a support role that
should work the payout queue does not need it. How keys are derived and the five
places each has to exist is in
Roles and permissions.
Related
- A customer disputes their balance — the diagnosis that has to come first.
- Customer wallets and balance adjustments — the wallet screen in full, including why a wallet cannot be deleted while funded.
- A withdrawal is stuck — the refund rule, and the
evidence you need before rejecting a
PROCESSINGpayout. - Working the deposit queue and Working the withdrawal queue — the queues themselves.