Working the deposit queue

How approving a deposit credits a wallet and books your fee, which rows you can actually decide, what each refusal means, and how to prove a payment gateway works before a customer finds out it does not.

9 min readUpdated 6 August 2026deposits, queue, approvals, gateways, fees

The deposit queue is Finance → Payment Systems → Deposit Records (/admin/finance/deposit/log). It lists every DEPOSIT transaction on the platform, and it opens filtered to PENDING, sorted oldest first.

A PENDING deposit row has moved no money. The customer's wallet was not touched when the row was created. Pressing Approve is what calls the wallet service, credits amount − fee to their balance, and books your cut. Until then, the row is a claim.

The mirror image is the reassuring half: rejecting changes no balance, because there was never a credit to reverse. You cannot make a customer poorer by rejecting a pending deposit.

What Approve actually does

One button, three consequences, in this order and inside one database transaction:

  1. The wallet is credited with amount − fee through walletService.credit, under the idempotency key admin_deposit_approve_<transactionId>. That key is why a double-click, a retried request or a re-run of a bulk batch cannot credit twice.
  2. The platform fee is booked — but only if fee is greater than zero. collectPlatformFee credits the Super Admin's wallet in the same currency and wallet type, writes a PLATFORM_FEE transaction, and inserts an adminProfit row. That row is what Finance → Revenue Analytics (/admin/finance/profit) reports.
  3. The customer is emailed the new status and their new balance.

The fee is collected at settlement, not at request. A deposit that is created and never approved — or is rejected — books no profit at all, so your revenue figures only ever contain money that actually arrived.

The balance quoted in that email is re-read from the database after the credit commits, so it is the figure the credit produced rather than the pre-credit one.

collectPlatformFee is written to never throw — a failure there must not roll back the customer's credit. If no user holds the Super Admin role, the fee is silently dropped and logged as [CRITICAL] Dropped platform fee — no Super Admin configured in the backend log. The customer is still credited correctly; only your revenue record is short. Keep a Super Admin account in existence.

Which rows you can decide

Only PENDING. That is enforced in three places and it is deliberate — the single-row endpoint answers Only pending transactions can be updated, the bulk endpoint refuses per id, and a settled row simply gets no Approve/Reject entries in its menu.

The status column carries ten values, most of which you will never decide:

Status What it means Decidable
PENDING Waiting on you, or waiting on a confirmation that has not landed Yes
COMPLETED Credited No
REJECTED Refused by an operator, with a reason No
FAILED The pipeline gave up — see the runbook below No
TIMEOUT Aged out of its verification window No
CANCELLED · EXPIRED · REFUNDED · FROZEN · PROCESSING Set by other paths; not part of the deposit decision No

A stale selection does not fail the batch

Bulk Approve and bulk Reject send PUT /api/admin/finance/deposit/log/status with the selected ids. Each id is evaluated on its own:

  • a row that is no longer PENDING is reported as Already COMPLETED (or whatever it now is) and skipped;
  • an id that is not a DEPOSIT transaction is reported as Not a deposit transaction;
  • everything else is decided.

The response says 12 of 15 processed. 3 could not be: … and names the failures. Do not re-run the whole batch — the twelve that went through are already credited, and only the three named ones need attention. The request only fails outright when nothing succeeded.

What the endpoint sends for you

A bulk decision changes the status and nothing else. The handler reads amount, fee, description and referenceId off the stored row and passes them straight back to the single-row update, rather than re-deriving them. Your rejection reason is nested at metadata.message, which is the field the customer's rejection email is composed from.

Bulk approve or reject. status is COMPLETED or REJECTED; reason is required for REJECTED.
Decide one deposit. Refuses anything not PENDING.

A rejection reason must be at least 3 characters, in the dialog, in the bulk endpoint and in the JSON schema. There is no default text: the Confirm button stays disabled until you type something. That is because the field used to be pre-filled with the prompt, and clicking straight through emailed the customer "Please provide a reason for rejection." as the explanation for their refused deposit.

The refusals you will meet

Approve does not always go through. Both of these leave the row PENDING so you can fix the cause and try again.

The row's fee is greater than or equal to its amount, so the customer would receive nothing or less than nothing. Usually a flat fee on a deposit method that is larger than a small deposit — a 1.01 flat fee against a 1.00 deposit is a real example. Fix the fee on this row from the detail screen's Manage tab, or fix the deposit method under Finance → Payment Systems → Methods (/admin/finance/deposit/method), then approve.

An on-chain spot deposit intent is created with amount: 0 — the amount is filled in later by the verification worker when the exchange reports the deposit. A spot row still sitting at zero has never been confirmed, and approving it would credit nothing. This is not a row to approve; it is a row to investigate. See A customer paid and the balance did not move.

Every wallet-service operation refuses a wallet whose status is off, so approving a deposit into a frozen wallet fails. Unfreeze it on /admin/finance/wallet first — see Customer wallets and balance adjustments.

Where the rows come from

Three pipelines write type: "DEPOSIT" rows, and they reach the queue in very different states.

A manual deposit method. The customer picks a method you defined on /admin/finance/deposit/method, fills in whatever custom fields it asks for, and POST /api/finance/deposit/fiat creates a PENDING row against their FIAT wallet with the method's fees already computed. These are the rows the queue exists for. Nothing else will ever settle them; they wait for you.

A fiat gateway. Starting a gateway payment also creates a PENDING row, but the vendor's webhook or the customer's return normally completes it within seconds and credits the wallet without you. A gateway row that is still pending an hour later means the callback never arrived — check the gateway's readiness before you approve it by hand.

An on-chain spot deposit. The customer submits a transaction hash, which creates a PENDING row with amount: 0 and the hash in referenceId. A verification worker fills in the amount and completes it. Approving one by hand is almost never right.

Ecosystem (ECO) deposits are the exception that does not appear here at all while they are unconfirmed: they are tracked in Redis until the confirmation depth is reached and only then written as an already-COMPLETED row carrying the chain hash in trxId.

The detail screen

/admin/finance/deposit/log/[id] opens one deposit with five tabs — Details, User, Wallet, Manage and Audit. It needs edit.deposit to open at all.

Manage is editable only while the row is PENDING. You can correct amount, fee, referenceId and description before deciding, which is the supported way to fix a fee that is blocking approval. Reference ID is optional: a bank transfer entered by hand has no processor reference and never gets one.

Audit is two append-only records side by side: every administrative action taken against this transaction (who, when, what they typed), and the wallet balance ledger for the wallet it moved — one row per balance change with previousBalance, newBalance, previousInOrder, newInOrder and the idempotency key. Nothing on that tab can write. It is the answer to "prove this customer was credited once".

Payment gateways are a readiness console, not a table

Finance → Payment Systems → Gateways (/admin/finance/deposit/gateway) looks nothing like the other Finance screens, on purpose. There are a fixed sixteen gateways, they are installed by the platform rather than created by you, and none of the questions you actually have about one is answerable from a database column:

  • Can it take a payment right now? → the environment, not the row.
  • If not, why? → which environment variable is unset.
  • Am I pointed at test or live? → a key prefix.
  • Where do I paste the webhook? → derived from your public URL.

GET /api/admin/finance/deposit/gateway/config answers all four. For each gateway it reports which variables the bundled integration reads, which of them are set, the leading marker of a prefixed key (sk_live, pk_test, AQE) and nothing more of it, the webhook and return URLs the vendor should be given, and a test/live verdict.

The status column answers "is it switched on", which is a different question from "can it authenticate". A freshly seeded install ships all sixteen gateways switched off with zero credentials — the seeder sets status: false on every row precisely so nobody is offered a gateway nobody has configured. The dangerous state is the one you create: switch a gateway on before its variables are set and every deposit through it fails at the vendor with a generic error the customer sees and you do not.

The page leads with that failure: "N gateways are switched on and cannot authenticate", with a button that filters to exactly those rows. If that banner is showing, either finish the setup or switch them off.

The sixteen bundled profiles, keyed by alias (the folder name under api/finance/deposit/fiat/, and the only stable join between a row and its integration — the seeder gives some rows a literal id and mints a UUID for others):

Alias Gateway Credential test
stripe Stripe Live probe — reads your balance
paypal PayPal Live probe — OAuth token
paystack Paystack Live probe — reads your balance
mollie Mollie Live probe — lists enabled methods
adyen Adyen Live probe — lists payment methods (test only)
klarna Klarna Live probe
authorizenet Authorize.Net Live probe — authenticateTestRequest
dlocal dLocal Live probe — signed read-only request
transfi TransFi Live probe — reads currencies on your MID
eway eWAY Live probe
paysafe Paysafe Format only
payu PayU Format only
paytm Paytm Format only
payfast PayFast Format only
ipay88 iPay88 Format only
2checkout 2Checkout (Verifone) Format only

Opening one gives you its setup steps in order, the vendor dashboard links, its known traps, the exact webhook URL to paste, and a Test credentials form.

Every gateway with its configuration health. Never returns a credential value.
Tests credentials against the vendor with a read-only call. Values typed into the form are used for one request and discarded.
Switches a gateway on or off for customers.

Prove a gateway before a customer does. The test call is read-only — it asks the vendor a question that only a valid credential can answer, and creates nothing. For the six gateways marked format only, the vendor exposes no such endpoint, so the check confirms presence and shape and you prove the credential with a sandbox deposit instead.

Credentials live in .env, never in the database row, and the backend reads them at request time. Correcting a variable requires a backend restart before this screen or a customer sees the change — and any NEXT_PUBLIC_* variable (PayPal's client id is one) needs a frontend rebuild, not just a restart.

The gateway edit page is gated on edit.deposit.gateway; the list is access.deposit.gateway. Reading which gateways exist and rewriting their fee and currency configuration are deliberately different rights.

The routine

  1. Work the queue oldest-first. It already opens that way. The Age column is measured against the deposit SLA of 72 hours — amber at half of it, red past it. The same number drives the dashboard health card, so the two cannot disagree.

  2. Read the row before you decide. Amount, fee and the net credited figure are all on the view dialog. The metadata block carries whatever the gateway or the customer supplied — a bank reference, a payer name, a channel.

  3. Reject with a reason a customer can act on. It is emailed to them and stored on the transaction, and it is the thing that gets quoted back at you in a dispute.

  4. Check the tiles once a day. Awaiting approval, Oldest pending deposit and Pending over 48h are on the analytics strip. One old deposit is an outlier; forty is a broken desk.

  5. Check the gateway console after any .env change or restart. It is the only screen that will tell you a gateway stopped being able to authenticate.