General Investment endpoints, tables and keys
Every general-investment endpoint with the permission it gates on, the four tables and their columns, the transaction and idempotency conventions, and the switch, KYC feature and cron behind them.
Two surfaces. Everything under /api/finance/investment is scoped to the
calling user and carries no permission — the purchase route is gated by the
feature switch and a KYC feature instead. Everything under
/api/admin/finance/investment carries an explicit permission, and there are
twenty-four of them.
This is the general investment product in core. AI Investments, Forex and Staking are separate products with separate tables, routes, crons and permission keys; nothing on this page applies to them.
Conventions that will catch you out
The platform pins the HTTP status at 200 and puts the real outcome in the body. A client that branches on the status code will report success for a refusal.
amount, profit and roiPercentage are DOUBLE columns, so they arrive as
JSON numbers rather than the strings most money columns on this platform return.
They are still money and double arithmetic is still inexact — do not compare two
computed payouts for equality.
transaction.referenceId carries a platform-wide UNIQUE index
(transactionReferenceIdKey). Every leg of one investment's life therefore has
to use a different reference, and one of them does not — see Transactions and
references below.
An investment row has no currency column. The denomination belongs to its
plan (investment_plan.currency), so any sum across investments must join the
plan and convert.
User endpoints
GET /api/finance/investment
?type= is required and is general or forex. A bad type is not always a
clean 400 — which error you get depends on which of the two modes below the
request lands in.
The route has two modes, and the switch between them is surprising:
- With
typeand nopageit returns the caller'sACTIVEinvestments as a bare array, each with its duration attached, and throws404 No active investments foundwhen there are none. An empty portfolio is a 404, not an empty list. This branch is taken before the type is validated, and its own switch has nodefault, so an unrecognised type leaves the model unset and the query throws — a 500, not a 400. Treat a 500 here as a typo intype. - With a
pageparameter it runs the standard filtered query: scoped touserId, sorted bycreatedAt, withplantrimmed toid,title,imageandcurrency, anddurationtoid,durationandtimeframe. The plan's rate is not in that projection, which is why the portfolio screen also loads the plan catalogue. This is the only path that validates the type, so 400Invalid investment typecomes from here — and from a request with notypeat all, which falls through to the same check.
POST /api/finance/investment
{ "type": "general", "planId": "…", "durationId": "…", "amount": 1000 }All four are required. A "forex" purchase through this same endpoint creates a
forexInvestment row that the Forex addon's cron settles.
Refusals, in the order they are checked:
| Refusal | Cause |
|---|---|
401 Unauthorized |
No session |
403 Investment feature is currently disabled |
The investment setting is not the exact string "true" |
| KYC refusal | invest_general (or invest_forex) missing from the level, when enforcement is on |
400 Invalid investment type |
type absent, non-string, or not general/forex |
404 Investment plan not found / 404 Investment duration not found |
Bad id |
400 Invalid investment amount |
Not a finite number above zero |
400 Amount must be at least … / must not exceed … |
Outside minAmount/maxAmount, each enforced only when greater than zero |
404 Wallet not found |
No wallet in the plan's exact currency and walletType. The route does not create one |
400 Insufficient balance |
Wallet balance below the amount |
400 Already invested in this plan |
The caller already holds an ACTIVE investment in this plan |
The response is a bare { "message": "Investment created successfully" } — the
row is not returned. Re-read the list to get it.
GET and DELETE /api/finance/investment/{id}
Both require ?type=. Both scope the lookup to where: { id, userId }, so a
row that belongs to somebody else answers the same 404 Investment not found as a row that does not exist.
DELETE refuses anything that is not ACTIVE with 400 Only active investments can be cancelled, credits the full principal back as a REFUND, annotates the
original debit's metadata with cancelled, cancelledAt and
refundTransactionId, then soft-deletes the investment row. No ROI is paid
and no fee is taken. It returns
{ "message": "Investment cancelled and the principal returned to your wallet" }.
The three public reads
plan, plan/{id} and stats all declare requiresAuth: false. Treat
everything they return as public.
The plan projection served to customers is id, title, description,
image, minAmount, maxAmount, profitPercentage, currency, walletType,
trending, defaultResult, plus each attached duration's id, duration and
timeframe. name, invested, minProfit, maxProfit and defaultProfit
are deliberately withheld.
The list filters on status: true. plan/{id} and the purchase route do not.
A deactivated plan is unlisted, not closed: anyone holding the id can still open
it and still buy it.
stats returns activeInvestors, totalInvested, averageReturn,
totalPlans and maxProfitPercentage. totalInvested is SUM(amount) across
every investment regardless of its plan's currency, so it adds unlike
currencies together; nothing in the product renders it, and neither should you.
Admin endpoints
Twenty-four routes across three objects. None of the fifteen permission keys is granted to any role by default.
Investments (the history desk)
The two status doors accept ACTIVE, COMPLETED, CANCELLED or REJECTED.
The edit door writes userId, planId, durationId, amount, profit,
result, status and endDate; the create door writes the same eight.
The status doors write one column through the shared updateStatus helper: no
wallet credit, no transaction row, no ROI, no email, no notification. Marking
an unpaid position COMPLETED also removes it from the settlement queue for
ever, because the cron only loads rows with status: ACTIVE.
The create door is a plain insert with no debit, no funding transaction and none
of the purchase route's checks. Inserted ACTIVE with a past endDate, the
next hourly run pays out principal plus ROI that was never collected.
The delete door stamps deletedAt and refunds nothing.
Read The investment history desk before
granting create.investment, edit.investment or delete.investment to
anyone.
Plans
Create and update both accept name, title, description, image,
minProfit, maxProfit, minAmount, maxAmount, invested,
profitPercentage, status, defaultProfit, defaultResult, trending,
currency, walletType and a durations array of duration ids, which is
written to the join table.
plan/options returns { id, name } where name is the plan's title,
not its name column, and only for plans with status true.
Durations
Durations have no status column, so there is no status endpoint. There is also no dependency guard: unlike the AI Investments addon, these routes do not refuse a delete while investments reference the row.
ON DELETE CASCADE on investment_ibfk_3 means deleting a duration destroys
every investment that used it, including ACTIVE ones whose principal has
already been taken — leaving no row to reconcile against and no refund. Detach
the duration from every plan and leave the row in place.
Delete semantics
Every delete route above takes the platform's standard query parameters:
| Query | Effect |
|---|---|
| (none) | Soft delete on a paranoid model (investment, investment_plan); permanent on investment_duration, which has no deletedAt |
?force=true |
Hard destroy — fires the foreign-key cascades |
?restore=true |
Un-delete a soft-deleted row |
Permission keys
| Object | Keys | Screen |
|---|---|---|
investment |
access · view · create · edit · delete |
/admin/finance/investment/history |
investment.plan |
access · view · create · edit · delete |
/admin/finance/investment/plan |
investment.duration |
access · view · create · edit · delete |
/admin/finance/investment/duration |
access.* is what the menu entry and the page gate on; view.* is what the
table's fetch gates on. Grant them together or the screen opens empty and never
loads a row, with no error. See Roles and permissions.
Tables
investment
Paranoid — filter on deletedAt IS NULL unless you want cancelled positions.
| Column | Type | Notes |
|---|---|---|
id |
CHAR(36) UUID | Primary key |
userId |
UUID | ON DELETE CASCADE to user |
planId |
UUID | ON DELETE CASCADE to investment_plan |
durationId |
UUID | ON DELETE CASCADE to investment_duration |
amount |
DOUBLE, not null | The principal, in the plan's currency |
profit |
DOUBLE, nullable | The absolute ROI amount, always positive. Written at purchase as the promise, overwritten at settlement with the same unsigned magnitude even on a LOSS |
roiPercentage |
DOUBLE, nullable | The canonical form: profit as a percentage of the principal. Null until settlement writes it |
result |
ENUM, nullable | WIN, LOSS, DRAW. Null on every row until settlement |
status |
ENUM, not null | ACTIVE, COMPLETED, CANCELLED, REJECTED. Default ACTIVE |
endDate |
DATETIME(3), nullable | Computed once at purchase and stored. Settlement reads this value and never recomputes it |
createdAt / updatedAt / deletedAt |
DATETIME |
A loss is expressed by result, not by a signed profit. On a LOSS row,
profit of 50 means fifty was taken. Read the result before you read the
number, and never quote the column to a customer without it.
There is a non-unique index on (userId, planId, status). It used to be unique,
which blocked re-investing after a cancellation because MySQL ignores a partial
WHERE on a unique index. The "one ACTIVE per plan" rule is now enforced in
the application, inside the same transaction that creates the row.
investment_plan
Paranoid. name is UNIQUE.
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
name |
VARCHAR(191), UNIQUE | Internal identifier. Appears in transaction descriptions; never served to customers |
title |
VARCHAR(191) | The public name |
image |
VARCHAR(191), nullable | Validated against ^/(uploads|img)/.*$ — an external URL is rejected |
description |
TEXT, not null | Public |
currency |
VARCHAR(191), not null | The currency the plan takes |
walletType |
VARCHAR(191), not null | FIAT, SPOT or ECO |
minAmount / maxAmount |
DOUBLE, not null | Enforced on purchase only when greater than zero |
profitPercentage |
DOUBLE, not null, default 0 | The rate. The only figure that reaches a payout |
defaultResult |
ENUM, not null | WIN, LOSS, DRAW. The direction. No database default |
defaultProfit |
INT, not null, default 0 | Last-resort legacy fallback for plans with no profitPercentage |
minProfit / maxProfit |
DOUBLE, not null | Required by the form; nothing reads them |
invested |
INT, not null, default 0 | Admin-editable and never incremented by anything |
trending |
TINYINT(1), nullable, default 0 | Cosmetic |
status |
TINYINT(1), not null, default 1 | Only true plans are listed |
investment_duration
Not paranoid and carries no timestamps. id (UUID), duration
(INT, not null) and timeframe (ENUM HOUR, DAY, WEEK, MONTH). No unique
index across the pair, so duplicate 3 MONTH rows are possible.
investment_plan_duration
The join. id, planId, durationId, no timestamps, both foreign keys
ON DELETE CASCADE. The shipped schema carries
UNIQUE (planId, durationId), so a duration can be attached to a plan only
once. A row here is what makes a term selectable on a plan.
Transactions and references
| Event | Wallet operation | transaction.type |
referenceId |
Idempotency key |
|---|---|---|---|---|
| Funding debit | INVESTMENT |
INVESTMENT |
<id> |
investment_<id> |
| Maturity payout | INVESTMENT_ROI |
INVESTMENT_ROI |
<id>_roi |
investment_roi_<id>_<RESULT> |
| Cancellation refund | REFUND |
REFUND |
<id> |
investment_refund_<id> |
A forex purchase through the same route uses FOREX_INVESTMENT instead.
Two conventions are load-bearing:
- The
_roisuffix on the payout. The purchase already wrote the funding debit under the bare investment id, andreferenceIdis unique platform-wide. Reusing it raised a duplicate-key error that rolled the whole settlement back and stranded every general investmentACTIVEfor ever. - The result inside the payout's idempotency key. The payout amount varies by outcome, so if an outcome is ever changed between runs the two differing credits must not collapse into one deduplicated credit.
The refund credit is issued with referenceId set to the bare investment
id — the same value the funding debit already wrote — while
transaction.referenceId carries the transactionReferenceIdKey UNIQUE index.
The insert therefore collides, the error is 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, and an error back to the customer.
The idempotency key (investment_refund_<id>) is distinct and correct; it is
the reference that is not. Treat a customer-reported "cancel failed" as this
until proven otherwise, and refund by hand from Finance → Transaction
Management → Wallets if one is owed.
The platform's own leg
Settlement also books the house side against adminProfit under type
INVESTMENT, via recordInvestmentOutcome:
| Outcome | adminProfit |
Reference | Wallet effect |
|---|---|---|---|
WIN |
Negative row for the ROI paid | <id>_payout |
Treasury debited best-effort, capped at its available balance, as PLATFORM_LOSS with reference <id>_payout_loss |
LOSS |
Positive row for the ROI kept | <id>_house |
Super Admin credited as PLATFORM_FEE with reference <id>_house_fee |
DRAW |
Nothing | — | Nothing |
Neither helper ever throws. A treasury with no reserves never blocks a
customer's winnings — the negative adminProfit row is still written, so
Finance → Revenue Analytics nets out correctly, and a shortfall is logged as
a warning.
The switch, the KYC feature and the cron
Admin → System → Platform Settings → Features → Investment. Read by exactly
three things: the purchase endpoint, which refuses with a 403 when it is not the
string "true"; the customer site menu (Investments → Investment Plans); and
the site footer, whose Products section drops its Investment link on the same
key. The settlement cron does not consult it — turning the feature off stops
new money coming in and does not stop payouts on money already in.
The toggle reads on over an empty settings table because the admin screen renders over built-in defaults, and only changed fields are saved. On a fresh install, toggle it off, back on, and save to create the row.
| KYC feature | Guards |
|---|---|
invest_general |
POST /api/finance/investment with type: "general" |
invest_forex |
The same endpoint with type: "forex" |
The gate is inert unless both kycStatus and kycFeatureEnforcement are
on, and the second is off by default on every install.
| Job | Title on the console | Category | Period |
|---|---|---|---|
processGeneralInvestments |
Process General Investments | normal |
1 hour |
A core job: registered unconditionally in the scheduler's constructor, needing
no extension row, and present on System → System Monitoring → Scheduled
Tasks (/admin/system/cron) on every install. Viewing needs view.cron;
triggering it by hand needs manage.cron.
There is no WebSocket surface for this product.