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.

9 min readUpdated 6 August 2026investment, payouts, cron, wallets, roi

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

Creates an investment

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 investment settingAdmin → System → Platform Settings → Features → Investment. Off, and the route answers 403 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 list invest_general — or invest_forex for 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 amountplan.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:

  1. investment.roiPercentage on the row — the canonical form, a percentage of the principal. Present on rows this cron has already touched.

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

  3. plan.profitPercentage — the rate the plan currently advertises.

  4. plan.defaultProfit — a last-resort legacy fallback for old plans with no profitPercentage.

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 WIN is 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 LOSS records the forfeited slice as platform revenue;
  • a DRAW books 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

Cancels an investment and refunds the principal

Cancellation is customer-initiated, from the investment's own page. It:

  • refuses anything that is not ACTIVE with 400 Only active investments can be cancelled;
  • credits the full principal back to the wallet it came from, under idempotency key investment_refund_<id>, as a REFUND transaction. No ROI is paid and no fee is taken;
  • leaves the original debit row in place and merges cancelled, cancelledAt and refundTransactionId into 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
  • Settings reference — the investment switch and the KYC feature-enforcement switch.
  • The admin panel — where Investment Management sits, and how Scheduled Tasks reports a failed run.