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.
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
textareaandqrhave dedicated branches; everything else falls through to a plain input whosetypeattribute is passed straight to the browser. Afilefield therefore produces a file picker whose selection is never uploaded anywhere, and animagefield 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
The request carries methodId, amount, currency and the customFields the
customer filled in. The route:
-
Checks the KYC feature gate for wallet deposits, then resolves the id. The same endpoint serves gateways and methods — it looks for a
depositGatewayfirst and falls back to adepositMethod, which is why the ids are UUIDs. -
Validates the amount against
minAmountandmaxAmount, and computes the fee. -
Creates or finds the customer's FIAT wallet in that currency, and writes a
transactionrow: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
-
Create the method with its title, instructions and fees, then switch it off from the table's status toggle while you finish.
-
Write the instructions properly. Account number, bank, branch, the exact reference you want quoted, and your confirmation window. Between 10 and 5000 characters.
-
Add the custom fields you will actually reconcile against — the sender's name, the bank reference, the transfer date. Use
inputandtextareaonly. -
Set
minAmountabove the point wherefixedFeeeats the deposit. CheckminAmount − (minAmount × percentageFee / 100 + fixedFee)is comfortably positive. -
Switch it on, and make one small deposit yourself.
-
Approve your own deposit from
/admin/finance/deposit/logand confirm the wallet was creditedamount − fee, and that the answers you typed are visible on the detail screen.
Related
- Working the deposit queue — what Approve does, the refusals you will meet, and the daily routine.
- Connecting a fiat deposit gateway — the other half of the deposit screen, for the sixteen bundled integrations.
- Withdrawal methods — the mirror image, and the one place the customer's payout details are collected.
- Fiat and spot currencies — which currencies a method can be used against.
- Backups and restore — including the uploads directory.