The general investment lifecycle
What happens between a customer buying a general investment plan and the hourly cron settling it — the debit, the stored maturity date, the WIN/LOSS/DRAW payout arithmetic, and the cancel refund.
An investment is a customer handing you money for a fixed term in exchange for a promised return. Three things decide what they are owed at the end of it: the ROI recorded when they bought, the outcome you set on the plan or the row, and the arithmetic below. None of the three is visible on the screens where you configure them, which is why this page exists.
This is the general investment product that ships with core — plans, durations and history under Finance → Investment Management. The AI Investments and Forex addons are separate products with their own plans, their own rows and their own settlement crons. Their payout arithmetic happens to match this one; nothing else about them does.
The three admin screens:
| Screen | Path | What you decide there |
|---|---|---|
| Investment Plans | /admin/finance/investment/plan |
Currency, wallet type, min/max, profit percentage, default outcome |
| Investment Durations | /admin/finance/investment/duration |
A number and a timeframe — HOUR, DAY, WEEK or MONTH |
| Investment History | /admin/finance/investment/history |
Nothing — it is a read-only register of individual investments. You can open a record or delete it; there is no edit action on the row |
Buying
The body is { type, planId, durationId, amount }, where type is "general"
or "forex". A "forex" purchase through this same endpoint creates a
forexInvestment row that the Forex addon's own cron settles — everything below
about settlement applies to "general" only. Any other type is a 400.
Two gates run before anything else:
- The
investmentsetting — Admin → System → Platform Settings → Features → Investment. Off, and the route answers403 Investment feature is currently disabled. This is a server-side refusal, not just a hidden menu. - The KYC feature gate. Per-feature enforcement needs both switches on,
under Features → Verification: KYC Verification (
kycStatus), the platform-wide master switch, and Enforce KYC Feature Access (kycFeatureEnforcement), which is off by default. With both on, the customer's approved verification level must listinvest_general— orinvest_forexfor a forex purchase. An unverified customer is refused with "KYC verification is required to invest."; a verified one whose level omits the feature gets "Your verification level does not include this feature (invest)." If either switch is off the gate returns immediately, nobody is checked, and no level needs to list the feature at all.
Then the ordinary validation: the plan and duration must exist, the amount must
be a finite number above zero and within the plan's minAmount and maxAmount
where those are set, and the customer must already hold a wallet of the plan's
walletType in the plan's currency with enough balance. A customer with no
such wallet gets 404 Wallet not found — the route does not create one.
One active investment per plan
A customer may hold only one ACTIVE investment per plan. A second purchase
into the same plan is refused with 400 Already invested in this plan while the
first is still running. Once that one completes or is cancelled, the same plan
can be bought again.
This is enforced in the application, inside the same transaction that creates
the row, because the old database constraint that blocked repeat purchases was
removed. If a customer says they cannot invest again, check whether their
previous investment in that plan is still ACTIVE — it usually is.
There is no such rule across plans. A customer can hold one active investment in every plan you publish.
What the purchase writes
The investment row and the wallet debit are written in one database transaction, so a failed debit leaves no orphan investment behind.
| Field | Value at purchase |
|---|---|
status |
ACTIVE |
amount |
What the customer paid |
profit |
The promised ROI as an absolute amount — plan.profitPercentage / 100 × amount |
endDate |
Computed once, at purchase, and stored (see below) |
result |
Null. It is decided at settlement |
The debit goes through the wallet service under idempotency key
investment_<investment id>, and the resulting transaction row carries
referenceId = the bare investment id, type INVESTMENT (or
FOREX_INVESTMENT), and a metadata blob naming the plan, duration and ROI.
Keying on the freshly created investment id — rather than on user, plan and
amount — is what lets the same customer buy the same plan again for the same
figure after a cancellation without the second debit being silently swallowed as
a duplicate.
A confirmation email is sent, and affiliate rewards are processed for the customer's referrer. A failure in either is logged and does not undo the purchase.
The maturity date
endDate is calculated once, at purchase, and stored on the row. The cron
reads the stored value. It never recomputes it.
The calculation adds the duration to the moment of purchase:
| Timeframe | Added |
|---|---|
HOUR |
that many hours |
DAY |
that many days |
WEEK |
that many × 7 days |
MONTH |
that many calendar months |
A MONTH is added with JavaScript's setMonth, which overflows rather than
clamping to the end of the month. A 1-month term bought on 31 January does not
mature on 28 February: the 31st of February rolls forward, so it matures on
3 March (2 March in a leap year). Whatever that arithmetic produces is the
date the customer was shown, and because it is persisted it is also the date
that settles.
Settlement reads the stored endDate, so an adjustment to it would be
honoured — but Finance → Investment Management → Investment History offers
no way to make one. The table is view-and-delete only, endDate is a read-only
column, and there is no edit dialog. The admin route
PUT /api/admin/finance/investment/history/{id} does accept endDate (along
with amount, profit, result and status), but nothing in the panel calls
it, so changing a maturity date means calling that endpoint directly.
Only legacy rows with no endDate fall back to recomputing from
createdAt, and that fallback treats a month as 30 days rather than a
calendar month. Rows created by the current purchase route always have an
endDate, so the two only differ on data that predates the column.
Settlement
The processGeneralInvestments job runs every hour from the cron process.
You can see its state and trigger it by hand on System → System Monitoring →
Scheduled Tasks.
Each run loads every investment with status: ACTIVE, oldest first, and skips
any whose stored endDate has not passed. It also skips — with an error in the
log, but without failing the run — rows whose plan, duration or user has been
deleted underneath them.
Which ROI is paid
Four sources, in strict order. The first one that has a value wins:
-
investment.roiPercentageon the row — the canonical form, a percentage of the principal. Present on rows this cron has already touched. -
investment.profit— the absolute ROI amount recorded at purchase. This is what the customer was shown when they bought, so it is what they are paid. -
plan.profitPercentage— the rate the plan currently advertises. -
plan.defaultProfit— a last-resort legacy fallback for old plans with noprofitPercentage.
The practical consequence: editing a plan's profit percentage does not
re-price investments already sold. They carry their promised figure in
profit and settle on that.
Which outcome is applied
investment.result if it has been set on the row, otherwise
plan.defaultResult. There is no market, no price feed and no model behind
this — the outcome is an operator decision. In the admin panel that decision is
only available per plan, as defaultResult: the History screen cannot edit
a row, so a per-row override has to go through
PUT /api/admin/finance/investment/history/{id}.
The payout
| Result | Credited to the customer's wallet |
|---|---|
WIN |
principal + ROI |
LOSS |
principal − ROI, floored at zero |
DRAW |
principal, unchanged |
On LOSS the customer still gets the remaining principal back. If the plan
promises 10% and the outcome is LOSS, a 1,000 investment returns 900 — not
zero. Only an ROI equal to or greater than the principal returns nothing, and
the floor at zero means the customer is never asked for more.
Operators reading the word "loss" as "the stake is forfeited" have configured
plans that pay out far more than they intended. Set defaultResult with the
table above in front of you.
What the credit looks like in the ledger
The payout is credited under idempotency key
investment_roi_<investment id>_<result> — the result is part of the key so
that if an outcome is ever changed between runs the two differing payouts cannot
collapse into one deduplicated credit.
The resulting transaction row has type INVESTMENT_ROI and referenceId
<investment id>_roi.
transaction.referenceId carries a platform-wide unique index, and the
purchase already wrote the funding debit under the bare investment id. Reusing
it for the payout raised a duplicate-key error that rolled the whole settlement
back — which stranded every general investment ACTIVE for ever. The suffix is
load-bearing. The same convention is used by the AI investment cron.
Alongside the customer's credit, recordInvestmentOutcome books the
platform's own leg against adminProfit under type INVESTMENT:
- a
WINis recorded as money the platform paid out (a negative entry, with the treasury debited best-effort up to its available balance — a house with no reserves never blocks a customer's winnings); - a
LOSSrecords the forfeited slice as platform revenue; - a
DRAWbooks nothing, because nothing changed hands.
Without that leg, Finance → Revenue Analytics reported fees collected while the ROI funded against them was invisible, so a positive number could be a losing month. It never throws; a failure to book the platform's side does not block the customer's payout.
Finally the row is updated to status: COMPLETED with the result, the
roiPercentage and the absolute profit written back, and the customer gets an
email plus an in-app notification linking to /investment/<id>.
When settlement fails
If the wallet cannot be found, or the database transaction fails for any other
reason, the error is logged with the investment, user and plan ids and the row
is left ACTIVE. The error is rethrown, but it does not escape the run: the
loop catches it per investment, writes an error line to that run's log, and
moves on to the next one. The run then finishes normally and Scheduled Tasks
records it as completed.
The investment is never auto-cancelled and never marked COMPLETED on a
failure. That is the safe direction: an investment that settles late is
recoverable, one that is closed without paying is a support case and a refund.
So a green run on Scheduled Tasks is not evidence that every customer was
paid — only a failure of the whole job, such as the query that loads the active
investments, shows up as failed. The evidence for a single stranded
settlement is the run's own log lines (Error processing investment id …) and
the backend cron log. Find the investment id there, fix the underlying cause —
almost always a missing wallet in the plan's currency and wallet type — and the
next hourly run settles it.
Cancelling
Cancellation is customer-initiated, from the investment's own page. It:
- refuses anything that is not
ACTIVEwith400 Only active investments can be cancelled; - credits the full principal back to the wallet it came from, under
idempotency key
investment_refund_<id>, as aREFUNDtransaction. No ROI is paid and no fee is taken; - leaves the original debit row in place and merges
cancelled,cancelledAtandrefundTransactionIdinto its metadata, so the audit trail survives; - soft-deletes the investment row (the table is paranoid, so it is recoverable);
- frees the plan for a repeat purchase.
The type query parameter is required. An unrecognised value is now a 400
Invalid investment type — it used to fall through to a 500, which read as a
transient fault and invited a retry.
Status reference
| Status | Meaning |
|---|---|
ACTIVE |
Running, or past its maturity date and waiting for the next cron run |
COMPLETED |
Settled — result and profit are final |
CANCELLED |
Reserved on the model; the customer cancel path soft-deletes instead |
REJECTED |
Reserved on the model |
| Result | Meaning |
|---|---|
WIN |
Principal plus ROI was paid |
LOSS |
Principal minus ROI was paid, floored at zero |
DRAW |
Principal only was returned |
Related
- Settings reference — the
investmentswitch and the KYC feature-enforcement switch. - The admin panel — where Investment Management sits, and how Scheduled Tasks reports a failed run.