Manual deposit methods

How to build a bank-transfer or cash deposit form by hand — the custom fields you ask the customer for, the instructions they pay against, the fees, and why the row credits nothing until you approve it.

8 min readUpdated 6 August 2026deposits, bank-transfer, manual, custom-fields, approvals

A deposit gateway is an integration — Stripe, Paystack, TransFi — bundled with the platform and switched on with credentials. A deposit method is something you build: a form you design, instructions you write, and a payment you confirm by hand. Bank transfer, cash at a branch, a local wallet nobody has an API for. It is how a large share of operators actually take money.

The screen is Admin → Finance → Payment Systems → Payment Methods (/admin/finance/deposit/method). Unlike the gateway console, this one is a full CRUD table — you create, edit and delete rows freely.

Action Permission
Open the screen access.deposit.method
Open a row view.deposit.method
Create create.deposit.method
Edit, and both status toggles edit.deposit.method
Delete, single and bulk delete.deposit.method

status defaults to true in the model and the create form does not ask for it. A method you save to finish tomorrow is offered to customers tonight, with whatever half-written instructions it currently carries. Create it, then switch it off from the table's status toggle until the wording is right.

The fields on the record

Field Required What it is
title Yes The name the customer picks from the list
instructions Yes 10–5000 characters. The only thing they read before paying you
image No A logo. Must be a path under /uploads/ or /img/
fixedFee Yes Flat fee, in the deposit currency. Cannot be negative
percentageFee Yes Percentage of the amount. Cannot be negative
minAmount Yes Floor. 0 means no floor
maxAmount Yes Ceiling. Send 0 for no ceiling
customFields No The form the customer fills in — see below
status Set by the toggle, not by the create or edit form

The create and edit dialogs group these as Basic Information (image, title, instructions), Fees limits (the four numbers) and Custom Fields.

instructions is the whole customer-facing product

It is required, the schema enforces 10 to 5000 characters, and it is rendered verbatim — newlines preserved — on the deposit form immediately above the fields you ask for. Nothing else on the platform tells the customer your account number, your branch, your reference format or your cut-off time.

Write it as if it is the only thing they will see, because it is. Include the account details, the exact reference they must quote, and how long you take to confirm.

Fees and limits

The fee is computed on the customer's request as max(amount × percentageFee / 100 + fixedFee, 0), rounded to two decimal places, and stored on the transaction as fee. The customer's wallet is later credited amount − fee, not amount.

Limits are enforced on the same request. minAmount is skipped when it is 0, and maxAmount is skipped when it is 0 or null — so zero means "no limit", not "nobody may deposit".

Approving a deposit refuses outright when amount − fee is zero or negative: "Cannot approve: the fee is not less than the deposit amount." A 1.01 flat fee against a 1.00 deposit is a real row that has existed in this database. The deposit stays PENDING so you can fix the method's fees, or that one transaction's fee, and approve again — but until you do, the customer is stuck.

Set fixedFee against the smallest deposit minAmount allows, not against a typical one.

Custom fields: the form you are designing

customFields is a JSON array. Each entry is { name, title, type, required }, and the Custom Fields editor in the create/edit dialog is a small table with a row per field: Name, Title, Type, Required, and a delete button.

Type Stored value Renders on the deposit form as
input input A single-line text box
textarea textarea A multi-line box, 3 rows
file file See the warning below
image image See the warning below
QR Code qr Rejected by the API — see below

title is what the customer sees. name is the key the answer is stored under, and you do not get to choose it: the model's setter overwrites whatever you typed with camelCase(title). A field titled Sender account number is always stored as senderAccountNumber. Two fields with titles that camel-case to the same key will collide. A field with an empty title is dropped silently on save.

required: true is enforced in the browser before the deposit is submitted, and only there — it is a form nicety, not a server-side constraint.

The type dropdown offers Input, Textarea, File Upload, Image Upload and QR Code. Verified against the code today:

  • QR Code is not in the API's accepted list at all (input, textarea, file, image). Saving a method with a QR field is rejected with a 400 reading "Type must be one of the following: input, textarea, file, image."
  • File Upload and Image Upload are accepted and stored, but the customer deposit form has no renderer for them. Only textarea and qr have dedicated branches; everything else falls through to a plain input whose type attribute is passed straight to the browser. A file field therefore produces a file picker whose selection is never uploaded anywhere, and an image field produces an image-type input, which is a submit button.

Until that changes, do not build a method that depends on the customer attaching proof. Ask for the bank reference, the sender's name and the transfer date in input fields, and reconcile against your own bank statement.

Every enabled method is offered for every fiat currency

Deposit gateways carry a currencies list and are filtered against the currency the customer chose. Deposit methods are not: the customer-facing lookup is depositMethod.findAll({ where: { status: true } }) with no currency condition at all.

So a method titled "Wire to our EUR account" appears under NGN, KES and every other enabled fiat currency, and its minAmount, maxAmount and fixedFee are interpreted in whichever currency the customer picked. If you need per-currency bank details, create one method per currency and name each one for the currency it belongs to.

What happens when a customer uses it

Creates a manual fiat deposit against a method

The request carries methodId, amount, currency and the customFields the customer filled in. The route:

  1. Checks the KYC feature gate for wallet deposits, then resolves the id. The same endpoint serves gateways and methods — it looks for a depositGateway first and falls back to a depositMethod, which is why the ids are UUIDs.

  2. Validates the amount against minAmount and maxAmount, and computes the fee.

  3. Creates or finds the customer's FIAT wallet in that currency, and writes a transaction row: type: "DEPOSIT", status: "PENDING", metadata = { method: "<your title>", ...their answers }.

The row is a claim, not a payment. No balance moves, no adminProfit row is written, and nothing tells you the money arrived — that is what your bank statement is for. The platform fee is deliberately not collected here, because a deposit that is never approved would otherwise leave profit reported on money you never received.

Where the row lands, and how it is credited

Every one of these rows appears in Admin → Finance → Payment Systems → Deposit Records (/admin/finance/deposit/log), which opens filtered to PENDING, oldest first. Open a row and the answers the customer typed are visible in the Transaction Metadata panel, as formatted JSON, alongside the method title.

Pressing Approve is what credits amount − fee to the wallet and books your fee. Rejecting changes no balance, because there was never a credit to reverse. The full mechanics — the idempotency key, what each refusal means, and the bulk path — are in Working the deposit queue.

Deleting a method

Deletion is soft: the model is paranoid, so a delete sets deletedAt and the row stops appearing. Transactions already created against the method are untouched — they carry the method's title in their metadata, not a foreign key, so a deleted method leaves its history readable.

The screen exposes both a single delete and a bulk delete, both behind delete.deposit.method. Switching a method off is almost always the better move: it disappears from the customer's list immediately and can be switched back on.

Uploads and your backup set

The image you attach to a method is uploaded through the platform's upload route and stored under frontend/public/uploads/, with the database holding only the path. The same directory holds KYC documents, ticket attachments and dispute evidence.

frontend/public/uploads/ is not in the database and is not recreated by any seeder. Confirm it is in your backup set and that your restore drill actually opens one of these images — see Backups and restore.

Getting one live

  1. Create the method with its title, instructions and fees, then switch it off from the table's status toggle while you finish.

  2. Write the instructions properly. Account number, bank, branch, the exact reference you want quoted, and your confirmation window. Between 10 and 5000 characters.

  3. Add the custom fields you will actually reconcile against — the sender's name, the bank reference, the transfer date. Use input and textarea only.

  4. Set minAmount above the point where fixedFee eats the deposit. Check minAmount − (minAmount × percentageFee / 100 + fixedFee) is comfortably positive.

  5. Switch it on, and make one small deposit yourself.

  6. Approve your own deposit from /admin/finance/deposit/log and confirm the wallet was credited amount − fee, and that the answers you typed are visible on the detail screen.