When money does not move

Triage for a stuck withdrawal, an uncredited deposit, a wrong balance, a failed bulk approve and a queued transfer — which screen to open, which cron to check, and what never to retry.

17 min readUpdated 6 August 2026withdrawals, deposits, wallets, cron, reconciliation

This page is the money half of triage. The infrastructure half — a backend that will not boot, a dead port, GLIBC, mail, licences — is Troubleshooting.

Everything here assumes the platform is otherwise up: pages load, the API answers, customers can sign in. When money still does not move, the cause is almost always one of five things, and there is a first check for each.

Approving a withdrawal is an irreversible outbound payment. The approve endpoint protects you with an atomic claim, and the bulk endpoint refuses rows that are not PENDING or PROCESSING — but a row that was left PENDING because the local status update failed is a row whose money has already gone. If a payout is not doing what you expect, read this page. Do not press the button again.

Before anything else: is the scheduler alive

The scheduler is its own PM2 process. Under the default split the web process registers no cron jobs at all, so a stopped cron app leaves a deployment where every page loads, every health tile is green and no withdrawal is ever processed. Nothing else in the product makes that obvious.

Open System → System Monitoring → Scheduled Tasks (/admin/system/cron). The page keeps three signals separate on purpose:

  • The scheduler heartbeat. Beaten every 15 seconds with a 90-second TTL, and read by GET /api/admin/system/cron/scheduler. It reports running, missing, stale, duplicate or unknown. unknown is not the same as "no scheduler" — it means the heartbeat could not be read. duplicate is worse than missing: two processes scheduling means money jobs run twice over the same rows.
  • The job registry, with each job's run state and any sticky refusal. The registry exists identically in all three processes and its run bookkeeping is hydrated from a Redis snapshot with a 24-hour TTL — so a scheduler that died an hour ago still produces a full page of jobs with plausible "last run" times. Only the heartbeat tells you whether any of them will run again.
  • A live log stream, which is where a job's own account of a run appears.

Viewing needs view.cron; triggering a job by hand needs manage.cron.

The three jobs that matter on this page:

Job Period What it does
processPendingWithdrawals 30 minutes Polls the exchange for PENDING spot withdrawals and converges their status.
reconcileSpotWithdrawals 5 minutes Crash-recovery for PROCESSING spot withdrawals.
processSpotPendingDeposits 15 minutes Verifies and credits PENDING spot deposits.

A withdrawal sits at PENDING forever

What it means. The customer's wallet was debited when they submitted the request. The money has not left the platform. Nothing is lost yet.

  1. Check whether it was ever meant to leave automatically. Withdrawal auto-approval is the setting withdrawAutoApprove, with the legacy withdrawApproval as a fallback and manual as the default when neither is set. One switch shows it: System → Platform Settings → Wallet → Security → Auto-Approve Withdrawals, a Super-Admin-only save that writes both rows.

    On an install upgraded from before 6.6.4 the two rows can still disagree, and the legacy one wins whenever the visible switch has never been saved — so a platform can be auto-approving while that switch reads off. node backend/scripts/migrate-withdraw-auto-approve.mjs reports which is in force without changing anything. If auto-approval is off — and on most installs it is — a PENDING withdrawal is not stuck. It is waiting for you, at Finance → Withdrawal Management → Records (/admin/finance/withdraw/log), which opens filtered to PENDING, oldest first.

  2. Confirm the scheduler is alive. See above. With auto-approval on, the payout is dispatched at submission time; with it off, the cron is what moves the row on afterwards.

  3. Read the cron log for processPendingWithdrawals. The line to look for is Exchange unavailable; skipping pending withdrawal run. That happens when no exchange provider row is active, its credentials are missing, or the exchange has banned your IP — and the job then reports completed. A green tick on that job does not mean it did anything.

  4. Check the row is inside the job's window. The scan is deliberately bounded: PENDING, the right type, created within the last 7 days, with a non-empty referenceId, on a wallet of type SPOT, oldest first, capped at 100 rows per run. A row that falls outside any of those is never picked up:

    • Older than 7 days — the cron will never touch it again. Decide it by hand.
    • No referenceId — nothing was ever dispatched to the exchange. It is a queue item awaiting your approval, not a lost payout.
    • A FIAT wallet — fiat withdrawals are not the spot cron's work.
    • More than 100 eligible rows — the backlog drains 100 per tick, oldest first. Two hours of arrears is normal after an outage; a permanently full batch is not.
  5. Check for a sticky refusal on the cron page. A job that declines to run returns normally, so the only signal is the refusal state the job registry carries. A refused job is not reported as completed, and the refusal names the reason, the impact and the fix.

A withdrawal sits at PROCESSING

What it means. Someone or something called exchange.withdraw(). The money may already have gone. This is the state that needs a human, and the one nothing else in the product will tell you about.

PROCESSING is written by an atomic claim: the approve endpoint updates the row PENDING → PROCESSING in a single compare-and-set, and only the request that wins that update is allowed to call the exchange. A concurrent request, a retry or a crashed-and-restarted process loses the claim and gets a 409 "Withdrawal is already being processed". That is the guard against a double on-chain send, and it is why re-pressing Approve is never the fix.

reconcileSpotWithdrawals runs every 5 minutes over PROCESSING + WITHDRAW rows on SPOT wallets, skipping anything younger than 5 minutes because the endpoint may still be mid-flight. What it does depends on one field:

referenceId is set — the exchange accepted the payout. The job converges to whatever the exchange reports: completed/okCOMPLETED, cancelled/canceledCANCELLED, failedFAILED. A vendor status of pending deliberately leaves the row PROCESSING rather than demoting it, because demoting it would push the row out of the reconciler's own coverage.

The payout is not in the page the exchange returned — that is not evidence it never happened. fetchWithdrawals is paginated and lookback-limited on most venues. The job refuses to auto-refund; after 24 hours it flags the row for review with the reason recorded, and backs off so it does not re-scan it every five minutes.

referenceId is empty — the process crashed somewhere around the exchange call, and the payout may or may not have been sent. The job only refunds on positive evidence of absence, and only after 24 hours.

What auto-refunds. When the exchange returns a terminal failure — FAILED or CANCELLED — the refund and the status flip happen in the same database transaction, refund first. The customer is credited back the full amount including the chain fee, a "Withdrawal Failed" notification is sent, and the row lands in its terminal state. The ordering is deliberate: flipping the status first meant a refund failure left a terminal row that no cron would ever retry, stranding the money permanently.

So: a PROCESSING row older than 24 hours that is flagged for review is waiting on you to go and look at the exchange's own dashboard. It is not waiting on the platform.

Filter the withdrawal queue to PROCESSING once a day. Both PENDING and PROCESSING are decidable from that screen; deposits and transfers accept PENDING only.

A bulk approve reported failures

The bulk decision endpoint is PUT /api/admin/finance/withdraw/log/status (edit.withdraw), taking ids, status (COMPLETED or REJECTED) and a reason that is mandatory for a rejection and must be at least three characters — it is emailed to the customer.

It does not re-implement approval. Each id is handed to the same single-row handler the queue uses, so the wallet-type routing, the fee accounting, the idempotency key and the customer email cannot drift from the single-row behaviour.

Partial success is reported, not rolled back. Approving forty withdrawals is forty independent money movements. If three fail, the thirty-seven that already left are not un-done.

The response body carries succeeded, failed and a failures array of { id, error } — that is where you read which ids failed and why. Re-select only those ids. Re-submitting the original selection asks the platform to approve rows that have already paid out; the per-row status guard catches the ones that reached a terminal state, but a row still sitting at PROCESSING because its local update was lost is exactly the row that has already sent money and would be sent again.

The toast shows the first three failure messages. The full list is in the response and, because every row's steps are threaded into the same audit context, in the audit trail entry for the batch at /admin/system/audit under module ADMIN_FIN.

Common per-row messages and what they mean:

Message Meaning
Already COMPLETED / Already REJECTED Stale selection. Refresh the queue.
Not a withdrawal transaction The id is not a WITHDRAW row.
Ecosystem withdrawals are settled on-chain by the ecosystem queue and cannot be approved here. An ECO wallet row. Use the ecosystem withdrawal queue; approving here would mark it paid without anything leaving.
Withdrawal is already being processed Someone else won the atomic claim. Do not retry.
Your withdraw amount including fee is higher than your balance The wallet no longer covers amount + chain fee.

If nothing succeeded, the endpoint raises a 400 with the first failure rather than returning a green toast over a batch that did nothing.

A deposit was paid but never credited

Split this by how the deposit was made, because the two paths fail differently.

Fiat gateway deposits

A gateway deposit is credited by its webhook. If the webhook never arrives or never lands, the payment is real and the balance is not.

  1. Check the webhook URL the gateway is actually configured with. Open Finance → Payment Systems → Gateways (/admin/finance/deposit/gateway), which is a readiness console rather than a table. It is backed by GET /api/admin/finance/deposit/gateway/config, which reports for each gateway whether an integration is bundled (supported), whether it can authenticate (credentialsComplete and missingRequired), whether it is on test or live credentials (mode), and the webhook and return URLs it expects — computed from the resolved public URL, so a wrong NEXT_PUBLIC_SITE_URL shows up here as a wrong webhook URL. Per-gateway, GET /api/admin/finance/deposit/gateway/{id}/config adds the ordered setup steps and the traps specific to that vendor. Credential values are never returned.

  2. Check the webhook was not refused before it reached the handler. A gateway webhook is an ordinary public route and it runs the same gauntlet as any other request. Three gates sit in front of it:

    • Geo restrictions. Installed as a global gate. Your payment provider's servers are in their country, not your customer's — a country rule that covers it refuses the delivery.
    • Licence enforcement. Only a short list of prefixes is exempt (/api/auth, /api/user/profile, /api/settings, the licence and extension admin routes, and /api/geo). Deposit webhooks under /api/finance/deposit/... are not on it, so an expired or unactivated licence refuses them.
    • Rate limiting. A burst of retries from a provider can trip it.

    Each of those produces a non-2xx that the provider records as a failed delivery. Most providers will retry for a while — replay from the provider's dashboard once the cause is fixed, rather than crediting by hand.

  3. Only then credit by hand, through the wallet balance adjustment described below, with the provider's reference in the description.

Spot (on-chain) deposits

These are credited by processSpotPendingDeposits, every 15 minutes. The same window applies as for withdrawals: PENDING, non-empty referenceId, a SPOT wallet, created in the last 7 days, oldest first, 100 rows per run.

Rows younger than about 25 minutes are handed to the WebSocket verification schedule; older ones are verified directly, once per run, using the same idempotency key as the WebSocket path so the two can never double-credit.

A deposit sitting at PENDING with no referenceId was never given a transaction hash to verify, and no amount of waiting will change that.

A customer's balance is wrong

There is a complete, append-only record of every balance movement, and it is the first thing to read.

Reads the wallet balance ledger

wallet_audit_log carries one row per balance operation with operation, amount, previousBalance, newBalance, previousInOrder, newInOrder, transactionId and the idempotencyKey. Filter it with ?filter={"walletId":"..."} for one wallet, or {"userId":"..."} for the customer's whole trail.

In the panel it is the Balance ledger, on the Audit Trail tab of the customer's page (/admin/crm/user/{id}) and of each withdrawal, deposit and transfer detail page. The value is the arithmetic: previousBalance → newBalance beside the amount that caused it.

The operations you will see are WALLET_CREATED, CREDIT, DEBIT, HOLD, RELEASE, TRANSFER_IN, TRANSFER_OUT and EXECUTE_FROM_HOLD. HOLD and RELEASE move money between balance and inOrder on the same wallet — they do not change what the customer owns, and reading one as a debit is the commonest way to mis-diagnose a "missing" balance.

  1. Find the movement that does not belong in the ledger, and note its idempotencyKey. An admin adjustment is keyed admin_wallet_adjust_{walletId}_{ADD|SUBTRACT}_{amount}_{scope}; a refund from a failed approval is withdraw_approve_refund_{transactionId}.

  2. Match it to a person. Take the transactionId or the record id to System → Audit Trail (/admin/system/audit) and filter by target. The trail records who, which module, method and path, the record touched, success or error, the reason they gave, how long it took, and the request id and IP. It is append-only — there is no create, edit or delete behind that screen.

  3. Correct it, if it needs correcting. Finance → Transaction Management → Wallets (/admin/finance/wallet), open the wallet, choose Add or Subtract, enter an amount and a description, and decide whether the customer is notified. The endpoint is POST /api/admin/finance/wallet/{id}/balance behind edit.wallet. Every dialog opens with a fresh idempotency token, so a double-click cannot double-credit. A SUBTRACT larger than the balance is refused.

Two different wallets cannot reuse one key. If a second adjustment or refund silently does nothing, look for an earlier operation carrying the same key rather than assuming the call failed.

Transfers stuck in the queue

Internal transfers are PENDING until decided, and they appear in the Operations inbox as the transfers queue with a 3-day target, linking to /admin/finance/transfer.

Each row opens a settlement page at /admin/finance/transfer/{id} with five tabs — Details, User, Wallet, Manage and Audit Trail. Manage is where the decision is made; Audit Trail carries the admin trail and the balance ledger for the wallet involved.

  • Single decision: PUT /api/admin/finance/transfer/{id} (edit.transfer). Only a PENDING transaction can be decided; the row is locked and its status re-checked inside the transaction, so a concurrent decision loses.
  • Bulk: PUT /api/admin/finance/transfer/status with COMPLETED or REJECTED.
  • REJECTED and CANCELLED refund the sending wallet.

A transfer that is still PENDING is waiting on an operator, not on a job. Transfers have no cron.

The trap that makes a hand-written check lie

Every DECIMAL column comes back from mysql2 as a string, not a number — deliberately, to avoid float loss on a DECIMAL(30,18) balance. Nothing in the product touches a raw balance without coercing it first.

It matters the moment you leave the product. In a quick script, a Node REPL or anything that reads the rows directly:

// balances arrive as strings
rows.reduce((a, r) => a + r.balance, 0)   // "0" + "1.5" + "2.25" -> "01.52.25"
rows.reduce((a, r) => a + Number(r.balance), 0)   // 3.75

amount.toFixed() throws on a string, and a + b concatenates. A "total balance" that is impossibly large, or a comparison that is always false, is this and not a corrupted database.

The same caution applies to any total you compute yourself across currencies: per-currency figures are kept separate throughout the platform precisely because a number that sums BTC to USD means nothing.

Before you escalate

Capture these, in this order. They are what anyone diagnosing after you will ask for, and most of them stop existing once a queue is worked.

  1. /admin/system/cron — the scheduler heartbeat banner and the rows for processPendingWithdrawals, reconcileSpotWithdrawals and processSpotPendingDeposits, including their last-run times and any refusal.

  2. The queue row itself/admin/finance/withdraw/log, /admin/finance/deposit/log or /admin/finance/transfer, showing the status and age, before you decide anything.

  3. The record's detail page, Audit Trail tab — the admin trail plus the balance ledger, which is the arithmetic nobody can reconstruct later.

  4. /admin/system/audit, filtered to ERROR for the window in question.

  5. The response body of the failed bulk action, if there was one. The failures array is the only place the per-id reasons exist.

  6. The gateway readiness console, for a deposit problem — it names the missing credential and the webhook URL it expects.

And what not to do:

  • Do not re-run an approve to retry it. See the top of this page.
  • Do not credit a customer by hand before reading the balance ledger. Half the "missing" balances are a HOLD against an open order.
  • Do not clear a PROCESSING row by editing its status. The reconciler owns that transition and refunds inside the same database transaction as the flip; a hand-edited status skips the refund and strands the money.
  • Do not fix a webhook problem by crediting and moving on. The next payment fails the same way.