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.

11 min readUpdated 6 August 2026refunds, adjustments, wallets, audit, runbook

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.

Approves or rejects a selection of withdrawals. Each id is decided independently against the same handlers the single-row path uses.

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.

Decides one deposit. Approving credits the wallet, books the fee and emails the customer.
Decides a selection of deposits.

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.

Credits or debits one wallet. 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.
  • SUBTRACT cannot reach held funds. If the money you are trying to claw back is in inOrder, 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

Corrects the fields of one PENDING transaction. It writes columns and nothing else.

This is the mechanism people reach for and it is almost never the right one.

The /admin/finance/transaction table ships with no edit buttoncanCreate, 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 a COMPLETED record.

  • A DEPOSIT or WITHDRAW cannot 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 DEPOSIT COMPLETED from 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 longer PENDING.

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:

  1. 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.
  2. The request only fails outright when nothing at all succeeded, so a green toast does not mean every id went through. Read the response.
  3. 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 Email
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 previousBalancenewBalance 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 previousBalancenewBalance 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.

Freezes or unfreezes one wallet.
Freezes or unfreezes a selection.

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.