SPEC-152-Q4: Client-Facing Suitability Report Viewer
Capability: SUITABILITY-ENGINE · Status: draft Repo: suitability-engine
Audience: WTP engineers building/reviewing this; Origo’s own front-end team, who will build their own equivalent view against the JSON contract below (we do not ship this UI into their product — see Deployment model).
1. Why this exists
Section titled “1. Why this exists”Origo needs their end-client to view and acknowledge a suitability report,
matching the UX pattern already proven live in client-onboarding’s own
client-facing report (apps/client-onboarding/src/app/report/) — a
progressive-disclosure, section-by-section review that gates the
acceptance action on having actually viewed every required section, with
every transition captured as an audit event (the same pattern the real
client-portal repo formalises for FCA acknowledgement). This spec
documents that reference UX precisely enough for Origo to reproduce it (or
diverge deliberately), and ships a working reference implementation
proving the JSON contract is sufficient to drive it.
2. Reference UX (as built in client-onboarding, source of truth)
Section titled “2. Reference UX (as built in client-onboarding, source of truth)”2.1 Shell
Section titled “2.1 Shell”-
Fixed left sidebar (dark navy,
wtp-navy-deepin the source brand palette — a placeholder in the reference implementation, see Branding), full viewport height, collapsible on mobile behind a hamburger toggle. -
Report title + client name at the top of the sidebar (“Suitability Report” / “Jane Doe”).
-
Nine section links, each with an icon, in a fixed order:
Order Section id Label 1 overviewOverview 2 about-youAbout You 3 objectivesYour Objectives 4 riskAttitude to Risk 5 recommendationsMy Recommendations 6 suitabilityWhy This is Suitable 7 costsCosts & Charges 8 risksKey Risks 9 downloadsDownloads -
Each link shows a checkmark once viewed (teal accent), and the currently active section gets a left border + lighter background — pure client-side state, driven by navigation, not a separate “mark as read” button.
-
“Proceed to Acceptance” button, fixed at the bottom of the sidebar, disabled until every section in a required subset has been viewed (not necessarily all nine — client-onboarding’s
REQUIRED_SECTIONS_FOR_ACCEPTANCEis a subset; Origo should decide their own required set, but the pattern — some sections may be “must-read”, others optional context — is worth keeping).
2.2 Section-view tracking = the audit trail
Section titled “2.2 Section-view tracking = the audit trail”This is the part that matters most and is easy to under-build: viewing a
section is itself the event being audited, not a side effect of a “mark
read” click. In the reference implementation, navigating to a section
fires a “viewed” event exactly once per section per session (idempotent —
revisiting doesn’t re-fire). client-onboarding persists this server-side
per client (reportSectionsViewed on the client record, patched via the
API) so the gate on “Proceed to Acceptance” survives a page reload — this
is not just client-side UI state, it’s the FCA-relevant record of what the
client actually looked at before accepting.
For Origo: this means your own back end needs an equivalent durable “sections viewed” store per client engagement, and your acceptance/proceed action needs to check it server-side, not just trust client-side state — otherwise the audit trail is forgeable by anyone who can call your “accept” endpoint directly.
2.3 Section content pattern
Section titled “2.3 Section content pattern”Each section is its own scrollable panel in the main content area.
Representative example (suitability, the “Why This is Suitable” section):
- A heading card explaining the recommended management approach
(
recommendation.dfmManageretc.) with a 3-up feature-benefit grid. - A performance-comparison visual (current vs. recommended platform).
- A list of collapsible reason cards — each with a heading and an expandable explanation (accordion pattern, one open/closed state per card, independent of the others).
Other sections (About You, Objectives, Risk, Recommendations, Costs, Risks, Downloads) follow the same visual language — card-based panels, consistent spacing, no page-level scroll competing with section-level scroll.
2.4 Data mapping — SuitabilityData → the nine sections
Section titled “2.4 Data mapping — SuitabilityData → the nine sections”The reference implementation renders directly off GET /v1/tenants/:tenantId/clients/:clientToken/suitability (or the format=json
export route)‘s response, i.e. { data: SuitabilityData, audit } per
suitability-template/contract.ts. Mapping:
| Section | Source fields |
|---|---|
overview | client.fullName, report.generatedAt, firm.name, adviser.* |
about-you | client.* (address, DOB, risk profile, investment experience) |
objectives | client.objectives.timeHorizonYears |
risk | client.riskScore, client.riskRating, client.capacityForLoss |
recommendations | recommendedPlatform.*, recommendation.* |
suitability | suitabilityReasons[] (one card each, when supplied), else recommendation.summaryStatement; recommendation.dfmManager |
costs | charges.*, currentPlatform.{amc,platformFee,totalCharges} |
risks | (integrator-authored — no contract field; see below) |
downloads | report.enclosures |
suitabilityReasons — integrator-authored pass-through
(suitability-engine#16, decided 2026-09-30: Option A). client-onboarding’s
real suitability page renders a list of narrative reason cards
(suitabilityReasons: [{id, heading, explanation}]) sourced from its own
client record. SuitabilityData (contract.ts) now carries the same
field as an optional pass-through:
-
The integrator (Origo included) supplies
suitabilityReasonson thePartialSuitabilityDatainput (REST export body, GraphQLPartialSuitabilityDataInput/ClientCreateInput, gRPCsuitability_reasons_json,client.create), from its own client record. -
derive()copies it to the derivedSuitabilityDataunchanged. The engine does not author, rewrite, reorder or synthesise reasons — no rule insuitability-rules/reads or writes it — and when none are supplied the field is simply absent (never an engine-invented default). -
Every request surface validates it in full (
id,heading,explanationall non-empty strings): malformed reasons are a 400 /BAD_USER_INPUT/ field-levelclient.createerror, because nothing downstream would otherwise stop a malformed card reaching a client. -
Readiness (
field-source-map.ts) classifies itManual— integrator/adviser-authored content, likerecommendation.summaryStatement. -
Limits: at most 50 reasons;
idup to 128,headingup to 500,explanationup to 10,000 characters; no keys beyond{id, heading, explanation}. -
Rendering: reasons are a list, so they render through JSON consumers or a Liquid (config-mount) template’s
{% for reason in suitabilityReasons %}loop. A DOCX template’s scalar{{...}}tags cannot render a list (the same constraint asreport.enclosures) — do not place{{suitabilityReasons}}in a DOCX template. Heading/explanation are caller-supplied free text: a Liquid template emitting HTML must| escapethem.
The reference implementation renders one card per supplied reason and
falls back to a single card built from recommendation.summaryStatement
when the integrator supplied none.
risks (Key Risks) remains integrator-authored, outside the contract.
It is the same “the integrator writes the narrative” model, but it is
not covered by this pass-through: there is no SuitabilityData field
for it (client-onboarding’s version isn’t traced to a shared contract
either), so the reference implementation leaves it as an Origo-authored
static section. Adding a parallel optional pass-through for it would be a
separate, deliberate contract change, not implied by #16.
2.5 Branding is NOT part of the contract
Section titled “2.5 Branding is NOT part of the contract”The colour palette, typography, and logo in client-onboarding’s real report are WTP/Benchmark brand tokens — not part of what Origo integrates against. The JSON contract carries data, never presentation. The reference implementation uses a neutral placeholder palette (documented in its own README) specifically so it reads as “swap this,” not “this is WTP’s brand, keep it.”
3. Integration flow — how Origo actually calls this
Section titled “3. Integration flow — how Origo actually calls this”- Origo’s back end calls
POST /v1/tenants/{tenantId}/clients/{clientToken}/suitability/export?format=json(bearer:SUITABILITY_ENGINE_SERVER_TOKEN, or the Origo-facing API-key-with-scopes model once that ships — seedocs/external/origo-suitability-and-adviser-api-design.md §5.1inclient-onboarding) to get{ data: SuitabilityData, audit }. - Origo’s front end renders it using their own equivalent of the shell described above (or the reference implementation as a starting point).
- Section-view events are recorded by Origo, in Origo’s own store — this engine has no concept of “has this client viewed this section”, and is not the right place to add one (it’s a stateless derive-and-render pipeline, not a session/audit-trail service).
- Origo’s own “accept” action is Origo’s own audit event, entirely outside this engine’s scope.
- If the branded document (not just the web view) is needed — e.g. for
download/email — that’s the
format=docxpath (suitability-engine#6, now implemented) against Origo’s own registered template.
4. Deployment model — what WTP ships vs. what Origo builds
Section titled “4. Deployment model — what WTP ships vs. what Origo builds”Per the scope decision made with the user 2026-08-16: WTP ships this
spec + a reference implementation (examples/client-report-viewer/) that
Origo can literally copy, fork, or just read for the interaction pattern.
WTP does not operate, host, or maintain a production version of this
UI for Origo — Origo’s own client-facing product is Origo’s to build and
brand. The reference implementation is explicitly not embedded into
client-onboarding or any other WTP product’s UX.
5. Reference implementation
Section titled “5. Reference implementation”See examples/client-report-viewer/ — a standalone Vite + React app (no
Next.js/WTP-app-shell coupling, so it’s genuinely portable). Run
npm run dev inside that directory; it renders against a bundled sample
SuitabilityData payload (examples/client-report-viewer/src/sample-data.ts)
matching the shape a real API response would carry, so no live backend
call is required to see it work. See that directory’s own README for
architecture notes and exactly what is/isn’t implemented.
