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
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.
| Term | Lives on | What it means |
|---|---|---|
tier | Merchant | MerchantTier enum: CLIENT (default) or SUBMERCHANT. There is no separate sub-merchant table — the tier is the only structural difference. |
parentClientId | Merchant | Optional self-relation "ClientSubmerchants" with onDelete: Restrict — a Client with sub-merchants cannot be deleted. The inverse side is subMerchants Merchant[]. |
isMarketplace | Merchant | Marks a Client as a marketplace. Gates the /api/clients and /api/admin/clients invite doors (403 client_not_marketplace). One-way once subs exist. |
canCreateSubMerchants | Merchant | Admin-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 / subMerchantCommissionFixedMinor | Merchant | Ummah'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. |
defaultSplitProfileId | Merchant | Chosen 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 · adyenTransferInstrumentId | Merchant | The full Adyen quartet, present on every merchant regardless of tier. |
adyenStoreId | Store | The Adyen Management-API store behind the sales channel; payment methods are requested against it per the merchant's business line. |
ringFenced | Store | Disables cross-store recovery — losses in this store cannot be recovered from the merchant's other stores. |
splitProfileId | Store | Where 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
isMarketplaceis checked bySubMerchantService.inviteSubMerchant— the service behind both the staff door and the Client self-service door. A Client without it receives 403client_not_marketplace. It is set either by staff viaPATCH /api/admin/clients/:clientId/marketplace, or automatically: a successful first invite through the portal door flips it totrueso marketplace-gated logic (sub lists, split configuration) recognises the Client.canCreateSubMerchantsis checked byPortalSubMerchantsService.inviteand by the portal sidebar. It is granted only on the admin invite form (theInviteMerchantDialogcheckbox, 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
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.invitereturns 403 when the caller'stier === SUBMERCHANT("Sub-merchants cannot invite further sub-merchants"). - Flag zeroing at creation —
AdminMerchantsService.createInviteforcescanCreateSubMerchants: falseandsubMerchantCommission* = 0on anySUBMERCHANTrow it creates. - Toggle tier check —
setMarketplaceModeonly acceptsCLIENTrows (400not_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.
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
| Door | Route & guards | Service & 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-merchantsMERCHANT 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
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.
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:ACTIVEorDISABLED),isDefault. - Money boundary — its own
adyenBalanceAccountIdplusadyenTransferInstrumentId; theringFencedflag 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 —
splitProfileIdpoints 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,apiKeyScopeare 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
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).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
| Path | Method · guard | Purpose |
|---|---|---|
/api/admin/clients/:clientId/submerchants/invites | POST · STAFF + merchant.write | Staff invites a sub for any marketplace Client. |
/api/clients/:clientId/submerchants/invites | POST · MERCHANT + merchant.write + assertSelf | Client self-service invite; 403 not_your_marketplace on a foreign :clientId. |
/api/clients/:clientId/submerchants | GET · MERCHANT + assertSelf | List the Client's subs (plus detail and transactions handlers under /:subId, each re-verified via getOwnedSub). |
/api/clients/:clientId/submerchants/:subId/resend-invite | POST · MERCHANT + assertSelf | Resend the code; only from INVITED, RESUBMISSION_REQUESTED or EXPIRED, reviving EXPIRED to INVITED. |
/api/clients/:clientId/submerchants/:subId/refund-fee-bearer | PATCH · MERCHANT + assertSelf | Client picks who funds its cut on a sub's refunds: SUBMERCHANT or CLIENT. Hand-off to Refunds & Approvals. |
/api/portal/sub-merchants | POST · MERCHANT + merchant.write | Portal invite door; delegates to createInvite, flips the caller's isMarketplace. |
/api/admin/clients/:clientId/marketplace | PATCH · STAFF | Marketplace toggle: 400 not_a_client off-tier, marketplace_locked once subs exist. |
/api/admin/clients/:clientId/split | PATCH · STAFF | Legacy splitConfig JSON write (branding + legacy fee shape) — superseded but still live (G2). |
/api/portal/onboarding/:id/branding | GET · public | Parent Client's brand for sub-merchant onboarding, read from the parent's splitConfig. |
/api/portal/onboarding/:id/open · /verify · /sumsub-token | POST · code / minted JWT | The onboarding chain; detail in Onboarding & KYC. |
/webhooks/sumsub | POST · HMAC-verified | Review result; GREEN drives SUMSUB_REVIEWED and enqueues provisionMerchant on adyen-provisioning. |
/api/admin/merchants/:id/fee-profile | PUT · STAFF | Assign or clear the merchant's non-payment fee profile. |
Adyen counterparts per row
| Ummah model | Adyen columns |
|---|---|
Merchant | adyenLegalEntityId · adyenAccountHolderId · adyenBalanceAccountId · adyenTransferInstrumentId |
Store | adyenStoreId · adyenBalanceAccountId · adyenTransferInstrumentId |
BankAccount | adyenTransferInstrumentId |
Ubo | adyenLegalEntityId |
SplitProfile | adyenSplitConfigurationId + 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.
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).
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.
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.
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.
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.
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.
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.
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.