Pre-provisioning the account pool
Building the pool of unowned forex accounts customers claim on first visit — why the admin form cannot create one, the exact row to insert, what never to seed on it, and how to monitor it.
A customer's first visit to /forex/dashboard provisions whatever accounts
they are missing, and it does that one of two ways: it claims an unowned
row from a pool, or it creates an empty one. The first path hands them
working MetaTrader credentials immediately. The second hands them an account
they can fund and invest with but cannot trade on, and which their dashboard
labels Pending.
Keeping the pool stocked is therefore an ongoing operational job, and it cannot be done from the admin screen. This page is how it is actually done.
POST /api/admin/forex/account requires an owner and the create form marks
Account Owner as required, so there is no supported way to produce an unowned
row from the admin panel. Building a pool means inserting into forex_account
yourself.
Take a backup first. A row in this table becomes a real customer balance the
moment somebody claims it, and a wrong value in balance, currency or
walletType is not recoverable from any admin screen.
What claiming actually does
GET /api/forex/account is the dashboard's read, and it writes. For each of
DEMO and LIVE that the customer does not already hold, it opens a
transaction and:
-
Looks for an unowned row of that type —
findOneonuserId IS NULLandtype, taken with a row lock so two simultaneous first visits cannot claim the same row. -
If one exists, claims it. The update sets
userIdto the customer and flipsstatusto true, conditional onuserIdstill being null, and the full row is then re-fetched and returned. Nothing else on the row is touched. -
If none exists, creates an empty account —
userId,type,status: false,balance: 0. No broker, noaccountId, nopassword, nomtversion.
Three properties of that lookup decide how you build the pool:
- There is no ordering. Which row a given customer gets is arbitrary. You cannot reserve a specific account for a specific customer this way.
- There is no quality filter. A pool row is claimed whether or not it has a broker, a login or a password — and it is switched active either way. A half-filled row is worse than no row, because the customer gets Deposit, Withdraw and Trade buttons and a terminal that will not connect.
statuson the pool row is overwritten on claim, so its seeded value only matters while the row is unclaimed. Seed it0so an unclaimed row cannot be mistaken for a live customer account when you filter the accounts table by status.
Two pools, matched on type
DEMO and LIVE are separate pools and are never substituted for each other.
Stock both.
Only the LIVE branch is gated: a customer whose verification level does not
carry the create_forex_account KYC feature has LIVE provisioning skipped
entirely — no claim, no fallback create. The skip is silent and does not fail
the request, so that customer still gets their DEMO account and still sees any
account they already hold. Tightening your levels can never lock an existing
customer out of a balance they already have.
Why the admin form cannot do it
POST /api/admin/forex/account (create.forex.account) does two things that
rule it out as a pool builder:
- The create form's first field group, Account Owner, marks
userIdrequired, and the handler's duplicate check readsfindOne({ userId, type }). - If that user already holds an account of that type, the request is refused with a 409 naming the existing account — That user already has a LIVE forex account ({accountId}). Edit it instead — a second one would split their balance across two rows.
The edit form has no owner field at all, so you cannot un-assign an existing account into the pool either. Both are deliberate: every lookup in the addon reads this user's LIVE account with no ordering, so a second row makes it arbitrary which one is debited, credited or refunded.
The row to insert
forex_account is paranoid, so it carries a deletedAt. createdAt and
updatedAt are NOT NULL with no database default and must be supplied.
INSERT INTO forex_account
(id, userId, accountId, password, broker, mt, type, status,
balance, leverage, createdAt, updatedAt)
VALUES
(UUID(), NULL, '51234567', 'Str0ngPass', 'YourBroker-Live01', 5, 'LIVE', 0,
0, 1, NOW(), NOW());| Column | What it must be |
|---|---|
id |
A UUID. char(36), no default |
userId |
NULL — this is the entire mechanism. Anything else is an owned account |
accountId |
The broker login. Handed to the web terminal as login |
password |
The account password, shown to the customer on the Trade screen. Validated at 6 to 191 characters |
broker |
The broker's server name, exactly as MetaTrader expects it. Handed to the terminal as servers. This is the field that is wrong when a terminal will not connect |
mt |
4 or 5. 5 sends the customer to trade.mql5.com, anything else to metatraderweb.app |
type |
DEMO or LIVE |
status |
0. Overwritten to 1 on claim |
balance |
0 |
leverage |
An integer, defaults to 1. Stored and displayed only — it does not affect any calculation |
createdAt, updatedAt |
NOW(). Both are NOT NULL |
Everything else has a usable default: currency and walletType are NULL,
dailyWithdrawLimit is 5000, monthlyWithdrawLimit is 50000, both
withdrawn counters are 0 and both window anchors are NULL.
currency and walletType are the account's denomination binding, and the
first deposit or withdrawal sets them permanently. There is no admin rebind —
the only way out is emptying the account and clearing both columns by hand.
Seed them and you have pre-bound an account to a denomination the customer who claims it may not hold. Every deposit, withdrawal and investment they attempt is then refused with This forex account holds {walletType} {currency} until somebody edits the database.
A seeded balance is worse: it is real, spendable, withdrawable money the
instant the row is claimed, with no transaction behind it and nothing in any
report to explain it.
The withdrawal caps are the one thing worth reviewing per pool. They are compared against the raw amount in the account's own currency, not dollars, so the defaults mean five thousand BTC on a BTC-denominated account — no cap at all. See Accounts.
Monitoring the pool
There is no pool tile on the forex dashboard and no "unowned" filter on the accounts table, so count the rows directly. Run this before any campaign, launch or marketing push:
SELECT type, COUNT(*) AS available
FROM forex_account
WHERE userId IS NULL
AND deletedAt IS NULL
GROUP BY type;deletedAt IS NULL is not optional — a soft-deleted row is invisible to the
claim query and would otherwise inflate the count.
The unowned rows do appear in /admin/forex/account, with an empty User
column, because the list joins the user table without requiring a match. That
is a useful eyeball check; it is not a count.
The dry-pool symptom
When the pool for a type runs out, nothing fails and nothing is logged. The
customer silently gets the fallback account, and this is what they see on
/forex/dashboard:
- A Pending badge where the account type would be.
- Waiting for admin approval, with a spinner, where the account number and password would be.
- A single Check status button. No balance, no leverage, no Deposit, no Withdraw, no Trade, and no expandable account details.
That state is driven purely by status being false. To rescue a stranded
customer, open their row at /admin/forex/account, fill in accountId,
broker and mt from your broker's back office, and turn the status on. The
backend money routes never check status, so nothing was blocked server-side —
the customer simply had no way to reach the buttons.
The password is the one field you cannot supply there. It is on neither the edit
form nor the create form, and it is not a table or view column either — the form
offers accountId, broker, mt, type, balance, leverage and status,
plus userId on create, and nothing else. PUT /api/admin/forex/account/{id}
does still accept a password of 6 to 191 characters, so setting one means an
API call or the same UPDATE forex_account you would use to build a pool row.
Skip it and you have handed the customer an active account whose Trade screen
shows an empty password — the credential they have to type into MetaTrader
themselves.
Deleting an account does not clean up after it
Deletion on the accounts table is unguarded, and an investment references the customer, not the account. Settlement then looks for this user's LIVE forex account, finds nothing, and:
- rolls back its transaction and skips the payout,
- broadcasts LIVE forex account not found for user {id} to the
processForexInvestmentslive log at/admin/system/cron— and only there, so it does not appear in the backend log, - leaves the investment
ACTIVE, so it is retried on every subsequent hourly tick and never completes.
The cron's own terminal-failure path has the same hole: an investment it gives
up on after three retries cannot be cancelled either, because the principal
refund has nowhere to land. It is left ACTIVE and the backend log records
ALERT: cannot refund investment {id} (no LIVE forex account for user {id})
under FOREX_INVESTMENT_PROCESS. That ALERT string is the one thing worth
grepping for after any account deletion.
Deleting an account under a live investment produces a row that can never
settle and never cancel, with no admin action that fixes it — the money is
owed to a customer whose account no longer exists. Cancel from
/admin/forex/investment first, which refunds the principal to the account
while it still exists, and delete the account afterwards.
Next: Accounts for what each field on the row does once a customer owns it, or The admin screens for the rest of the back office.