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.

11 min readUpdated 6 August 2026investment, settlement, cron, payouts, sql

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

  1. 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 needs view.cron; triggering it by hand needs manage.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.

  2. 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 activeCapital Outstanding principal across every ACTIVE position, in USD. What you owe.
    Overdue Capital overdueCapital The slice of that which is past its endDate by more than an hour.
    Overdue Settlements overdueCount How 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. investment at 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 found

and 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.

  1. Find the plan's currency and walletType on Finance → Investment Management → Investment Plans.
  2. Confirm the customer has a wallet of exactly that type and currency — Finance → Transaction Management → Wallets, filtered to the user.
  3. 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.
  4. 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); skipping

The 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, endDate and deletedAt.
  • The plan's currency, walletType, profitPercentage and defaultResult.
  • Whether an INVESTMENT transaction exists under the bare investment id, and an INVESTMENT_ROI one under <id>_roi.
  • The last few runs of processGeneralInvestments from Scheduled Tasks, including the log lines, not just the status badge.
  • Whether the scheduler heartbeat reads running.