UMMAH FLOWS · 03 Verified against code · Aug 2026

Platform structure · entities, marketplaces & stores

Merchants, Marketplaces & Stores

One Merchant table, two tiers and a self-relation give Ummah its whole marketplace hierarchy — while Adyen sees only flat, first-class siblings. This page walks the entity model, the marketplace gates and their one-way lock, the three doors through which a sub-merchant is created, and the store lifecycle that turns a merchant into a paid sales channel — with every guard, queue and error code named from the code. Pricing economics appear only as pointers; the numbers live in Pricings, Splits & Fees.

Section 1

The shape of the platform

There is no Client model and no SubMerchant model. Every business on the platform — a direct merchant, a marketplace, a marketplace's sub-merchant — is a row in the same Merchant table, distinguished by tier and stitched into a tree by parentClientId. The tree is pure Ummah software: on Adyen's balance platform, every one of those rows is a fully independent legal entity with its own account holder, balance account and transfer instrument.

One tree in Ummah, flat siblings on Adyen — Model A topology

FIG 1 · topology
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  subgraph UDB["Ummah database — the tree"]
    direction TB
    C["Merchant · tier CLIENT
isMarketplace true"] S1["Merchant · tier SUBMERCHANT"] S2["Merchant · tier SUBMERCHANT"] C -->|"parentClientId"| S1 C -->|"parentClientId"| S2 end subgraph ADY["Adyen balance platform — flat siblings"] direction TB A1["Legal entity · account holder
balance account · transfer instrument"] A2["Legal entity · account holder
balance account · transfer instrument"] A3["Legal entity · account holder
balance account · transfer instrument"] end C -.->|"adyen ids"| A1 S1 -.->|"adyen ids"| A2 S2 -.->|"adyen ids"| A3 classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class C client class S1,S2 sub class A1,A2,A3 adyen
Marketplace Client Sub-merchant Adyen rails
The SubMerchantService header comment states the design outright: a sub-merchant is its own legal entity, account holder, balance account and transfer instrument, exactly like a Client. Parent–child relationships never reach Adyen — the money relationship between parent and child is expressed only through split configurations, covered in Pricings, Splits & Fees.

Why does Adyen never see the tree?

Ummah runs the Model A topology: every sub-merchant completes its own KYC through Sumsub and is provisioned as a first-class Adyen entity. The alternative — nesting subs under the parent's account holder — would pool liability and KYC on the parent. Instead the hierarchy is a software concern (parentClientId), and the parent's cut of a sub's payments is realised as a split leg into the parent's own balance account, not as ownership of the sub's funds.

model Merchant

The only actor table

Carries tier (CLIENT or SUBMERCHANT), the parentClientId self-relation, the marketplace flags, the admin-locked sub-merchant commission fields, and the four adyen* identifiers that anchor it to the balance platform.

model Store

The sales channel

Each store owns its own Adyen balance account under the merchant's existing account holder — no extra KYC — plus an Adyen Management-API store (adyenStoreId) with payment methods auto-requested. Pricing attaches here via splitProfileId.

model SplitProfile

The pricing pointer

The template that decides how each payment divides between sub-merchant, marketplace Client and Ummah. This page only points at it — mechanics, worked numbers and per-scheme behaviour live in Pricings, Splits & Fees.

prisma/schema.prisma:38-41 · prisma/schema.prisma:390-399 · prisma/schema.prisma:519-563 · src/modules/submerchant/submerchant.service.ts:44-55

Section 2

The vocabulary

Ten identifiers carry the whole model. All names below are verbatim from prisma/schema.prisma and the backend services.

Core terms of the merchant hierarchy
TermLives onWhat it means
tierMerchantMerchantTier enum: CLIENT (default) or SUBMERCHANT. There is no separate sub-merchant table — the tier is the only structural difference.
parentClientIdMerchantOptional self-relation "ClientSubmerchants" with onDelete: Restrict — a Client with sub-merchants cannot be deleted. The inverse side is subMerchants Merchant[].
isMarketplaceMerchantMarks a Client as a marketplace. Gates the /api/clients and /api/admin/clients invite doors (403 client_not_marketplace). One-way once subs exist.
canCreateSubMerchantsMerchantAdmin-granted at invite; gates the portal invite door and the portal's Sub-merchants and Splits navigation. Forced to false on every SUBMERCHANT row.
subMerchantCommissionPercentBps / subMerchantCommissionFixedMinorMerchantUmmah's admin-locked leg on the marketplace's sub-merchant payments; stamped into every split the Client creates and re-stamped on every Adyen sync. Economics in Pricings, Splits & Fees.
defaultSplitProfileIdMerchantChosen by the admin at invite; per the schema comment, merchants never choose their own split. Inherited by the default store and later stores.
adyenLegalEntityId · adyenAccountHolderId · adyenBalanceAccountId · adyenTransferInstrumentIdMerchantThe full Adyen quartet, present on every merchant regardless of tier.
adyenStoreIdStoreThe Adyen Management-API store behind the sales channel; payment methods are requested against it per the merchant's business line.
ringFencedStoreDisables cross-store recovery — losses in this store cannot be recovered from the merchant's other stores.
splitProfileIdStoreWhere pricing attaches. A store without one cannot take payments — the checkout guard blocks it.

prisma/schema.prisma:38-41 · prisma/schema.prisma:390-399 · prisma/schema.prisma:446-467 · prisma/schema.prisma:519-563

Section 3

Tiers, gates & the one-way lock

Two separate booleans govern marketplace behaviour, and they are checked by different doors. Understanding which flag gates which path explains most of the surprises in this feature.

Two flags, two doors

  • isMarketplace is checked by SubMerchantService.inviteSubMerchant — the service behind both the staff door and the Client self-service door. A Client without it receives 403 client_not_marketplace. It is set either by staff via PATCH /api/admin/clients/:clientId/marketplace, or automatically: a successful first invite through the portal door flips it to true so marketplace-gated logic (sub lists, split configuration) recognises the Client.
  • canCreateSubMerchants is checked by PortalSubMerchantsService.invite and by the portal sidebar. It is granted only on the admin invite form (the InviteMerchantDialog checkbox, which also opens the Ummah commission inputs — see Pricings, Splits & Fees).

A Client can therefore satisfy one gate but not the other — one of the documented gaps in Section 8.

The one-way lock

PATCH /api/admin/clients/:clientId/marketplace enforces two rules. Only tier === CLIENT rows may become marketplaces — anything else is refused with 400 not_a_client, which transitively blocks sub-of-sub through this path. And marketplace mode is one-way once used: disabling it while sub-merchants exist is refused with marketplace_locked (a deliberate product decision, recorded 2026-08-17).

The marketplace toggle and its two refusals

FIG 2 · gates
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart TD
  REQ["PATCH /api/admin/clients/:clientId/marketplace"]
  DIR{"enable or disable?"}
  TIER{"tier is CLIENT?"}
  NC["400 not_a_client
blocks sub-of-sub too"] ON["isMarketplace = true"] HAS{"sub-merchants exist?"} LOCK["marketplace_locked
one-way once used"] OFF["isMarketplace = false"] REQ --> DIR DIR -->|"enable"| TIER TIER -->|"no"| NC TIER -->|"yes"| ON DIR -->|"disable"| HAS HAS -->|"yes"| LOCK HAS -->|"no"| OFF classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class REQ ummah class DIR,TIER,HAS,OFF plain class NC,LOCK danger class ON client
Ummah admin action Marketplace Client Refusal Neutral step
The service map records marketplace_locked as HTTP 400, the backoffice map as 422 — the error code is consistent, the status code is worth pinning down. The tier check lives only here: every other nesting guard trusts that isMarketplace can never be set on a SUBMERCHANT row.

No nesting — three separate guards

Sub-merchants cannot have sub-merchants, but no single invariant says so. Three guards in three places combine to enforce it:

  • Portal tier check — PortalSubMerchantsService.invite returns 403 when the caller's tier === SUBMERCHANT ("Sub-merchants cannot invite further sub-merchants").
  • Flag zeroing at creation — AdminMerchantsService.createInvite forces canCreateSubMerchants: false and subMerchantCommission* = 0 on any SUBMERCHANT row it creates.
  • Toggle tier check — setMarketplaceMode only accepts CLIENT rows (400 not_a_client).

SubMerchantService.inviteSubMerchant itself never re-checks the target's tier — it relies transitively on isMarketplace only ever being settable on CLIENT rows. The invariant holds today, but it is distributed, not declared.

VerdictThe gates hold, but by convention rather than construction: two flags with different owners, a tier check that lives in one endpoint, and a nesting rule spread across three services. A single service-level invariant ("only an isMarketplace CLIENT may gain children") would make the model self-defending. See G7.

src/modules/submerchant/submerchant.service.ts:595-646 · src/modules/merchants/portal-submerchant.service.ts:43-123 · src/modules/admin/admin-merchants.service.ts:385-391 · prisma/schema.prisma:459-467

Section 4

Inviting a sub-merchant

Three routes create a SUBMERCHANT row. Two share a service; the third delegates to the admin invite machinery — and the difference decides whether the new sub arrives priced or unpriced.

4a · Three doors in

Ummah staff Marketplace Client Sub-merchant owner
The three invite endpoints
DoorRoute & guardsService & behaviour
Staff POST /api/admin/clients/:clientId/submerchants/invites
@RequireScope('STAFF') + merchant.write
SubMerchantService.inviteSubMerchant: the Client must have isMarketplace = true (403 client_not_marketplace); the Sumsub level is validated via sumsub.listLevels; a transaction creates the Merchant row (tier: SUBMERCHANT, parentClientId, status: INVITED, bcrypt verificationCodeHash, TTL expiresAt) plus an invited owner User with the MerchantOwner system role; the invite email is enqueued, audit event submerchant.invited written, and the invite link carries &clientId= for Client-branded onboarding. No defaultSplitProfileId is stamped — see G1.
Client self-service POST /api/clients/:clientId/submerchants/invites
@RequireScope('MERCHANT') + merchant.write + assertSelf
Portal POST /api/portal/sub-merchants
MERCHANT scope + merchant.write; caller must not be SUBMERCHANT tier (403) and must have canCreateSubMerchants
PortalSubMerchantsService.invite delegates to AdminMerchantsService.createInvite(dto, actor, reqId, parentClientId). The sub's split is resolved properly: dto.splitProfileId must be one of the caller's own active profiles (403 otherwise), falling back to the caller's defaultSplitProfileId "so the sub is never unsplit". The Sumsub level is resolved server-side (caller's own sumsubLevelName, else the first company-type level in the live catalogue, 422 if none). On success the caller's isMarketplace flips to true.

The assertSelf check on the Client door deserves a note: the controller compares the bearer token's merchantId to the path's :clientId and returns 403 not_your_marketplace on mismatch. All six handlers of ClientSubMerchantController call it, and sub-resources additionally re-verify ownership via getOwnedSub (matching id, parentClientId and tier: SUBMERCHANT). The doc comment records that the path parameter was previously trusted as-is — the fix is real, but it is a per-controller convention, not a shared guard (G5).

4b · Invite to ACTIVE

Whichever door created it, the sub-merchant then walks the same onboarding chain as a top-level Client: code entry, Sumsub WebSDK verification, then Adyen provisioning through the BullMQ queue. The full state machine and Sumsub detail live in Onboarding & KYC; the sequence below shows the sub-merchant-specific path end to end.

Client self-service invite, from POST to ACTIVE

FIG 3 · invite flow
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
  participant CL as Marketplace Client
  participant API as Ummah backend
  participant SUB as Sub-merchant owner
  participant SS as Sumsub
  participant Q as BullMQ worker
  participant AD as Adyen
  CL->>API: POST /api/clients/:clientId/submerchants/invites
  Note over API: assertSelf + isMarketplace gate
row created INVITED · audit submerchant.invited API-->>SUB: invite email · 6-digit code SUB->>API: POST /api/portal/onboarding/:id/open SUB->>API: POST /api/portal/onboarding/:id/verify Note over API: OPENED to VERIFIED · mints MERCHANT JWT SUB->>API: POST /api/portal/onboarding/:id/sumsub-token SUB->>SS: Sumsub WebSDK iframe · documents SS-->>API: POST /webhooks/sumsub · GREEN (HMAC-verified) API->>Q: job provisionMerchant · queue adyen-provisioning Q->>AD: legal entity · business line · ToS · account holder · balance account · transfer instrument AD-->>API: ACCOUNT_HOLDER.UPDATED · verificationStatus valid Note over API: markActive · ensureDefaultStore reuses onboarding BA
Statuses walked: INVITED, OPENED, VERIFIED, IN_PROGRESS, SUMSUB_REVIEWED, ADYEN_PROVISIONING, ACTIVE — with RESUBMISSION_REQUESTED, FAILED, EXPIRED as side exits. Before code entry, the public GET /api/portal/onboarding/:id/branding dresses the page in the parent Client's brand, read from the legacy splitConfig JSON (G2).

Resending is Client-controllable: POST /api/clients/:clientId/submerchants/:subId/resend-invite is allowed only from INVITED, RESUBMISSION_REQUESTED or EXPIRED, and revives an EXPIRED sub back to INVITED with a fresh code.

VerdictOnly the portal door guarantees a priced sub-merchant. The staff and Client doors create the row without defaultSplitProfileId, so on activation ensureDefaultStore builds a store that cannot transact and merely logs a warning. Until the doors converge on createInvite, every non-portal invite needs a manual split assignment before the sub can take a payment.

src/modules/submerchant/submerchant.controller.ts:52-285 · src/modules/submerchant/submerchant.service.ts:308-501 · src/modules/merchants/portal-submerchant.service.ts:43-123 · src/modules/onboarding/onboarding.controller.ts:46-201 · src/modules/onboarding/provisioning.service.ts:34-92 · src/core/queue/queue.constants.ts:5-44

Section 5

Stores as sales channels

A Store is where a merchant actually sells: a named channel with its own money boundary. The crucial design choice is that every store gets its own Adyen balance account, provisioned under the merchant's existing account holder — which means new channels need no additional KYC, and each channel's funds are separable.

Anatomy

  • Identity — displayName, status (StoreStatus: ACTIVE or DISABLED), isDefault.
  • Money boundary — its own adyenBalanceAccountId plus adyenTransferInstrumentId; the ringFenced flag disables cross-store recovery so this channel's losses stay its own.
  • Acquiring surface — an Adyen Management-API store (adyenStoreId) with default payment methods visa, mc, maestro, amex, applepay and googlepay auto-requested per the merchant's business line.
  • Pricing attachment — splitProfileId points at the split profile whose Adyen split configuration auto-divides every payment, with the remainder booked to this store's balance account. How the legs divide is the subject of Pricings, Splits & Fees.
  • Legacy columns — mccOverride, pricingOverride, webhookConfig, apiKeyScope are explicitly marked "Do not build new code against these".

Lifecycle

The default store is created by StoresService.ensureDefaultStore when the merchant goes ACTIVE: it reuses the balance account already provisioned during onboarding (no second account), stamps merchant.defaultSplitProfileId onto the store, runs ensureAdyenStore to create the Management-API store and request payment methods, and finally applyPendingSplitProfile pushes the split configuration onto the Adyen store. Additional stores go through StoresService.create, which provisions a fresh balance account under the same account holder.

Who chooses the split is asymmetric by design. Per the StoresService.create comment, admins may choose one; merchants never do — only AdminStoresController passes { allowSplitProfile: true }, the portal store-create route ignores dto.splitProfileId entirely, and a merchant's new store inherits the default store's split.

Store lifecycle — from creation to first payment

FIG 4 · store lifecycle
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  NEW["New store
StoresService.create"] DEF["Default store at activation
ensureDefaultStore"] BA["Own balance account provisioned
under the account holder · no extra KYC"] REU["Reuses the onboarding
balance account"] AST["ensureAdyenStore
adyenStoreId · payment methods
visa mc maestro amex applepay googlepay"] SPL{"splitProfileId?"} LIVE["Payments auto-split by the
attached configuration · remainder
to this store's balance account"] BLK["Checkout guard
blocks payments"] NEW --> BA DEF --> REU BA --> AST REU --> AST AST --> SPL SPL -->|"attached"| LIVE SPL -->|"none"| BLK classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class NEW plain class DEF ummah class BA,LIVE sub class REU plain class AST adyen class SPL plain class BLK danger
Ummah-owned action Merchant money / live Adyen rails Blocked
When a split profile is attached, PaymentsService sends no explicit splits[] at all — Adyen divides the payment from the store-attached configuration. The blocked branch is not hypothetical: sub-merchants invited through SubMerchantService and the seeded demo store both land there (G1, G8).
VerdictOne balance account per store is the right isolation primitive: channels get separable funds and optional ring-fencing without re-running KYC. The single trap is the unsplit store — the checkout guard fails safe, but nothing upstream forces a split to exist before a store is born.

prisma/schema.prisma:519-563 · src/modules/stores/stores.service.ts:31-38 · src/modules/stores/stores.service.ts:90-117 · src/modules/stores/stores.service.ts:473-544 · src/modules/stores/stores.service.ts:611-655 · src/modules/checkout/payments.service.ts:257-261

Section 6

The screens

Two Next.js 15 apps operate this model. The merchant portal gives a marketplace Client day-to-day control of its subs; the backoffice gives Ummah staff the authoritative levers. Both share one light-only design language.

Merchant portal

The sidebar shows Sub-merchants and Splits only when merchant.canCreateSubMerchants is set and the tier is not SUBMERCHANT — capability granted at invite, not mere tier. A sub-merchant visiting /sub-merchants sees "Not available".

/sub-merchants · list + invite

Sub-merchant management

The list shows owner email, a four-step public onboarding status (Invite, Business verification, Final checks, Active), an auto-approval caps summary, the refund-fee bearer ("you pay" vs "sub pays"), and actions: Resend invite, Caps, View.

InviteSubMerchantDialog collects Legal name (min 2 characters), Owner email, optional Trading name, and a pricing step: a Split picker listing only active and synced splits with the hint "you take {x}%" — blank means the account default split, since "a store with no split can't take payments". Submits to POST /api/portal/sub-merchants. The onboarding link is revealed exactly once; the 6-digit code is emailed to the invitee only.

SubMerchantCapsDialog sets Payout cap and Refund cap in pounds (0 or blank means everything needs approval) via /api/portal/payouts|refunds/submerchants/:subId/cap — the approval flows those caps feed are covered in Refunds & Approvals.

/sub-merchants/[id] · detail

One sub, in full

An onboarding progress bar in merchant wording (no Adyen or Sumsub internals), stat cards for 30-day volume, a read-only balance roll-up and caps, plus a live refund-fee bearer select ("Sub pays" vs "You pay") writing /api/clients/:clientId/submerchants/:subId/refund-fee-bearer.

A Stores & splits table shows the split applied per store — the name links into /splits, and an unpriced store reads "none — assign one from Splits". Below it, the sub's full filterable transaction list, view-only: rows do not link through.

The Splits page itself — where the Client composes its own cut on top of Ummah's locked leg — is pricing territory: see Pricings, Splits & Fees.

Backoffice

/merchants/[id] · Approvals tab

Merchant detail

The tab (labelled "Approvals", or "Approvals & pricing" behind NEXT_PUBLIC_FEATURE_PRICING) stacks four controls: MerchantMarketplaceCard — the isMarketplace checkbox, hidden for SUBMERCHANT tier and one-way locked once a sub has onboarded (the backend refuses with marketplace_locked); MerchantFeeProfileCard — assign or clear a fee profile via PUT /api/admin/merchants/:id/fee-profile, with "No fee profile (free)" as the empty state; the payout, refund and MIT cap cards; and the feature-flagged MerchantPricingTab. The Stores tab can view a store's split read-only (SplitProfileViewDialog, with "Edit split profile" deep-linking to /split-profiles?edit=<id>) and unassign a split from a store.

InviteMerchantDialog · /sub-merchants

Invite & oversight

The merchant invite dialog is where marketplace capability is born: alongside legalName, ownerEmail, a company-type sumsubLevelName and the mandatory defaultSplitProfileId ("Merchants cannot choose their own"), the canCreateSubMerchants checkbox opens the Ummah % and Ummah £ inputs sent as subMerchantCommissionPercentBps / subMerchantCommissionFixedMinor — zero is valid. What those numbers do is Pricings, Splits & Fees material.

Every sub-merchant across all marketplaces is listed under Entities at /sub-merchants.

ummah-platform-merchant-portal/components/nav/Sidebar.tsx:129-154 · ummah-platform-merchant-portal/app/(app)/sub-merchants/page.tsx:32-90 · ummah-platform-merchant-portal/components/sub-merchants/InviteSubMerchantDialog.tsx:38-167 · ummah-platform-merchant-portal/app/(app)/sub-merchants/[id]/page.tsx:32-79 · ummah-platform-backoffice/app/(app)/merchants/[id]/page.tsx:108-133 · ummah-platform-backoffice/components/merchants/InviteMerchantDialog.tsx:36-123 · ummah-platform-backoffice/components/merchants/MerchantStoresTab.tsx:159-230

Section 7

Implementation notes

Endpoints

Hierarchy & store endpoints, verbatim
PathMethod · guardPurpose
/api/admin/clients/:clientId/submerchants/invitesPOST · STAFF + merchant.writeStaff invites a sub for any marketplace Client.
/api/clients/:clientId/submerchants/invitesPOST · MERCHANT + merchant.write + assertSelfClient self-service invite; 403 not_your_marketplace on a foreign :clientId.
/api/clients/:clientId/submerchantsGET · MERCHANT + assertSelfList the Client's subs (plus detail and transactions handlers under /:subId, each re-verified via getOwnedSub).
/api/clients/:clientId/submerchants/:subId/resend-invitePOST · MERCHANT + assertSelfResend the code; only from INVITED, RESUBMISSION_REQUESTED or EXPIRED, reviving EXPIRED to INVITED.
/api/clients/:clientId/submerchants/:subId/refund-fee-bearerPATCH · MERCHANT + assertSelfClient picks who funds its cut on a sub's refunds: SUBMERCHANT or CLIENT. Hand-off to Refunds & Approvals.
/api/portal/sub-merchantsPOST · MERCHANT + merchant.writePortal invite door; delegates to createInvite, flips the caller's isMarketplace.
/api/admin/clients/:clientId/marketplacePATCH · STAFFMarketplace toggle: 400 not_a_client off-tier, marketplace_locked once subs exist.
/api/admin/clients/:clientId/splitPATCH · STAFFLegacy splitConfig JSON write (branding + legacy fee shape) — superseded but still live (G2).
/api/portal/onboarding/:id/brandingGET · publicParent Client's brand for sub-merchant onboarding, read from the parent's splitConfig.
/api/portal/onboarding/:id/open · /verify · /sumsub-tokenPOST · code / minted JWTThe onboarding chain; detail in Onboarding & KYC.
/webhooks/sumsubPOST · HMAC-verifiedReview result; GREEN drives SUMSUB_REVIEWED and enqueues provisionMerchant on adyen-provisioning.
/api/admin/merchants/:id/fee-profilePUT · STAFFAssign or clear the merchant's non-payment fee profile.

Adyen counterparts per row

Where each Ummah row anchors to Adyen
Ummah modelAdyen columns
MerchantadyenLegalEntityId · adyenAccountHolderId · adyenBalanceAccountId · adyenTransferInstrumentId
StoreadyenStoreId · adyenBalanceAccountId · adyenTransferInstrumentId
BankAccountadyenTransferInstrumentId
UboadyenLegalEntityId
SplitProfileadyenSplitConfigurationId + syncedAt

Who may do what — roles in brief

Authorisation is JwtAuthGuard plus RolesGuard reading @RequireScope('STAFF'|'MERCHANT') and @RequirePermissions(...). A user's JWT scope is derived — any STAFF-scope role makes the token STAFF, otherwise MERCHANT — and system roles (StaffAdmin, MerchantOwner) store no permissions, resolving to allPermissions(scope) at token time from the registry. Fine-grained names (sub-merchants.view, stores.create, team.delete) are flattened through a bridge marked TEMPORARY onto the coarse decorator names (merchant.read, merchant.write) that the hierarchy controllers actually check — and the bridge has holes (G4). @CurrentMerchant() resolves the acting merchant from the API key or the JWT and 403s staff tokens, keeping staff and merchant surfaces cleanly apart.

src/modules/submerchant/submerchant.controller.ts:52-285 · src/modules/auth/permissions.registry.ts:37-179 · src/modules/auth/roles.guard.ts:11-50 · src/modules/auth/auth.service.ts:72-158 · src/common/decorators/current-merchant.decorator.ts · prisma/schema.prisma:342-345 · prisma/schema.prisma:483-563 · prisma/schema.prisma:621-623

Section 8

Gaps & recommendations

Everything below was verified in code. Priorities: p0 blocks money or correctness, p1 is commercial or operational friction, p2 is polish.

P0 · G1

Two invite doors create unpriced sub-merchants

SubMerchantService.inviteSubMerchant — behind both /api/admin/clients/:clientId/submerchants/invites and /api/clients/:clientId/submerchants/invites — creates the SUBMERCHANT row without defaultSplitProfileId. On activation ensureDefaultStore builds a store with splitProfileId null and only logs a warning; the sub cannot take payments until someone assigns a split by hand. Fix: resolve or require a split in inviteSubMerchant exactly as the portal path does via AdminMerchantsService.createInvite (inherit the parent's default when omitted).

P1 · G2

Legacy splitConfig JSON is still live

The schema marks splitConfig, pricingModel and pricingByScheme as SUPERSEDED by SplitProfile/FeeSchedule, yet PATCH /api/admin/clients/:clientId/split still writes it, AdyenSplitService.buildSplits still reads it on the explicit-splits path, and the public branding endpoint reads brand fields from it. An admin editing the legacy endpoint may believe they changed live pricing when the store-attached SplitProfile governs real payments. Fix: move branding to first-class columns, then freeze the legacy write path behind a deprecation error.

P1 · G3

Two parallel invite systems with different gates

The Client door demands isMarketplace (admin toggle); the portal door demands canCreateSubMerchants (admin invite flag) and auto-flips isMarketplace. A Client can satisfy one but not the other, and the two doors produce differently-configured subs — split inheritance and invite-link shape included. Fix: converge both doors on AdminMerchantsService.createInvite and check both flags in one place.

P1 · G4

Permission legacy-bridge strands merchant custom roles

The hierarchy controllers gate on coarse merchant.read/merchant.write, reachable in MERCHANT scope only through incidental legacy mappings — merchant.write only via subscriptions.edit, while sub-merchants.create and sub-merchants.edit have no bridge entries at all. A custom role granted exactly the sub-merchant permissions cannot invite subs or set splits. Fix: add bridge entries for the sub-merchants.* family, or move the controllers onto fine-grained permission names.

P1 · G5

assertSelf is a convention, not a guard

The historical IDOR on :clientId is fixed — all six ClientSubMerchantController handlers call assertSelf, and sub-resources re-verify via getOwnedSub — but the fix is per-controller discipline. Any new :clientId route must remember to re-implement it. Fix: extract a shared guard or param decorator that binds :clientId to the bearer's merchantId by default.

P2 · G6

listSubMerchants ignores isMarketplace

Both the admin controller and SubMerchantService.listSubMerchants fetch the flag but never check it — listing a non-marketplace Client silently returns an empty list rather than a 4xx. Relatedly, configureSplit writes split/branding config to any Client row without requiring the flag. Fix: assert marketplace status (or return an explicit error) in both.

P2 · G7

Nesting prevention is three guards in three places

The portal tier check, createInvite's flag zeroing and setMarketplaceMode's not_a_client each carry part of the "no sub-of-sub" rule, while inviteSubMerchant itself never re-checks the target's tier. Fix: one service-level invariant asserting the parent is an isMarketplace CLIENT at every creation point.

P2 · G8

Seeded demo store cannot transact

prisma/seed.ts creates the demo merchant's store without a split profile or Adyen ids, so it trips the same checkout guard as G1 — consistent with the legacy-merchant warning path, but an easy local-dev stumble. Fix: seed a synced split profile and stamp it on the seed store.