What investors see, after the portfolio rework

The four customer-facing investment routes, the two old paths that are now redirect stubs, which plan fields render on which screen, and where the admin shortcut points.

8 min readUpdated 6 August 2026investment, routes, navigation, notifications, support

When a customer asks "where do I see my investment?", the answer changed. The old dashboard and the old history table were folded into one portfolio screen, and a per-investment page was added that never existed before. This page is the map: which route shows what, which old paths still work and why, and what each screen reads off a plan you configured.

Everything here is the general investment product that ships with core — /investment, backed by Finance → Investment Management in the admin. AI Investments, Forex and Staking are separate products, with their own customer pages, their own tables and their own admin screens; nothing below applies to them.

The four routes

Route Sign-in What is on it
/investment No The product overview: a hero with up to three platform figures, a featured-plans strip, how a term settles, and what to expect
/investment/plan No Every plan with status true, as cards, with search and filter chips
/investment/plan/<plan id> No to read, yes to buy One plan in full, with the invest composer beside it
/investment/portfolio Yes Running positions first, settled positions below behind a disclosure
/investment/<investment id> Yes One position: principal, status, the maturity clock, and the Cancel control

The first three are readable by a signed-out visitor, because the two endpoints behind them (GET /api/finance/investment/plan and GET /api/finance/investment/stats) declare no auth. That is deliberate: the landing page is the marketing surface for the product, and it used to render completely blank for anyone not signed in.

The overview's figures are conditional

The hero draws at most three numbers, from GET /api/finance/investment/stats, and each one is omitted when it is zero:

Figure Source
Plans available Count of investment_plan rows with status true
People invested Distinct users holding an ACTIVE investment
Highest rate per term MAX(profitPercentage) across active plans

So a fresh install shows a hero with no statistics rather than a row of zeroes. If an operator expects figures and sees none, the cause is an empty book, not a broken endpoint.

totalInvested is computed by that endpoint but is not rendered anywhere. It is SUM(amount) across every investment regardless of its plan's currency, so it adds naira to tether; there is no honest single figure to print.

"Highest rate" is profitPercentage, which applies to the whole term whatever its length. A 5% plan on a 7-day duration pays 5% over those seven days. Nothing on these pages annualises it, and you should not either when answering a customer.

/investment/dashboard and /investment/history are redirect stubs

Both paths still resolve. Both now redirect to /investment/portfolio, using the locale-aware redirect so the customer's language segment survives the hop.

They are kept because they have been linked from investment emails, from notifications and from customers' own bookmarks for as long as the product has existed. Live in-app navigation points at /investment/portfolio directly — a stub exists for links nobody controls any more, not as a routing layer.

What that means on a support ticket: a customer quoting /investment/history is not on a stale build. They followed an old link, landed on the portfolio, and everything they were looking for is on it — the settled section is the old history table, and the running section is the old dashboard.

What was lost, deliberately: the old history table declared amount and profit as plain number columns and disabled its own view dialog, so on desktop there was no way to find out what currency a figure was in. Every figure on the portfolio carries its unit.

The settlement cron's completion notification — and the View Investment button on it — linked to /investments/{id}, plural. There is no /investments route in this product and there never was. Every customer who tapped that button, in every completion notification the platform has ever sent, landed on a not-found page.

It now links /investment/<investment id>, singular, which is the per-position page described below. The singular form was equally dead until this release, because no per-investment page existed at all.

Notifications already delivered are not rewritten. If a customer complains that the link in an old notification is broken, that is why; send them to /investment/portfolio and the position is there.

The same notification is typed system (stored as SYSTEM, normal priority), so it sorts with ordinary platform messages rather than above them.

The navigation, and who sees which item

The investment chrome renders three items, from frontend/app/[locale]/investment/menu.ts:

Item Href Notes
Overview /investment exact: true — without it the item would light up on every /investment/* route
Plans /investment/plan
My investments /investment/portfolio auth: true

auth: true means the layout filters the item out entirely for a signed-out visitor, rather than showing a door onto a sign-in wall. So a customer who says "I can't find My investments" is almost certainly signed out — the item is not hidden by permission, plan ownership or the feature switch.

There is deliberately no "Invest" item. Investing is an action, and it lives on every plan card and at the head of the portfolio, not in the nav bar.

Separately, the main site menu carries Investments → Investment Plans pointing at /investment, and that entry is gated on the investment setting — turn the feature off and the entry disappears from the site menu while the routes themselves keep resolving. See General Investment: enable and configure.

Menu labels resolve through the investment translation namespace with the nav prefix, and the item's key is the lookup path — so Overview reads investment.nav.overview.title. All three title keys are present in all 90 message bundles, and all 90 hold the English word. The same words already exist translated as common.overview (89 locales) and common.plans (85), but the menu translator can only reach one namespace, the one derived from the route, so it cannot use them.

Until those keys are filled in, each label falls back to the English title in the menu file. Adding a locale value to investment.nav.overview.title, investment.nav.plans.title or investment.nav.portfolio.title does render — the lookups are live, and the same holds for the three descriptions.

The admin shortcut points at Investment Plans

The header on these pages carries an admin shortcut, and it resolves to /admin/finance/investment/plan.

The shortcut previously opened /admin/ai/investment. That is a different product: its own tables (ai_investment, ai_investment_plan, ai_investment_duration), its own routes, its own settlement cron and its own permission keys. An operator following the shortcut from these pages arrived at a console whose plans do not appear here and whose edits do nothing to any position on these screens.

There is no /admin/finance/investment index page — the product has three leaf screens and no landing screen — so the shortcut goes to plan, the screen that owns everything the customer pages render.

Which plan fields render where

Both plan endpoints select the same twelve-ish attributes, and the two screens draw different subsets of them. Nothing else on the plan reaches a customer.

On a plan card (/investment/plan, and the featured strip on /investment)

Shown Field
Thumbnail, 56px square image — rendered here for the first time; a missing file keeps the tile so the grid stays aligned
Title title
Trending badge trending
Description, clamped to three lines description
The headline rate profitPercentage
An outcome chip beside the rate defaultResult
Minimum / Maximum minAmount / maxAmount, with the plan's currency
Terms The attached durations, written out by ICU as "30 days" / "3 months" — pluralised and localised, never the raw MONTH enum

Cards sort trending first, then by rate descending. Filter chips are All plans, Trending (only when at least one plan is trending) and one chip per currency (only when more than one currency is represented) — so the control never offers a dead option.

On the plan page (/investment/plan/<id>)

Everything above except the trending badge, plus:

Shown Field
Currency currency
Funded from walletTypeFIAT, SPOT or ECO
Terms as badges The attached durations

The picture is bounded both ways and hidden entirely when the file is missing, because a single-plan page has no grid alignment to preserve.

profitPercentage is a magnitude. defaultResult is the direction, and it is what the outcome chip on both screens states. A plan at 12% with defaultResult: LOSS deducts 12% of the principal at maturity; at DRAW it pays nothing and returns the stake.

That field is served to the customer precisely so the pages cannot present a deduction as a gain. Set it deliberately on every plan — see Investment plans and durations.

Never served to a customer: name (the internal unique identifier), invested (an admin-typed number that nothing increments — do not publish it as social proof), minProfit and maxProfit (vestigial; nothing in the purchase or settlement path reads them), and defaultProfit (a legacy fallback).

The single-plan endpoint ignores status

GET /api/finance/investment/plan filters on status: true. GET /api/finance/investment/plan/{id} does not, and neither does the purchase endpoint.

So a plan you deactivate disappears from /investment/plan but its own page still opens, still shows the composer, and still sells. Anyone holding the id — a bookmark, an old email, a script — can keep buying it. If a plan must genuinely stop taking money, read the deletion warning in Investment plans and durations first.

The portfolio

Signed-in only. It loads the customer's investments and the plan catalogue — the list endpoint trims the embedded plan to four fields and sends neither the rate nor the settlement rule, so the catalogue supplies both.

  • A summary strip, bucketed by currency. An investment carries no currency of its own; the denomination belongs to its plan, so positions in different currencies are never added together.
  • Running, sorted by how close each is to paying, with a live countdown. One page-wide clock drives every countdown, so all of them read the same instant.
  • Settled, below, behind a Show N settled disclosure.

Two behaviours worth knowing on a ticket:

  • A position that crosses maturity in the browser is not settled by the browser. The page re-reads once at the boundary so the row stops describing itself as running, but the cron performs the settlement, on its own hourly schedule. Up to an hour between "0 remaining" and "completed" is normal.
  • The store clears when the signed-in user changes. Signing out and back in as somebody else used to show the previous account's rows on the first paint.

The position page

/investment/<investment id> reads GET /api/finance/investment/{id} with type=general, scoped to the caller. It shows the principal, a status badge, a link to the plan, and a maturity panel with Opened, Matures (or Matured), Term and Rate.

The Cancel control renders only while the position is ACTIVE, because that is the only state the server accepts. Cancelling returns the full principal and pays no ROI; the row is then soft-deleted, so the page redirects to the portfolio rather than re-reading itself into a not-found panel.

The backend answers a missing row and a row belonging to somebody else with the same 404 Investment not found, because the lookup is scoped to the caller's id. Both land on a final "this position is gone" panel. Any other failure lands on a retry panel instead. If a customer reports "my investment disappeared", check whether they are signed in as the account that opened it before you go looking at the database.