Investment plans and durations
Every field on an investment plan, which two of them decide what a customer is paid, how durations join to plans, and why deleting either can destroy live positions.
A plan is the product a customer buys: a currency, a wallet to fund it from, a minimum and a maximum ticket, a rate, and a rule for what happens at maturity. A duration is the term they buy it for. The two are separate tables joined many-to-many, so one plan can be sold over several terms and one term can be reused across every plan.
Two screens, no landing page between them:
| Screen | Path | Table | Deletes |
|---|---|---|---|
| Investment Plans | /admin/finance/investment/plan |
investment_plan |
Soft (paranoid) |
| Investment Durations | /admin/finance/investment/duration |
investment_duration |
Permanent |
Before anything else on this page: read the deletion warning at the bottom. It is the one action here that can destroy a customer's principal.
Plan fields
Full create/edit/delete, in four groups on the form. Permissions are
access|view|create|edit|delete.investment.plan.
Basic information
| Field | Notes |
|---|---|
name |
The internal identifier. Unique across all plans, required, and the string that shows up in transaction descriptions and support threads. |
title |
The display name customers see. |
image |
Optional. Must be a path under /uploads/ or /img/ — an external URL is rejected by validation. |
description |
Required, free text, shown on the plan page. |
Financial settings — these four decide the ticket.
| Field | Notes |
|---|---|
walletType |
FIAT, SPOT or ECO. The customer must already hold a wallet of this type in this currency; the purchase route does not create one, it answers 404 Wallet not found. |
currency |
The currency picker reloads from the wallet type, so set the wallet type first. |
minAmount |
Enforced on purchase when greater than zero. |
maxAmount |
Enforced on purchase when greater than zero. |
profitPercentage |
The rate. See below — this is the only figure that reaches a payout. |
Profit configuration — four fields, and only two of them do anything.
| Field | Notes |
|---|---|
minProfit |
Required by the form. Nothing in the purchase or settlement path reads it. |
maxProfit |
Same. |
defaultProfit |
A last-resort legacy fallback, used only for old plans that have no profitPercentage at all. |
defaultResult |
WIN, LOSS or DRAW. Required. This decides what maturity does to the principal. |
Plan settings
| Field | Notes |
|---|---|
durations |
Multi-select of duration rows. Required — a plan with no term cannot be bought. |
trending |
Cosmetic flag for the customer-facing plan list. |
status |
Active or inactive. |
invested |
Shown on the table and editable, but nothing increments it. It is not a measured total of anything. Do not publish it as social proof. |
The two fields that decide money
Everything a customer is paid comes from profitPercentage and defaultResult.
Nothing else on the form reaches a wallet.
At purchase the route computes, once:
roi = (plan.profitPercentage / 100) × amountand stores it on the investment row as an absolute amount. That stored figure is what settles — so editing a plan's rate does not re-price positions already sold. They carry the number they were promised.
At maturity the outcome is investment.result if one has been set on the row,
otherwise the plan's defaultResult, and it decides the direction:
defaultResult |
What the customer gets back |
|---|---|
WIN |
principal + ROI |
DRAW |
principal, unchanged |
LOSS |
principal − ROI, floored at zero |
A plan at 12% with defaultResult: LOSS deducts 12% of the principal at
maturity. A plan at 12% with DRAW pays nothing at all and returns the stake.
The rate on its own says nothing about direction.
defaultResult is required on the form and has no safe default. Set it
deliberately on every plan, and re-check it on any plan you inherited.
Note also what LOSS does not mean: it is not forfeiture of the stake. A 1,000
investment on a 10% LOSS plan returns 900. Only an ROI at or above 100% returns
nothing.
profit on the row is always positive
The profit column on an investment is the absolute ROI, never a signed
profit-and-loss figure. It is written at purchase, before anything has been
earned, and the settlement job overwrites it with the same unsigned magnitude
even when the outcome is LOSS — that is, even when the amount was deducted.
The consequence on screen: the history desk prints profit with an explicit
sign, and it is a plus on a LOSS row too. On a LOSS row, +50 means fifty
was taken. Read the Result badge before you read the number, and never
quote the profit column to a customer without it.
A loss is expressed by result, never by a negative profit. If you are
tempted to type a minus into that field on the history desk, you are about to
create a record nothing in the platform will interpret the way you meant.
Durations
/admin/finance/investment/duration is two fields:
| Field | Values |
|---|---|
duration |
An integer |
timeframe |
HOUR, DAY, WEEK or MONTH |
So 3 + MONTH is one row, reusable by every plan. Attach them to a plan from
the plan form's Durations multi-select; the join is written to
investment_plan_duration when the plan is saved.
Because durations are shared, editing one changes the term for every plan that carries it, and for any future purchase on those plans. Positions already open keep the maturity date computed at purchase and stored on their own row, so an edit does not move an existing customer's clock. If you want a different term for one plan, add a new duration row rather than editing a shared one.
The duration table is not paranoid: there is no soft delete, no restore and no Show deleted view.
The history desk
/admin/finance/investment/history is the record of every position ever opened,
active and settled, with an analytics header. It is a read-and-investigate
screen, and the three write actions behind it do not do what their names
suggest.
That screen has its own page: The investment history desk. Read it before you touch a row.
What the customer sees
The customer-facing product is three pages, and they do not mirror the admin screens one for one:
| Customer page | What is on it |
|---|---|
/investment |
Overview — what the plans are and how a term settles |
/investment/plan |
Every plan with status: true, its rate, terms and currency |
/investment/portfolio |
Running positions first, settled ones behind a disclosure |
/investment/<id> |
One position: the maturity clock, and the Cancel control |
/investment/portfolio replaced the old separate dashboard and history
pages. Both old paths still resolve as redirects, because they are linked from
the site menu, from settlement notifications and from investment emails — so a
customer quoting /investment/history is not on a stale build.
Two differences between what you see and what they see, both of which produce support tickets:
- Cancelling is theirs, not yours. The customer cancels from
/investment/<id>; the principal is refunded in full, no ROI is paid, and the investment row is soft-deleted rather than moved toCANCELLED. So a cancelled position disappears from the history desk's default view — turn on Show deleted to find it. There is no admin cancel action anywhere. - One active position per plan. A customer cannot buy the same plan twice
while the first is running; they get
400 Already invested in this plan. There is no such limit across different plans.
Deleting
investment_duration is not paranoid, so a delete there is permanent, and the
investment.durationId relation is declared ON DELETE CASCADE. There is no
dependency guard on this route.
The sibling AI Investments addon had exactly this defect and now refuses the delete while any investment depends on the row — its guard file documents the outcome plainly: the cascade destroyed every investment using that duration, including ACTIVE ones whose principal had already been debited, leaving no row to reconcile against and no refund. The core investment routes have no equivalent guard.
Never delete a duration that is attached to a plan anyone has bought. Detach it from every plan and leave the row in place.
investment_plan is paranoid, so the row menu's ordinary Delete is a soft
delete and the cascade does not fire. That is not safe either: the settlement job
joins the plan, and a soft-deleted plan makes that join return nothing. The job
logs "has no associated plan (plan may have been deleted); skipping" and moves
on — every ACTIVE position on that plan is then skipped on every hourly run, for
ever. Principal locked, never settled, no error visible on the website.
Soft-deleted plans also drop out of the history desk's analytics, so the outstanding capital you are still liable for stops being counted.
Permanent Delete in the same row menu sends force=true and hard-destroys
the row, which does fire the cascade.
To retire a plan safely: set status to inactive, wait for every position on it
to settle, and delete only then. If a plan was already deleted with live
positions on it, restore it (Show deleted → Restore) and the next hourly run
picks the stranded positions up.
Before you publish a plan
-
Check the feature switch. Admin → System → Platform Settings → Features → Investment must be on, or every purchase is refused with a 403. Nothing seeds that row and the toggle reads on regardless, so on a new install toggle it off and back on and save before you trust it. See General Investment: enable and configure.
-
Check the KYC feature. If Enforce KYC Feature Access is on, the verification levels you expect to buy this must list
invest_general. -
Set
defaultResultdeliberately, with the payout table above in front of you. -
Confirm the currency and wallet type match a wallet your customers already have. A plan denominated in a currency nobody holds fails at purchase with Wallet not found, which reads to the customer like a platform fault.
-
Confirm the settlement job is alive on System → System Monitoring → Scheduled Tasks.
processGeneralInvestmentsruns hourly, so a term ending at 14:05 pays on the next run — and a stopped scheduler pays nothing while the site looks perfectly healthy. -
Buy it once yourself, on a short
HOURduration, and watch it settle.
For what a settlement run actually does — the ROI it selects, the ledger rows it writes and what happens when one fails — see The general investment lifecycle.