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.

7 min readUpdated 6 August 2026investment, plans, durations, roi, payouts

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) × amount

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

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

  2. Check the KYC feature. If Enforce KYC Feature Access is on, the verification levels you expect to buy this must list invest_general.

  3. Set defaultResult deliberately, with the payout table above in front of you.

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

  5. Confirm the settlement job is alive on System → System Monitoring → Scheduled Tasks. processGeneralInvestments runs 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.

  6. Buy it once yourself, on a short HOUR duration, 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.