UMMAH FLOWS · 13 Verified against code · Aug 2026

Staff console · permissions & operations

Back-office Operations & RBAC

The staff console and the permission system beneath it. How a sign-in becomes a JWT carrying a scope and a flattened permission set, how RolesGuard arbitrates every admin and portal endpoint, and what each back-office screen actually operates — queues, approval consoles, platform earnings, reconciliation, settings — with the exact endpoints, guard names, and the one temporary permission bridge that quietly decides who can do what. Every claim below was verified in the platform code.

Section 1

What it is

Ummah runs one staff console — ummah-platform-backoffice, a Next.js app over the backend's api/admin/* surface — and one permission system that serves both that console and the merchant portal. There is no separate admin auth stack: the same NestJS auth module mints every JWT, and a single Role table split by scope decides whether a token is staff or merchant.

The mechanism is deliberately simple: roles are rows, permissions are strings, and everything is resolved once, at token time. When a user signs in (POST api/auth/login, with TOTP MFA where enrolled), the backend loads their UserRole links, expands each role — system roles expand to the full registry for their scope, custom roles to their stored list — flattens the result, and stamps it into the JWT. Guards never touch the database again: RolesGuard compares the token's scope and permission set against each handler's @RequireScope(...) and @RequirePermissions(...) metadata.

From sign-in to a guarded handler

FIG 1 · rbac resolution
%%{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
  U["Sign-in
POST api/auth/login + TOTP"]:::client R["Load UserRole → Role rows"]:::plain S{"Role.isSystem?"}:::plain ALLP["allPermissions for the scope
from the permission registry"]:::ummah EXP["Role.permissions as stored
custom fine-grained names"]:::plain FLAT["flattenRolePermissions
+ TEMPORARY bridge fine → coarse"]:::warn TOK["JWT minted
scope STAFF or MERCHANT + permissions"]:::ummah G["JwtAuthGuard → RolesGuard
@RequireScope + @RequirePermissions"]:::ummah OKN["Handler runs"]:::sub NO["403 Forbidden"]:::danger U --> R --> S S -->|"system role"| ALLP --> FLAT S -->|"custom role"| EXP --> FLAT FLAT --> TOK --> G G -->|"scope matches and ALL listed permissions held"| OKN G -->|"any check fails"| NO 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;
User action Ummah auth machinery The temporary bridge — see Section 3 Allowed Denied
Because expansion happens at token time, a role edit only takes effect on the next login or refresh — and the amber bridge step is where fine-grained role names silently become the coarse names the guards actually check.

User — UserRole — Role

Roles are rows, not code

User (optional merchantId) links through UserRole to Role { scope, permissions[], merchantId?, isSystem }, with RoleScope = STAFF | MERCHANT. Two system roles are seeded: StaffAdmin (STAFF) and MerchantOwner (MERCHANT). isSystem rows store no permissions — they resolve to allPermissions(scope) at token time, so new registry entries reach admins and owners automatically.

<menu>.<action>

The permission registry

Every permission is a registry entry named <menu>.<action> with action ∈ view | create | edit | delete. STAFF menus include split-profiles (VCED), merchants (VCE) and staff (VCED); MERCHANT menus include sub-merchants (VCE), splits (VE), stores (VCE) and team (VCED). The registry is the single source both for role editors and for system-role expansion.

RolesGuard

The guard stack

JwtAuthGuard authenticates; RolesGuard authorises against @RequireScope('STAFF'|'MERCHANT') and @RequirePermissions(...) — the user must hold all listed permissions, e.g. merchant.read, merchant.write. @CurrentMerchant() resolves req.merchantId (API-key surface) or req.user.merchantId (JWT) and 403s staff tokens, so staff can never accidentally act as a merchant.

ummah-platform-backend/prisma/schema.prisma:227-273 · ummah-platform-backend/prisma/seed.ts:29-59 · ummah-platform-backend/src/modules/auth/permissions.registry.ts:37-179 · ummah-platform-backend/src/modules/auth/roles.guard.ts:11-50 · ummah-platform-backend/src/modules/auth/auth.service.ts:72-158

Section 2

The vocabulary

Terms this page relies on
TermLives inWhat it means
Roleprisma/schema.prisma:246Named permission bundle: scope RoleScope, permissions String[], optional merchantId (merchant-owned custom roles), isSystem.
RoleScopeprisma/schema.prisma:50STAFF | MERCHANT. Determines which console a role belongs to and which registry half it can draw from.
isSystemRole modelSeeded, immutable roles (StaffAdmin, MerchantOwner) that store no permission list and expand to allPermissions(scope) when the JWT is minted. Edit or delete attempts return 422.
Permission registryauth/permissions.registry.tsThe catalogue of every <menu>.<action> permission per scope, plus the helper functions (allPermissions, flattenRolePermissions) used at token time.
Legacy bridgepermissions.registry.ts:120-161A mapping, marked TEMPORARY in code, from fine-grained registry names to the coarse names decorators still check — e.g. merchants.create → merchant.write. Incomplete; see Section 3.
@RequireScoperoles.guard.tsHandler decorator naming the token scope a route demands. Wrong scope = 403 before any permission check.
@RequirePermissionsroles.guard.tsHandler decorator listing permissions the token must hold — all of them, not any.
@CurrentMerchant()common/decorators/current-merchant.decorator.tsParameter decorator resolving the acting merchant id from either auth path; returns 403 for staff tokens so admin JWTs cannot reach merchant-bound handlers.
PlatformSettingprisma/schema.prisma:1139Platform-wide key/value config editable at api/admin/settings — including the api.disabled_endpoints kill-switch.
AuditLogprisma/schema.prisma:816Append-only trail of privileged actions, browsable platform-wide and per merchant. RequestLog (schema:847) sits beside it for raw request telemetry.
TOTP MFAauth.controller.ts:49-246Authenticator-app second factor: POST api/auth/mfa/setup, POST api/auth/mfa/enable, DELETE api/auth/mfa/disable. Shared by staff and merchant users.

Section 3

Permission resolution, step by step

User Auth module RolesGuard

Everything the guards will ever know about a user is decided in auth.service.ts when the token is minted. Three rules govern it.

  • Scope is derived, not stored on the user. If any of the user's roles has scope: STAFF, the JWT scope is STAFF; otherwise it is MERCHANT. A user holding a mix of staff and merchant roles therefore becomes a staff token — and @CurrentMerchant() will then 403 them off merchant-bound routes, so mixed assignments are effectively staff assignments.
  • System roles are registry mirrors. StaffAdmin and MerchantOwner resolve to allPermissions(scope) at token time. That is why the seed stores them empty: adding a menu to the registry instantly widens every system-role holder without a migration.
  • Custom roles are literal. A merchant- or staff-built role contributes exactly its stored permissions[] strings, then flattenRolePermissions merges all roles and applies the legacy bridge before the set is stamped into the token.

The TEMPORARY legacy bridge — and where it leaks

The registry speaks fine-grained names (sub-merchants.create, merchants.view), but most controllers still guard on an older coarse pair: merchant.read / merchant.write. The bridge in permissions.registry.ts:120-161 translates fine to coarse at token time. It is explicitly marked temporary, and it is incomplete in both directions:

Bridge behaviour for the permissions that matter most
Fine-grained role grantBridged coarse namePractical effect
merchants.create STAFFmerchant.writeStaff role can invite and mutate merchants — as intended.
sub-merchants.view MERCHANTmerchant.readMerchant role can list its sub-merchants — as intended.
subscriptions.edit MERCHANTmerchant.writeOver-grant. The only merchant-scope route to merchant.write — which also gates sub-merchant invites and split-profile writes. Granting subscription editing silently grants marketplace administration.
sub-merchants.create / sub-merchants.edit MERCHANT— no bridge entry —Dead grant. The named permission exists in the registry and role editor, but maps to nothing the guards check. The holder sees the screens and 403s on the actions.

Concretely: a marketplace Client builds a custom "Partnerships" role and ticks sub-merchants.create and sub-merchants.edit but not subscriptions.edit. Members of that role open the sub-merchants screen (bridged merchant.read via sub-merchants.view) but every POST /api/clients/:clientId/submerchants/invites and every split write returns 403, because those handlers demand merchant.write — reachable only through the subscriptions permission. The inverse failure is worse: a bookkeeping role granted subscriptions.edit for dunning tweaks can invite sub-merchants and rewrite the Client's split assignments.

Verdict · dangerThe bridge makes merchant custom roles lie. Named grants do nothing, and an unrelated grant confers marketplace-admin power. System-role users (MerchantOwner, StaffAdmin) are unaffected — they hold everything — which is exactly why this has survived: the default cast never trips it. Fix tracked as the P0 in Section 11.

ummah-platform-backend/src/modules/auth/auth.service.ts:72-158 · ummah-platform-backend/src/modules/auth/permissions.registry.ts:37-179 (bridge 120-161) · ummah-platform-backend/src/modules/auth/roles.guard.ts:11-50 · ummah-platform-backend/src/modules/submerchant/submerchant.controller.ts:144-285

Section 4

Staff & merchant teams

Ummah staff Merchant admin api/admin/staff · api/portal/team

The same role machinery is administered from two consoles: staff manage staff, merchants manage their own teams — and neither can touch the two seeded system roles.

  • Staff management lives at api/admin/staff (guarded by the staff.view/create/edit/delete permissions): create staff users, assign STAFF-scope roles, deactivate. Role CRUD itself is api/admin/roles. New staff receive set-password onboarding links; the back-office sign-in flow under app/(auth) shares the platform JWT module rather than shipping its own.
  • Merchant team management lives at api/portal/team/members (invite, role-assign) and api/portal/team/roles (custom MERCHANT-scope roles), guarded by the team.* permissions. Custom roles are scoped to the merchant via Role.merchantId.
  • System roles are immutable. Any attempt to edit or delete StaffAdmin or MerchantOwner returns 422 — their meaning is owned by the registry, not by any editor.
  • MFA is TOTP on both sides: POST api/auth/mfa/setup issues the secret, POST api/auth/mfa/enable confirms the first code, DELETE api/auth/mfa/disable removes it. Merchant owners enrol during onboarding first login; the portal ships dedicated mfa / mfa-setup pages.
Verdict · warnThe people-management surfaces are clean and symmetrical. The caveat is downstream: any custom role built in either editor inherits the Section 3 bridge semantics, so role design must currently be tested against what the guards check, not what the checkboxes say.

ummah-platform-backend/src/modules/staff/staff.controller.ts:36-80 · ummah-platform-backend/src/modules/roles/roles.controllers.ts:39-169 · ummah-platform-backend/src/modules/team/team.controller.ts:37-90 · ummah-platform-backend/src/modules/auth/auth.controller.ts:49-246 · ummah-platform-backoffice/app/(auth)

Section 5

The console map

The back-office nav is a faithful map of the ops feature set — every screen is a thin client over an api/admin/* module. One group name is verbatim from the sidebar ("Pricing & money", which also gates /fee-schedules behind NEXT_PUBLIC_FEATURE_PRICING); the rest are grouped here by function.

Back-office information architecture

FIG 2 · console map
%%{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
  BO["Back-office console
STAFF JWT · api/admin/*"]:::ummah ENT["Entities
dashboard · merchants/:id · sub-merchants
onboarding-queue · kyc-queue · customers"]:::plain PAY["Payment ops
transactions/:id · refunds · disputes/:id
subscriptions/:id · payouts · payout-approval · transfers"]:::plain MON["Pricing & money
split-profiles · fee-profiles · calculator
platform-earnings · fx · fee-schedules behind flag"]:::liable VIS["Integrity & visibility
reconciliation · reports · events
audit · notifications"]:::plain PLAT["Platform
staff · settings: defaults · rate-limits
sumsub-levels · profile"]:::plain BO --> ENT BO --> PAY BO --> MON BO --> VIS BO --> PLAT 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;
Console shell Ummah's own money — liable-account views Screen groups
Two entries in the money group are not what they appear: fx is a frontend placeholder with no backend module behind it, and fee-schedules is retired-in-place behind a feature flag — both are itemised in Section 11.

Most payment-ops screens are admin twins of merchant surfaces — admin-payments, admin-refunds, admin-subscriptions, admin-captures, admin-balances, admin-customers — giving staff the cross-merchant view of the same data each merchant sees for itself. Where flows are documented elsewhere in this series, this page does not repeat them: transactions on Checkout & Payments, disputes on Disputes & Recovery, and the whole pricing console family — split-profiles, fee-profiles (with its fee-charges deduction ledger and per-row Retry), calculator — on Pricings, Splits & Fees.

merchants/:id — the lifecycle cockpit

The merchant detail screen is where staff operate a single merchant end to end. Every control is a small, explicit endpoint on admin-merchants.controller.ts:

Lifecycle controls on the merchant detail screen
ControlEndpointWhat it does
Invite / resendPOST api/admin/merchants/invites · POST :merchantId/resend-inviteCreates the invited merchant (split profile mandatory at invite) or re-sends the onboarding code. Full flow on Onboarding & KYC.
Suspend / statusPOST :id/suspend · PATCH :id/statusDrives MerchantStatus. SUSPENDED blocks the merchant's whole /v1 surface (403 merchant_suspended); INACTIVE allows GET only (403 merchant_inactive on writes) — enforced in ApiKeyGuard.
Sumsub toolsPOST :merchantId/sumsub/simulate · GET :merchantId/sumsub/eventsSandbox-only KYC outcome simulation, plus the full per-merchant Sumsub webhook history.
Adyen toolsPOST :merchantId/adyen/retry-provisioning · POST :merchantId/adyen/sign-pciRe-runs LEM provisioning after a 4xx FAILED; signs the PCI questionnaire. Read-side: GET :id/adyen/verification, GET :merchantId/balance-accounts, GET :id/onboarding-payloads.
Notes & auditnotes endpoints · GET api/admin/merchants/:id/auditFree-text ops notes on the record, plus the merchant-filtered slice of the audit log.
Money config:merchantId/api-keys · /banks · /stores · /sweeps · /reserve · /statements · /balances · /fee-profilePer-merchant twins of the money surfaces — sweeps and reserves are staff-managed here, merchant-side read-only.

Onboarding & KYC queues

Two queue screens keep stuck merchants visible: onboarding-queue reads GET api/admin/merchants/onboarding-queue (everyone between invite and ACTIVE, with their current state-machine status), and kyc-queue reads the review queue at api/admin/onboarding/review-queue (merchants awaiting or failing Sumsub review, where the simulate and resend tools earn their keep). The state machine itself — INVITED → OPENED → VERIFIED → IN_PROGRESS → SUMSUB_REVIEWED → ADYEN_PROVISIONING → ACTIVE — is documented on Onboarding & KYC.

ummah-platform-backoffice/app/(app)/ · ummah-platform-backoffice/components/nav/Sidebar.tsx:75-92 · ummah-platform-backend/src/modules/admin/admin-merchants.controller.ts:34-237 · ummah-platform-backend/src/modules/api-keys/api-key.guard.ts:39-84

Section 6

Approval consoles

Two money movements pause for a human: payouts and refunds. Both follow the same shape — a request lands in a queue with a configurable auto-approval cap, ops approve or reject, a worker executes. The caps live in two places: platform defaults (Settings → Defaults) and, for sub-merchants, per-sub caps set by the parent Client (0 or blank = everything needs approval).

FLOW · PAYOUT APPROVE

Payout approval, end to end

Merchant Ops payout worker Adyen

A merchant requests a payout from the portal (POST api/portal/payouts) or API (POST v1/payouts). Above the auto-approval cap it waits in the payout-approval screen; ops decide with POST api/admin/payouts/:id/approve or /reject. Execution belongs to the worker, never the console.

Payout approve → execute → settle

FIG 3 · approval queue
%%{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 M as Merchant portal
  participant B as Backend
  participant O as Ops console
  participant W as Payout worker
  participant A as Adyen
  M->>B: POST api/portal/payouts
  B-->>O: queue row + payout.requested email
  Note over B,O: requests within the auto-approval cap skip straight to execution
  alt approved
    O->>B: POST api/admin/payouts/:id/approve
    B->>W: enqueue payout job
    W->>W: Redis lock payout:ba:{id}:lock NX EX 60
    W->>A: bank transfer sized from live balance
    A-->>B: balancePlatform.transfer.created / updated
    B-->>M: payout.sent / payout.settled webhook + email
  else rejected
    O->>B: POST api/admin/payouts/:id/reject
    B-->>M: request rejected
  end
The worker is a single writer per balance account — the Redis lock (compare-and-delete Lua release) plus live-balance sizing means an approval can never double-spend or overdraw a store's balance, even if the queue replays.

Status advances only on Adyen's balancePlatform.transfer.created/updated webhook legs — the console shows what Adyen confirmed, not what was attempted. Failures emit payout.failed to both email and merchant webhooks. Balance mechanics, sweeps and statements live on Balances & Payouts.

Verdict · okA textbook approval queue: human decision, idempotent machine execution, webhook-confirmed state. The one thing to watch is cap hygiene — the platform default and the Client's per-sub cap are configured on different screens by different people.

ummah-platform-backend/src/modules/payouts/admin-payouts.controller.ts:48-60 · ummah-platform-worker/src/workers/payout.processor.ts:1-13 · ummah-platform-backend/prisma/schema.prisma:1477 (AdyenTransferLeg)

FLOW · REFUND QUEUE

Refund approval

Refunds requested via POST /v1/refunds, the portal, or the admin surface can pause at RefundStatus PENDING_OPS — emitting the refund.pending_ops merchant webhook and the refund.pending_approval ops email — until staff action in the back-office refunds queue releases them to Adyen. Terminal state then follows the REFUND / REFUND_FAILED / REFUNDED_REVERSED webhooks. Who bears the fee is configuration, not code path: RefundFeeBearer (merchant vs platform) and ClientRefundFeeBearer (sub vs parent Client), with the Client setting per-sub refund caps at PUT api/portal/refunds/submerchants/:subId/cap.

The full refund lifecycle, fee-bearer economics and failure modes are on Refunds & Approvals — this console is its staff-side gate.

ummah-platform-backend/src/modules/refunds/refunds.controller.ts:17-37 · ummah-platform-backend/prisma/schema.prisma:185 (RefundStatus), 197-206 (fee-bearer enums)

Section 7

Money oversight

Three screens watch the platform's own money and the integrity of everyone else's.

Platform earnings — the liable-account view

platform-earnings reads GET api/admin/platform-earnings (add ?refresh=1 or use the "Refresh from Adyen" button to bypass cache): per-currency balance cards for Ummah's liable balance account plus its movement list, filterable by category — platformPayment ("Commission legs", the split commission booked at authorisation), internal (fee-charge transfers), and bank. Direction and status filters are applied server-side per page because Adyen accepts but ignores both query parameters (verified 2026-08-11). The screen is strictly read-only — no pricing inputs live here.

Verdict · warnTreat the headline earnings figure as indicative, not bookable: the pricing capability audit flagged an earnings overcount in how commission is tallied. The mechanics, worked numbers and the fix live on Pricings, Splits & Fees — reconcile against the liable-account movements, not the headline.

Transfers

The transfers screen is the raw movement ledger — the platform-wide view over Adyen transfer activity recorded from balancePlatform.transfer.created/updated webhooks (AdyenTransferLeg rows), sitting alongside the payout screens for tracing where a specific leg went.

Reconciliation panel

The reconciliation screen reads GET api/admin/reconciliation/summary, /mismatches, /awaiting, /runs and /runs/latest, and can trigger a manual sweep with POST api/admin/reconciliation/run — the same queue as the nightly 02:00 UTC cron ('0 2 * * *'). Each run checks four invariants: splits mismatched (Adyen booked ≠ recorded), split legs stale beyond 24 h, payments stuck 3 days, recoveries stuck 24 h — and records a ReconciliationRun with ReconStatus; an Adyen outage yields DEGRADED plus the reconciliation.alert ops email. The checks themselves are documented on Reconciliation & Integrity.

ummah-platform-backend/src/modules/balances/admin-platform-earnings.controller.ts · ummah-platform-backoffice/app/(app)/platform-earnings/page.tsx:14-45 · ummah-platform-backend/src/modules/reconciliation/reconciliation.controller.ts:21-58 · ummah-platform-worker/src/workers/reconciliation.processor.ts:1-16

Section 8

Visibility surfaces

Four screens answer "what happened?" at different altitudes: reports aggregate, events show the wire, audit shows the humans, notifications show the emails.

reports

A data-driven report registry

Every report is a row in report-registry.ts — title, columns, query, scope — thirteen of them, executed by a single runner that serialises CSV, JSON or PDF. Adding a report is adding a registry entry, not a controller. Scoping is the interesting part: a registry row with merchantId set is merchant-scoped (visible in the portal, the /v1 API and the admin's per-merchant view); merchantId: null marks it platform-wide — back-office only, e.g. the platform P&L. Each report filters on its natural timestamp range.

events

The wire, both directions

api/admin/events backs a three-tab browser: inbound (persisted AdyenWebhookEvent / SumsubWebhookEvent rows, exactly as the ingress service stored them), outbound (merchant webhook deliveries) and endpoints (merchant endpoint health). It is the first stop for "did Adyen tell us?" — the delivery pipeline itself is on Events & Notifications.

audit

Immutable audit trail

api/admin/audit browses the append-only AuditLog — who acted, what they touched, with before/after detail per entry — and every merchant detail screen carries its own filtered slice at GET api/admin/merchants/:id/audit. Privileged mutations write rows as they happen (e.g. submerchant.invited); RequestLog keeps the raw request telemetry beside it.

notifications

The email ledger

The notifications screen (backed by admin-notifications.controller.ts) browses Notification rows — the fifteen template types from onboarding.succeeded to reconciliation.alert, each row being both the send job and its audit record, with SENT/FAILED status after the SQS-driven consumer runs. Pipeline details on Events & Notifications.

ummah-platform-backend/src/modules/reports/report-registry.ts · ummah-platform-backend/src/modules/reports/reports.controllers.ts · ummah-platform-backend/src/modules/events/events.controller.ts:24-160 · ummah-platform-backend/src/modules/audit-log/audit-log.controller.ts · ummah-platform-backend/src/core/notifications/admin-notifications.controller.ts · ummah-platform-backend/prisma/schema.prisma:816, 847, 2168

Section 9

Platform settings

Settings is four screens over two backend modules — and it holds the platform's biggest lever: a key/value store that can switch off API endpoints without a deploy.

  • Defaults (settings/defaults) is a mostly read-only view of platform configuration: pricingModel, refund and payout auto-approval caps, the MIT cap, webhook timeouts, rate limits and feature-flag pills. Exactly one field is editable in place: paymentLinkExpiryDays (1–365).
  • Rate limits (settings/rate-limits) configures the platform rate limiter via api/admin/rate-limits, backed by the guard in common/guards — the throttle-side companion to the kill-switch below.
  • Sumsub levels (settings/sumsub-levels) manages which Sumsub verification levels invites may use — the same catalogue the invite dialogs validate against.
  • PlatformSetting KV (api/admin/settings) is the generic store behind it all. Its sharpest key is api.disabled_endpoints: any /v1 endpoint can be switched off without a deploy, and ApiExposureGuard honours the change live within 30 seconds. GET api/admin/system/info rounds out the ops picture with platform build/runtime info.
Verdict · okConfig-as-data with an audited admin surface is the right shape, and the endpoint kill-switch is a genuinely good incident lever. The friction is that most defaults are display-only — changing them is a deploy-side operation rather than a console one.

ummah-platform-backend/src/modules/settings/admin-settings.controller.ts:28-35 · ummah-platform-backend/src/modules/settings/admin-rate-limits.controller.ts · ummah-platform-backoffice/app/(app)/settings/defaults/page.tsx:19-86 · ummah-platform-backend/prisma/schema.prisma:1139 (PlatformSetting) · ummah-platform-backend/src/modules/api-catalog/api-exposure.guard.ts

Section 10

Implementation notes

The endpoints this page leans on, with their guards. All api/admin/* routes sit behind JwtAuthGuard + RolesGuard with @RequireScope('STAFF'); portal routes demand MERCHANT scope. Named permissions are shown where the code declares them.

Endpoint reference
EndpointGuard / permissionPurpose
POST api/auth/login · refresh · me · change-passwordpublic / JWTSession lifecycle shared by both consoles; magic-link, forgot- and reset-password beside them.
POST api/auth/mfa/setup · POST mfa/enable · DELETE mfa/disableJWTTOTP enrolment and removal.
api/admin/staffSTAFF · staff.view/create/edit/deleteStaff user CRUD and role assignment.
api/admin/rolesSTAFFSTAFF-scope role CRUD; system roles return 422 on mutation.
api/portal/team/members · api/portal/team/rolesMERCHANT · team.*Merchant team invites and merchant-scoped custom roles.
POST api/admin/merchants/invites · POST :merchantId/resend-inviteSTAFF · merchant.writeMerchant invitation lifecycle.
POST :id/suspend · PATCH :id/statusSTAFF · merchant.writeLifecycle control; drives the /v1 gate in ApiKeyGuard.
POST :merchantId/sumsub/simulate · GET :merchantId/sumsub/eventsSTAFFSandbox KYC simulation; Sumsub event history.
POST :merchantId/adyen/retry-provisioning · POST :merchantId/adyen/sign-pciSTAFFProvisioning retry after FAILED; PCI questionnaire signing.
GET api/admin/merchants/onboarding-queue · api/admin/onboarding/review-queueSTAFFThe two queue screens' read models.
POST api/admin/payouts/:id/approve · /rejectSTAFFPayout approval console actions; execution in the payout worker.
api/admin/refunds surfaceSTAFFRefund queue twin releasing PENDING_OPS refunds.
GET api/admin/platform-earningsSTAFFLiable-account balances and movements; ?refresh=1 bypasses cache.
api/admin/reconciliation/summary·mismatches·awaiting·runs·runs/latest · POST /runSTAFFReconciliation panel reads and manual sweep trigger.
reports.controllers.ts (portal · admin · v1)scope per surfaceRegistry-driven report runner, CSV/JSON/PDF.
api/admin/eventsSTAFFInbound / outbound / endpoints event browser.
api/admin/audit · GET api/admin/merchants/:id/auditSTAFFPlatform-wide and per-merchant audit browsing.
api/admin/settings · api/admin/rate-limits · GET api/admin/system/infoSTAFFPlatformSetting KV (incl. api.disabled_endpoints), rate-limit config, system info.

Data-model notes: the whole permission system is three tables (User, UserRole, Role) plus a code-side registry — there is no Permission table, so permission renames are code changes and the registry is the migration. Role.merchantId scopes merchant custom roles; Role.isSystem plus the 422 guard keeps the two seeded roles stable for the allPermissions expansion to lean on.

Section 11

Gaps & recommendations

What the code verifies as missing or misleading, ranked by how much it can hurt.

P0

Retire the legacy permission bridge

sub-merchants.create / sub-merchants.edit bridge to nothing, while subscriptions.edit is the only merchant-scope route to merchant.write — so custom roles both under-deliver named grants and over-grant marketplace administration through an unrelated checkbox. Fix: move controllers to fine-grained @RequirePermissions checks (e.g. 'sub-merchants.create' on the invite handlers), then delete the bridge from permissions.registry.ts:120-161.

P1

Make :clientId self-binding a shared guard, not a convention

ClientSubMerchantController.assertSelf fixed the historical trusted-param hole, but it is re-implemented per handler — a new :clientId route that forgets the call reopens the IDOR. Fix: a shared guard/decorator that binds path merchant params to the bearer's merchantId for all MERCHANT-scope routes.

P1

Label the retired fee-schedule machinery

/fee-schedules and the PricingEditor survive behind NEXT_PUBLIC_FEATURE_PRICING (backend 404s without FEATURE_PRICING) and still offer events the live split mechanism never charges — PAYOUT, SUBSCRIPTION, MIN_MONTHLY, FX_MARKUP. An auditor or new operator can mistake it for live pricing. Fix: an explicit "retired — superseded by split profiles" banner in the screen and nav, per the generational story on Pricings, Splits & Fees.

P2

Hide or badge the fx placeholder

The back-office ships an fx screen in the "Pricing & money" nav group, but no FX module exists anywhere in the backend module list — the screen has nothing real to operate. Fix: put it behind a feature flag or a Coming-soon badge until a backend module exists, so the nav stays a truthful feature map.