General Investment troubleshooting
Why positions sit at ACTIVE, what each settlement log line means, which admin actions look like payouts and are not, and the SQL that finds unsettled and unpaid rows.
Every failure mode in this product is silent. A position that does not settle
stays ACTIVE and looks exactly like one that is still running. A run that
failed on one customer finishes and reports completed. A status set by hand
reads COMPLETED to everyone who looks at it afterwards, including the job that
would otherwise have paid it.
So the first move is never the row. It is the two places that can tell you whether settlement is happening at all.
Start here: is settlement running
-
Open the scheduler. System → System Monitoring → Scheduled Tasks (
/admin/system/cron), the job Process General Investments (processGeneralInvestments). It runs every hour and is a core job — it needs no extension and exists on every install. Viewing needsview.cron; triggering it by hand needsmanage.cron.Read the scheduler heartbeat first, not the job row. The job registry is hydrated from a Redis snapshot with a 24-hour TTL, so a scheduler that died an hour ago still renders a full page of jobs with plausible "last run" times. Only the heartbeat says whether anything will run again.
-
Open the alarm. Finance → Investment Management → Investment Analytics (
/admin/finance/investment/history). Three tiles at the top are the settlement-stall alarm:Tile Metric Meaning Capital Deployed activeCapitalOutstanding principal across every ACTIVEposition, in USD. What you owe.Overdue Capital overdueCapitalThe slice of that which is past its endDateby more than an hour.Overdue Settlements overdueCountHow many positions that is. The one-hour grace keeps positions maturing right now out of the count. If the last two are non-zero and rising, settlement is not happening.
The settlement loop catches a per-investment failure, writes an error line to
that run's log, and moves on to the next row. The run then finishes normally and
Scheduled Tasks records it as completed. Only a failure of the whole job —
the query that loads the active investments, for instance — reports failed.
The evidence for one stranded settlement is the run's own log lines and the backend cron log, never the job's status badge.
Everything sits at ACTIVE
Nothing has settled at all, on any plan, for hours or days.
Almost always the scheduler. Under the default process split the web process
registers no cron jobs, so a stopped cron app produces a deployment where
every page loads, the site is perfectly healthy, and nothing settles. See
The scheduler console.
Two things that are not the cause, and are worth ruling out loudly:
- The feature switch.
investmentat Admin → System → Platform Settings → Features → Investment has three readers and only three: the purchase endpoint, the customer menu, and the site footer's Products → Investment link. Turning it off stops new money coming in and removes the ways in; the settlement job never consults it and keeps paying out. - A term that just ended. The job runs hourly, so a position maturing at 14:05 is paid on the next run. Up to an hour of "why is my investment not completed" is normal and correct.
One investment fails every hour: Wallet not found
The run log shows, on every pass:
Wallet not found for user <userId> in investment <id>
Error processing investment id <id>: Wallet not foundand the backend log carries
processGeneralInvestment failed for investment <id> (user <userId>, plan <planId>): Wallet not found.
What it means. Settlement looks up the wallet by three exact values —
userId, the plan's currency, and the plan's walletType — and does
not create one. If the customer holds USDT in SPOT and the plan is denominated
in USDT ECO, there is no wallet as far as this query is concerned.
What happens to the money. Nothing. The error is thrown, the database
transaction rolls back, and the row is left ACTIVE. It is never auto-cancelled
and never marked COMPLETED. That is the safe direction: a late settlement is
recoverable, a position closed without paying is a refund and a support case.
The failure does not fail the run — the loop absorbs it and carries on — so the same investment fails again on the next pass, and every pass after that, until the wallet exists.
- Find the plan's
currencyandwalletTypeon Finance → Investment Management → Investment Plans. - Confirm the customer has a wallet of exactly that type and currency — Finance → Transaction Management → Wallets, filtered to the user.
- If they do not, have them hold that currency in that wallet type, or use the balance adjustment on the wallet screen to create the wallet with a real ledger entry.
- Wait for the next hourly run, or trigger the job by hand. It settles properly — real credit, real transaction, real email.
The same mismatch produces 404 Wallet not found at purchase time, which
reads to the customer as a platform fault. A plan denominated in a currency and
wallet type your customers do not hold cannot be bought and cannot be settled.
has no associated plan or has no associated duration
Investment <id> has no associated plan (plan may have been deleted); skipping
Investment <id> has no associated duration (duration may have been deleted); skippingThe row is skipped — silently as far as the run's outcome is concerned — on every hourly pass, for ever. Principal held, never settled, no error on the website.
Plan missing. investment_plan is paranoid, so the ordinary Delete on the
plan screen soft-deletes and the settlement join then returns nothing. Restore
it: Show deleted on Investment Plans → Restore. The next hourly run picks
up every stranded position on it.
Soft-deleted plans also drop out of the history desk's analytics, because that conversion joins each position to its plan and the join is required — so retiring a plan quietly shrinks the outstanding liability the screen reports.
investment_duration is not paranoid and investment.durationId is
declared ON DELETE CASCADE. Deleting a duration destroys every investment that
used it, including ACTIVE ones whose principal had already been debited. There
is no dependency guard on the route and no restore.
If you are seeing has no associated duration rather than missing rows, the
duration id on the investment no longer resolves for some other reason — check
the row directly. Either way, never delete a duration that is attached to a plan
anyone has bought. Detach it from every plan and leave the row in place.
A customer was marked complete but never paid
The row reads COMPLETED. The customer's balance never moved. There is no
INVESTMENT_ROI transaction.
Cause: the status action on the history desk. Both
PUT /api/admin/finance/investment/history/{id}/status and the bulk
PUT .../history/status write a single column through a shared helper and
return "updated successfully". No wallet credit, no transaction row, no
result, no ROI, no email, no notification.
The damage compounds: the settlement job only ever loads rows with
status: ACTIVE, so marking an unpaid position COMPLETED permanently
removes it from the queue of things that will ever be settled.
The signature is a row showing COMPLETED with a null result. Settlement
always writes result alongside the status; a hand-set status never does.
To fix one: set the row back to ACTIVE with an endDate in the past and let
the next hourly run settle it properly. Everything about the admin actions on
that screen is in The investment history desk —
read it before you touch a row.
A payout nobody can explain
Money left the platform for an investment that was never funded.
Cause: POST /api/admin/finance/investment/history. It is a bare insert of
userId, planId, durationId, amount, profit, result, status and
endDate. No wallet is debited, no funding transaction is written, and none of
the purchase route's checks run — no feature switch, no KYC gate, no
minimum or maximum, no balance check, no "one active per plan" rule.
Insert it ACTIVE with a past endDate and the next hourly run credits the
customer principal plus ROI, out of the platform's own funds, for a
principal that was never collected.
The table's Create button is deliberately off, so this is only reachable through
the API — but create.investment exists, is grantable, and some admins reach
it. The diagnostic is a COMPLETED or ACTIVE investment with an
INVESTMENT_ROI transaction and no INVESTMENT transaction under the bare
investment id. Do not grant create.investment.
Already invested in this plan on a repeat purchase
A 400, and it is a rule rather than a fault.
A customer may hold only one ACTIVE investment per plan. A second purchase
into the same plan while the first is running is refused. Once the first
completes or is cancelled, the same plan can be bought again — and there is no
limit across plans, so a customer can hold one active position in every plan
you publish.
It is enforced in the application, inside the same transaction that creates the row, because the old database constraint blocked repeat purchases outright.
Check the customer's positions on the history desk before you go looking for a
bug: their previous investment in that plan is usually still ACTIVE. If it was
cancelled, remember the cancel path soft-deletes the row, so turn on Show
deleted to find it.
Invalid investment type 400s
Three routes share one handler pair and switch on a type query parameter or
body field: the create (POST /api/finance/investment), the single read
(GET /api/finance/investment/{id}?type=) and the cancel
(DELETE /api/finance/investment/{id}?type=). The valid values are general
and forex, case-insensitively.
An absent value is a 400 Invalid investment type on all three — each
route checks for it before the switch. If you see that 400 from your own
tooling, the caller is omitting ?type=general.
An unrecognised value is a 400 on only two of them. The create catches it
with a guard after the switch, and the cancel with an explicit default branch —
which it did not have before, so ?type=bogus used to fall through to a 500
with an undefined model, and a 500 on a money operation reads as a transient
fault and invites a retry.
The single read still has neither guard: its switch ends at the forex case,
so GET /api/finance/investment/{id}?type=bogus runs on with no model and
500s on the lookup. A 500 from that route, with a type present in the
query string, means the value is misspelled — not that the database is down.
The general list endpoint has the same requirement, plus one quirk: called with
a type and no page, it returns only ACTIVE investments and answers
404 No active investments found when there are none. An empty portfolio is
a 404 on that path, not an empty array.
A cancel fails and nothing is refunded
The customer presses Cancel on /investment/<id>, gets an error, and their
balance does not move.
The refund credit is issued with referenceId set to the bare investment
id — the same value the funding debit wrote at purchase — and
transaction.referenceId carries a platform-wide UNIQUE index. The insert
collides, the error is a duplicate reference, not a duplicate idempotency key,
so it is re-thrown rather than absorbed, and the whole cancellation transaction
rolls back: no refund, no soft delete, no state change at all.
Nothing is lost — the position is still ACTIVE and will settle normally at
maturity. If the customer must be released early, the safe route is to make the
platform's own settlement do it: set the row's result to DRAW and its
endDate into the past through PUT /api/admin/finance/investment/history/{id}
(edit.investment), and the next hourly run returns the principal, and only the
principal, through the ordinary credit path — real transaction, real email.
Do not refund on the wallet screen and leave the position ACTIVE: it would
settle again at maturity and pay the customer twice. And do not close it with
the status action, which pays nobody.
Diagnostic SQL
Read-only. Run them against the platform database.
Positions the cron should have settled and did not
SELECT i.id, i.userId, i.amount, p.currency, p.walletType,
p.title AS plan, i.endDate
FROM investment i
JOIN investment_plan p ON p.id = i.planId
WHERE i.status = 'ACTIVE'
AND i.deletedAt IS NULL
AND i.endDate IS NOT NULL
AND i.endDate < NOW() - INTERVAL 1 HOUR
ORDER BY i.endDate ASC;This is the row-level form of the Overdue Settlements tile. An empty result
with a non-zero tile means the difference is positions whose plan was
soft-deleted — the tile joins the plan too, so add
AND p.deletedAt IS NOT NULL to a second run to find them.
Settled positions with no payout transaction
SELECT i.id, i.userId, i.amount, i.profit, i.result, i.updatedAt
FROM investment i
LEFT JOIN transaction t
ON t.referenceId = CONCAT(i.id, '_roi')
AND t.deletedAt IS NULL
WHERE i.status = 'COMPLETED'
AND i.deletedAt IS NULL
AND t.id IS NULL
ORDER BY i.updatedAt DESC;The settlement credit is always written with referenceId = '<id>_roi', so a
missing row means no payout was made. Two legitimate exceptions before you
escalate: a LOSS whose ROI equals or exceeds the principal produces a zero
payout and correctly writes no transaction, and a row with result null was
never settled by the cron at all — it was closed by hand, which is the case
above.
Positions whose wallet does not exist
SELECT i.id, i.userId, p.currency, p.walletType, p.title AS plan, i.endDate
FROM investment i
JOIN investment_plan p ON p.id = i.planId
LEFT JOIN wallet w
ON w.userId = i.userId
AND w.currency = p.currency
AND w.type = p.walletType
AND w.deletedAt IS NULL
WHERE i.status = 'ACTIVE'
AND i.deletedAt IS NULL
AND w.id IS NULL;Every row here will fail settlement with Wallet not found, once an hour, until
the wallet exists.
Plans that will pay nothing
SELECT id, name, title, currency, walletType,
profitPercentage, defaultProfit, defaultResult, status
FROM investment_plan
WHERE deletedAt IS NULL
AND (profitPercentage IS NULL OR profitPercentage = 0);profitPercentage is NOT NULL DEFAULT 0 on the shipped schema, so zero is
the realistic finding — a plan that advertises 0% and settles at exactly the
principal whatever its defaultResult says. Legacy rows carrying a NULL fall
back to defaultProfit, which is the only thing that column is still for.
While you are there, look at defaultResult on every row: it is the field that
decides whether the rate is added to or taken from the principal, and it has no
safe default.
Before you escalate
Have these to hand:
- The investment id, and its
status,result,endDateanddeletedAt. - The plan's
currency,walletType,profitPercentageanddefaultResult. - Whether an
INVESTMENTtransaction exists under the bare investment id, and anINVESTMENT_ROIone under<id>_roi. - The last few runs of
processGeneralInvestmentsfrom Scheduled Tasks, including the log lines, not just the status badge. - Whether the scheduler heartbeat reads
running.
Related
- The general investment lifecycle — what a settlement run actually does, and the ROI it selects.
- The investment history desk — the three admin actions that write columns and move no money.
- General Investment endpoints, tables and keys — the reference conventions the SQL above depends on.
- What investors see, after the portfolio rework — the screens the customer is describing.
- When money does not move — the same triage for deposits, withdrawals and transfers.