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.

8 min readUpdated 6 August 2026api, endpoints, permissions, database, investment

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

The caller's investments. Requires a type; paginated, or ACTIVE-only
Opens an investment. Debits the wallet and stores the maturity date
One investment, owner only
Cancels an ACTIVE investment and refunds the principal
The caller's ACTIVE investments with the full plan attached
Every plan with status true. Public — no session required
One plan. Public, and NOT filtered on status
Platform-wide investment figures. Public

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 type and no page it returns the caller's ACTIVE investments as a bare array, each with its duration attached, and throws 404 No active investments found when 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 no default, so an unrecognised type leaves the model unset and the query throws — a 500, not a 400. Treat a 500 here as a typo in type.
  • With a page parameter it runs the standard filtered query: scoped to userId, sorted by createdAt, with plan trimmed to id, title, image and currency, and duration to id, duration and timeframe. 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 400 Invalid investment type comes from here — and from a request with no type at 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)

List every investment, filtered, sorted and paginated
Insert an investment row. Debits nobody — see the warning
Bulk status write
Bulk delete, restore or force-destroy
One investment in full
Edit one investment's columns
Delete, restore or force-destroy one investment
Single status write

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

List plans with their durations
Create a plan and attach its durations
Bulk activate or deactivate
Bulk delete, restore or force-destroy
Plans as picker options — id plus title, active plans only
One plan in full
Update a plan and its durations
Delete, restore or force-destroy one plan
Activate or deactivate one plan

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

List durations
Create a duration
Bulk delete — PERMANENT, and cascades
Durations as picker options, formatted 30 DAY
One duration
Update a duration
Delete one duration — PERMANENT, and cascades

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 _roi suffix on the payout. The purchase already wrote the funding debit under the bare investment id, and referenceId is unique platform-wide. Reusing it raised a duplicate-key error that rolled the whole settlement back and stranded every general investment ACTIVE for 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

Master switch for the general investment product. Stored as text — the exact string "true" or "false". No database default, and nothing seeds the row.

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.