UMMAH FLOWS · 04 Verified against code · Aug 2026

Acceptance stack · sessions, SDK & lifecycle

Checkout & Payments

How money enters Ummah: the server-side checkout session that freezes pricing before a card is ever seen, the pk_ + cs_ browser surface that can choose a payment method but never an amount, and the webhook-driven lifecycle that walks every transaction from INITIATED to SETTLED. The page covers the full acceptance stack — hosted pages, holds and captures, saved-card MIT charges — with an engineering verdict on what stands and what bites. Every claim below was verified in the platform code.

Section 1

What it is

Checkout & Payments is the platform's acceptance stack: five repos cooperating so that a merchant's server, a shopper's browser and Adyen each see exactly what they are allowed to see.

The design principle is a single sentence: every money decision is made on the server before the browser is involved. The merchant's server, holding an sk_ secret key, creates a checkout session; that call resolves and freezes the split plan for all eight payment methods into CheckoutSession.splitsSnapshot and returns a single-use cs_ token plus the merchant's pk_ publishable key. The browser — whether the embeddable web SDK or a hosted page — presents that token pair to the /sdk/checkout surface, where the backend injects the amount and the split plan itself. The browser chooses a payment method; it can never choose a price.

After authorisation the story becomes asynchronous: Adyen's webhooks land at the dedicated ummah-platform-webhook ingress (HMAC-verified, persisted, deduplicated), ride the BullMQ queue webhook-in.adyen, and the worker walks the Transaction through its lifecycle — settlement is inferred from Adyen's booked split legs, not from any settlement webhook.

The acceptance stack in one view

FIG 1 · hero
%%{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
  MS["Merchant server
sk_ key"] -->|"POST /v1/checkout/sessions"| SES["CheckoutSession
splitsSnapshot · 8 FeeMethods"] SES -->|"single-use cs_ + pk_"| BR["Shopper browser
Adyen Web SDK"] BR -->|"POST /sdk/checkout/payments"| PAY["PaymentsService
amount + splits injected"] PAY -->|"POST /payments"| AD["Adyen
authorise + auto-split"] AD -.->|"webhooks · queue webhook-in.adyen"| WK["Worker
lifecycle + reconciliation"] WK -->|"booked legs sum complete"| FIN["Transaction SETTLED"] 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 MS sub class SES,PAY,WK ummah class BR plain class AD adyen class FIN sub
Ummah-owned step Adyen merchant / success neutral
The dotted hop is the trust boundary: everything after Adyen is asynchronous and at-least-once, which is why the worker — not the API response — is the authority on a transaction's final state.

CheckoutSession

The frozen intent

One row per payment attempt. Carries the single-use cs_ token, the purpose (payment or PM_UPDATE tokenisation), the customer link, and splitsSnapshot — a per-method split plan computed before any card is seen. Payment links, subscription links and MIT charges all mint one.

Transaction

The lifecycle record

Upserted with status INITIATED before the Adyen call, then driven entirely by webhooks. Carries feeMethod, the splitsApplied mirror, capturedMinor/refundedMinor, and a merchantReference of the form <merchantSlug>-<ref> so webhooks can resolve it.

StoredPaymentMethod

The saved card

Tokenised during checkout when the session has a customer and savePaymentMethod; the token lands via Adyen's RECURRING_CONTRACT webhook (and from additionalData on a successful authorisation). It powers POST /v1/charges MIT payments and subscription billing.

ummah-platform-backend/src/modules/checkout/checkout.controller.ts:24-38 · checkout.service.ts:91-99 · sdk.controller.ts:17-62 · prisma/schema.prisma:1052,1149,1637

Section 2

The vocabulary

Twelve terms carry this whole page. Economics terms (rates, legs, bearers) live on Pricings, Splits & Fees.

TermWhat it isWhere it lives
sk_ keyServer-to-server secret key; Authorization: Bearer on the /v1 surface via ApiKeyGuard. Merchant status gates the whole surface: SUSPENDED blocks everything, INACTIVE allows reads only.api-key.guard.ts:39-84
pk_ keyPublishable key identifying the merchant to the browser. Useless alone — it must be paired with a live session token, and it is rejected outright on /v1.publishable-session.guard.ts
cs_ tokenSingle-use checkout-session token returned by session create; sent as X-Ummah-Session beside X-Ummah-Publishable-Key.sdk.controller.ts:17-62
splitsSnapshotFrozen per-method split plans plus totalChargeMinor, computed at session create for all 8 fee methods — the browser never influences it.checkout.service.ts:525-561, schema.prisma:1080
FeeMethodThe 8 pricing buckets: VISA, MASTERCARD, AMEX, DEBIT, CREDIT, APPLE_PAY, GOOGLE_PAY, OTHER.pricing.constants.ts:36-45
brandToFeeMethodMaps the SDK's BIN-lookup hint (paymentMethod.brand/type + fundingSource) to a FeeMethod: amex→AMEX, visa→VISA, mc/mastercard→MASTERCARD, wallets, debit/credit, else OTHER.checkout.constants.ts:27-41
splitsAppliedThe locally recorded split mirror written on Transaction before the Adyen call, re-written at split-aware capture, reconciled later against Adyen's booked legs.payments.service.ts:199-232
captureModeMANUAL turns the authorisation into a hold (additionalData.manualCapture 'true'); the default captures immediately. Set per session or by an "authorise only" payment link.payments.service.ts:266-271
PublishableSessionGuardAuthenticates the pk_ + cs_ pair on every /sdk/checkout route; the origin-enforcement interceptor additionally checks the browser Origin against the merchant's registered domains.publishable-session.guard.ts, origin-enforcement.interceptor.ts
MIT / ContAuthMerchant-initiated transaction on a stored card — POST /v1/charges — capped by Merchant.mitAmountCapMinor (0 = uncapped).checkout.controller.ts:40-57
PM_UPDATESession purpose for zero-value card tokenisation: amount 0, no splits, no Transaction row. Used by card-update and subscription links.checkout.service.ts:200-268
RECURRING_CONTRACTThe Adyen classic webhook that lands a tokenised card as a StoredPaymentMethod against the Customer.adyen-webhook.service.ts:161

Section 3

The payment flow

One session, two authenticated surfaces, one asynchronous truth. This is the canonical card payment from POST /v1/checkout/sessions to SETTLED.

Merchant server · sk_ Shopper browser · Adyen Web SDK CheckoutService / PaymentsService Adyen Checkout API Worker · webhook-in.adyen

3a · Creating the session (server side)

POST /v1/checkout/sessions sits behind ApiKeyGuard and @Idempotent() (optional Idempotency-Key header, replayed for 24 hours, 422 if the same key arrives with a different body). CheckoutService.createSession then:

  • Gates the merchant — gate.assertOperational — and resolves the payee: a Client passing onBehalfOfMerchantId creates the session for one of its sub-merchants.
  • Refuses unsplittable stores — assertStoreIsSplit requires splitProfileId, adyenStoreId and adyenBalanceAccountId, erroring with code store_not_split. An unconfigured store would silently book 100% of the funds to Ummah's liable balance account.
  • Provisions balance accounts lazily — ensureBalanceAccount for the payee store, the Client store, and any tip beneficiary.
  • Freezes pricing — buildSnapshot loops the 8 FeeMethods, calling FeeResolverService.resolvePaymentFee then AdyenSplitService.buildResolvedSplits per method, and stores the result as splitsSnapshot.
  • Returns the single-use cs_ token and the pk_ publishable key for the browser.

3b · Paying in the browser

The browser surface is four routes on /sdk/checkout, all guarded by PublishableSessionGuard and the domain-allowlist origin interceptor. GET client-key hands the Adyen Web client key and environment; POST paymentMethods proxies Adyen's method list for the session's store; the Adyen Web component mounts the card form and its BIN lookup reports the brand as the shopper types. On POST payments, PaymentsService.payments derives feeMethod = brandToFeeMethod(...) from that pre-authorisation hint, picks snapshot.byMethod[feeMethod] ?? byMethod['OTHER'], upserts the Transaction (INITIATED, splitsApplied recorded) and only then calls Adyen — amount from snapshot.totalChargeMinor, store set to adyenStoreId, idempotency key pay-<sessionId>. 3DS2 runs natively in the component where possible; challenge and redirect flows finish through POST payments/details, which feeds the same applyPaymentResult.

The embeddable SDK (ummah-platform-web-sdk) wraps all of this as a React <UmmahCheckout> and a framework-agnostic mountUmmahCheckout(el, opts) — card plus Apple Pay and Google Pay, brand detection for per-method pricing, native 3DS2 in-component with a full-page redirect helper, and an environment switch of 'test'|'live' choosing the API host. The bundled Adyen Web library is invisible to integrators.

Full payment sequence — session to settlement

FIG 2 · sequence
%%{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 server
  participant B as Shopper browser
  participant U as Ummah backend
  participant A as Adyen
  participant W as Worker
  M->>U: POST /v1/checkout/sessions
  Note over U: assertStoreIsSplit · buildSnapshot
8 FeeMethod plans frozen into splitsSnapshot U-->>M: single-use cs_ token + pk_ key M-->>B: page mounts SDK with pk_ + cs_ B->>U: POST /sdk/checkout/paymentMethods U->>A: /paymentMethods for the session store A-->>U: available methods U-->>B: card form mounts · BIN lookup hints brand B->>U: POST /sdk/checkout/payments with brand hint Note over U: feeMethod = brandToFeeMethod(hint)
Transaction INITIATED · splitsApplied recorded U->>A: /payments — amount injected from snapshot
splits sent only for profile-less stores alt 3DS2 challenge or redirect A-->>U: action U-->>B: SDK handles action B->>U: POST /sdk/checkout/payments/details U->>A: /payments/details end A-->>U: resultCode Authorised U-->>B: success · Transaction AUTHORIZED A--)W: AUTHORISATION + balancePlatform.transfer webhooks Note over W: HMAC-verified at ingress, deduped,
queued on webhook-in.adyen W->>W: reconcileSplits — booked legs vs mirror → SETTLED
The synchronous response is a courtesy; the webhook path is the authority. A crashed browser after step 11 changes nothing — the transaction still settles from Adyen's events.
Verdict · soundAmount and splits are injected server-side from a frozen snapshot, sessions are single-use, origins are allowlisted, and the Adyen call is idempotent per session. The one soft spot is that the recorded fee method comes from the browser's brand hint — see Section 4.

ummah-platform-backend/src/modules/checkout/checkout.controller.ts:24-41 · checkout.service.ts:91-99,489-561 · sdk.controller.ts:17-62 · payments.service.ts:129-134,199-263,303-319 · common/interceptors/idempotency.interceptor.ts:20-60 · ummah-platform-web-sdk/src/index.ts · src/mount.ts

Section 4

Where splits attach

This page tracks where the split plan touches the payment flow; the economics — rates, legs, bearers, worked numbers — belong to Pricings, Splits & Fees.

Splits attach at four moments:

  • Session create — the freeze. buildSnapshot resolves a full split plan per FeeMethod and freezes all eight into splitsSnapshot, so a rate change after session create cannot alter an in-flight payment.
  • Pay time — the selection. PaymentsService.payments picks the plan for the BIN-lookup brand hint. For a store carrying a SplitProfile the request sends no splits[] — Adyen auto-splits from the store's own split configuration — and the platform records a locally recomputed mirror (buildProfileSplits, banker's rounding, chosen by splitGroupFor(feeMethod): VISA_MC / AMEX / REMAINING). Only profile-less stores send the frozen explicit legs.
  • Capture time — the recompute. On a manual-capture hold, CapturesService re-records (and for explicit-splits payments re-sends) splits for the captured amount — Section 6.
  • Settlement — the audit. The worker's TransferEventService.reconcileSplits compares Adyen's booked platformPayment transfer legs against splitsApplied; a complete matching set promotes the transaction to SETTLED, a divergence stamps splitsMismatch and alarms the back office.
Verdict · watchsplitsApplied is a pre-authorisation prediction. The actual scheme returned in additionalData after authorisation is stored but the mirror is never recomputed from it — a wrong or missing brand hint records the wrong rate group while Adyen books the real one, and only the reconciliation alarm catches it.

ummah-platform-backend/src/modules/checkout/payments.service.ts:184-232,255-263,404-461,672-676 · core/adyen/adyen-split.service.ts:259-350,498-619 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:246-470

Section 5

Transaction lifecycle

Ten statuses, all webhook-driven after the initial upsert. There is no settlement webhook: SETTLED is inferred when Adyen's booked split legs sum exactly to the captured or authorised amount.

Transaction state machine

FIG 3 · states
%%{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
  I["INITIATED"] -->|"AUTHORISATION ok"| AU["AUTHORIZED"]
  I -->|"AUTHORISATION failed"| FA["FAILED"]
  AU -->|"CAPTURE webhook"| CA["CAPTURED"]
  AU -->|"manual capture submitted"| CP["CAPTURE_PENDING"]
  CP -->|"CAPTURE ok"| CA
  CP -->|"CAPTURE failed"| EX["EXPIRED"]
  AU -->|"CANCELLATION"| CN["CANCELLED"]
  CA -->|"legs sum complete"| SE["SETTLED"]
  AU -->|"legs complete first"| SE
  SE -->|"CHARGEBACK"| DI["DISPUTED"]
  DI -->|"won or reversed"| SE
  SE -->|"refundedMinor reaches total"| RF["REFUNDED"]
  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 I,CN,RF plain
  class AU,CP,EX warn
  class CA,SE sub
  class FA,DI danger
pending / hold money landed failed / at risk neutral / terminal
An immediate-capture payment can reach SETTLED straight from AUTHORIZED when the transfer legs land before the CAPTURE webhook — the CAPTURE handler then refuses to downgrade. A failed capture on CAPTURE_PENDING can also fall back to AUTHORIZED where the hold is still live.
Who sets each status
StatusSet byNotes
INITIATEDPaymentsService.payments upsert, before the Adyen callSession moves to IN_PROGRESS; splitsApplied already recorded.
AUTHORIZEDapplyPaymentResult on resultCode Authorised; confirmed by the AUTHORISATION webhookStores actual scheme/fundingSource from additionalData; token capture for saved cards.
FAILEDFailed AUTHORISATION webhookAlso triggers the DECLINE fee charge (decline-<txnId>-<psp>).
CAPTURE_PENDINGCapturesService.capture before submitting to AdyenSubmit failure restores AUTHORIZED.
CAPTUREDCAPTURE webhook successSets capturedMinor/capturedAt; never downgrades a row already SETTLED.
EXPIREDFailed capture on a CAPTURE_PENDING holdOr back to AUTHORIZED, depending on the failure.
CANCELLEDCANCELLATION webhookBooks hold releases.
SETTLEDWorker reconcileSplits — complete booked-leg setAlso accrues rolling reserve. FX-converted settlements promote with amounts explicitly unverified.
DISPUTEDCHARGEBACK family webhooksWon / reversed outcomes restore SETTLED.
REFUNDEDREFUND webhooks once refundedMinor covers the totalThe refund path itself is on Refunds & Approvals.
Verdict · soundDeriving settlement from booked legs makes the ledger self-auditing — the same data that settles a payment proves its split. The FX blind spot (settled unverified, reserve accrual skipped) is the one acknowledged exception.

ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:143-201,210-348 · transfer-event.service.ts:246-470,313-366 · ummah-platform-backend/src/modules/checkout/payments.service.ts:404-461

Section 6

Holds & capture

A session (or an "authorise only" payment link) with captureMode MANUAL pins additionalData.manualCapture 'true' on the Adyen call: the authorisation becomes a hold, displayed with authorisedUntil of roughly seven days — a display value, not an enforced expiry.

Merchant portal / Back office CapturesService Adyen sk_ caller · the bypass

The split-aware path is POST /api/portal/payments/:id/capture or its admin twin, both landing in CapturesService.capture. It validates hard: loadHold rejects anything whose captureMode is not MANUAL, the row must be AUTHORIZED, the amount must not exceed the authorisation, and there is one capture per authorisation. Then splitsForCapture branches:

  • Profile store — re-record buildProfileSplits(capturedAmount) using the transaction's scheme group, and send nothing: Adyen's store split configuration recomputes the split itself at capture.
  • Explicit-splits payment — scaleSplitsProportionally produces pro-rata legs which are recorded and sent as splits[] on POST /payments/{psp}/captures, because a capture whose amount differs from the authorisation, sent without splits, books everything to the liable account.

Worked shape: a £100.00 hold captured at £60.00 re-records the mirror for £60.00 (with splitsReconciledAt nulled and splitsMismatch cleared), transitions to CAPTURE_PENDING under idempotency key capture-<txnId>, and lets the CAPTURE webhook decide the truth. A submit failure restores AUTHORIZED. Releases go through CapturesService.release, confirmed by the CANCELLATION webhook.

Capture-hold flow — the split-aware path and the bypass

FIG 4 · capture
%%{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
  H["AUTHORIZED hold
additionalData.manualCapture true"] --> SAFE["Portal / admin capture
CapturesService.capture"] H --> BY["sk_ POST /v1/payments/:id/capture"] SAFE --> Q{"store has a
SplitProfile?"} Q -->|"yes"| MIR["re-record buildProfileSplits
send no splits"] Q -->|"no"| SCL["scaleSplitsProportionally
record and send splits"] MIR --> CP2["CAPTURE_PENDING
idempotency capture-txnId"] SCL --> CP2 CP2 --> AD2["Adyen POST captures"] BY --> RAW["raw adyen.checkout.capture
no guard · no splits · no mirror"] AD2 -.->|"CAPTURE webhook"| OK2["CAPTURED → SETTLED"] RAW -.->|"CAPTURE webhook"| OK2 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 H warn class SAFE ummah class Q,MIR,SCL plain class CP2 warn class AD2 adyen class BY,RAW danger class OK2 sub
Ummah-owned step pending / hold bypass — unguarded Adyen success
Both paths converge on the same CAPTURE webhook — which is exactly why the bypass is dangerous: downstream processing cannot tell a well-formed capture from one that booked the whole amount to the liable account.

6a · The sk_ bypass

POST /v1/payments/:id/capture (and its sibling POST /v1/payments/:id/cancel) on the merchant API surface still route to PaymentsService.capture, which submits adyen.checkout.capture(pspReference, ...) directly with reference cap-<txn.id> — no MANUAL/AUTHORIZED guard, no splits sent, no splitsApplied re-record, no CAPTURE_PENDING transition, and no Adyen idempotency key. A partial capture through this route on an explicit-splits (profile-less) payment makes Adyen book the entire captured amount to the liable account, while the recorded mirror stays at the authorisation amount.

Verdict · live gapThe split-aware capture engine exists and is correct — but the public API doesn't use it. Until /v1 capture and cancel are routed through CapturesService, every API-integrated merchant with manual capture is one partial capture away from mis-booked funds and a guaranteed splitsMismatch alarm.

ummah-platform-backend/src/modules/captures/captures.service.ts:60-146,216-297 · portal-captures.controller.ts:20-33 · admin-captures.controller.ts:21-35 · checkout.controller.ts:90-106 · payments.service.ts:608-650

Section 7

Saved cards & merchant-initiated charges

Cards are tokenised inside the normal checkout; charging them later is a first-class server-side flow with its own cap — and its own recorded-rate gap.

Tokenisation during checkout

A session carrying customerId and savePaymentMethod adds shopperReference and storePaymentMethod to the Adyen call. The token lands twice over: a successful AUTHORISATION carries additionalData['recurring.recurringDetailReference'], and the RECURRING_CONTRACT webhook upserts the StoredPaymentMethod against the Customer. Merchants browse chargeable cards through the checkout service's saved-card lookup and the /v1/customers surface.

MIT charges — POST /v1/charges

Behind sk_ auth and @Idempotent(), CheckoutService.mitCharge reuses the entire session machinery — a real CheckoutSession with a frozen splitsSnapshot — then pays as ContAuth with paymentMethod {type:'scheme', storedPaymentMethodId}. Merchant.mitAmountCapMinor caps the amount (0 = uncapped). Subscription billing drives the same path: the worker's subscription-tick queue calls the backend's signed internal endpoint (X-Internal-Signature, idempotent per billing period), which lands in mitCharge.

The gap: that payment method object has no brand, so brandToFeeMethod('scheme') resolves to OTHER and the mirror records REMAINING-group rates regardless of the stored card's real scheme — while Adyen books the real scheme's rule. On every per-scheme-priced profile store, MIT charges rely on the splitsMismatch alarm rather than a correct mirror.

Verdict · watchMoney is booked correctly by Adyen; the local record is systematically wrong for scheme-priced stores. StoredPaymentMethod already knows the card's brand — pass it into brandToFeeMethod, or recompute the mirror post-authorisation.

7a · PM_UPDATE zero-value sessions

Card refresh without a charge uses a session of purpose PM_UPDATE: the snapshot is stored empty (byMethod: {}) and PaymentsService.pmUpdatePayments sends amount 0 with storePaymentMethod and recurringProcessingModel 'Subscription'. A store is still required — store-less payments 403 on platform accounts — but there are deliberately no splits and no Transaction row: nothing to account for, nothing to reconcile. Card-update links (/u/[token]) and subscription links both ride this.

ummah-platform-backend/src/modules/checkout/checkout.controller.ts:40-57 · checkout.service.ts:200-268,278-365 · payments.service.ts:272-291,331-385 · ummah-platform-worker/src/workers/subscription-tick.processor.ts:86-130 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:161

Section 8

Hosted checkout pages

The Next.js app ummah-platform-checkout serves three token-addressed experiences, all funnelling into the same session machinery as the SDK.

/l/[token]

Payment link

Resolves GET checkout/links/:token for link details and merchant branding, then POST checkout/links/:token/session mints a normal session via CheckoutService.createSession with the link's amount, store, line items and capture mode, tagged metadata {paymentLinkId}. "Authorise only" links set captureMode MANUAL; the link flips to PAID lazily once a session with that metadata holds an authorised transaction. /l/[token]/return closes redirect flows.

/sub/[token]

Subscription link

SubscribeFlow walks GET checkout/subscribe/:token → POST :token/validate-coupon → POST :token/session (initial payment plus card tokenisation) → POST :token/complete, which creates the subscription. Recurring billing then runs through the MIT path in Section 7.

/u/[token]

Card update

UpdateCard drives GET checkout/pm-update/:token → POST :token/session (a PM_UPDATE zero-value authorisation) → POST :token/complete. Minted from the API by the pm-update controller; used for expiring-card upkeep on subscriptions.

All three tokens are stored only as environment-bound peppered hashes — the raw token is returned once at creation and is irrecoverable, so "re-copy the URL" is really rotation: POST :id/regenerate mints a fresh token and the old URL stops working. Link redemption requires status ACTIVE. The shared HostedCheckout component wraps the same checkout-controller core the web SDK uses, so splits, 3DS2 and lifecycle behave identically to Section 3.

Verdict · soundOne session engine behind every surface means the hosted pages inherit the server-injected-amount guarantee for free. Token-as-hash with rotation is the right shape for links that live in WhatsApp messages and email footers.

ummah-platform-checkout/app/l/[token]/LinkCheckout.tsx · app/sub/[token]/SubscribeFlow.tsx · app/u/[token]/UpdateCard.tsx · ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133,229-289 · public-payment-links.controller.ts:21-28 · pm-update-public.controller.ts:21-39 · subscription-links/public-subscribe.controller.ts:24-51

Section 9

Implementation notes

Every endpoint in the acceptance stack, by surface, with its guard. The /v1 surface additionally sits behind ApiExposureGuard — anything absent from the exposure catalog 404s.

Endpoints of the acceptance stack
EndpointGuard / authPurpose
POST /v1/checkout/sessionsApiKeyGuard (sk_) + @IdempotentCreate session, freeze splitsSnapshot, return cs_ + pk_.
POST /v1/chargesApiKeyGuard (sk_) + @IdempotentMIT charge on a stored card (ContAuth, mitAmountCapMinor).
GET /v1/payments · GET /v1/payments/:idApiKeyGuard (sk_)List and inspect transactions.
POST /v1/payments/:id/captureApiKeyGuard (sk_)Raw Adyen capture — bypasses CapturesService (see 6a).
POST /v1/payments/:id/cancelApiKeyGuard (sk_)Raw cancel — bypasses CapturesService.release.
GET /sdk/checkout/client-keyPublishableSessionGuard + origin allowlistAdyen Web client key and environment.
POST /sdk/checkout/paymentMethodsPublishableSessionGuard + origin allowlistPayment methods for the session's store.
POST /sdk/checkout/paymentsPublishableSessionGuard + origin allowlistPay — amount and splits injected server-side; Transaction upserted INITIATED.
POST /sdk/checkout/payments/detailsPublishableSessionGuard + origin allowlist3DS2 / redirect completion via applyPaymentResult.
GET checkout/links/:token · POST .../sessionpublic, token-addressedPayment-link resolution and session mint.
GET checkout/subscribe/:token · .../validate-coupon · .../session · .../completepublic, token-addressedHosted subscribe flow.
GET checkout/pm-update/:token · .../session · .../completepublic, token-addressedZero-value card refresh (PM_UPDATE).
POST /api/portal/payments/:id/captureportal JWT + RBACSplit-aware capture via CapturesService (release action alongside).
POST /api/admin/payments/:id/capturestaff JWT + RBACBack-office twin of the portal capture.
POST /internal/subscriptions/:id/tickX-Internal-Signature HMACWorker-triggered subscription MIT charge, idempotent per period.

Data model notes

  • CheckoutSession — status, purpose, splitsSnapshot (Json), customerId, savePaymentMethod; single-use cs_ token.
  • Transaction — status, feeMethod, splitsApplied / splitsReconciledAt / splitsMismatch, capturedMinor, refundedMinor, pspReference, merchantReference (<merchantSlug>-<ref>, capped at 80 chars). PaymentEvent rows keep the raw webhook timeline.
  • IdempotencyKey — store-and-replay for @Idempotent endpoints: scoped by method, path, actor and body hash; 24 h TTL; responses persisted only on success so failures stay retryable; fails open on infrastructure errors.
  • Adyen idempotency — pay-<sessionId> on /payments, capture-<txnId> on split-aware captures; the sk_ capture bypass sends none.
  • AdyenWebhookEvent — the ingress ledger: HMAC-verified, unique dedupeKey (eventCode:pspReference:success for classic items), BullMQ jobId = hash of the dedupe key, processedAt re-checked by the worker.

ummah-platform-backend/src/modules/api-keys/api-key.guard.ts:39-84 · api-catalog/api-exposure.guard.ts · common/interceptors/idempotency.interceptor.ts:20-60 · prisma/schema.prisma:833,1052,1178-1191,1273 · ummah-platform-webhook/src/webhooks/ingress.service.ts:18-144 · payments.service.ts:719-725

Section 10

Gaps & recommendations

Everything below is verified against current code, ordered by how much money it can misplace.

P0

Route /v1 capture and cancel through CapturesService

POST /v1/payments/:id/capture and /cancel still call raw Adyen operations — no guards, no splits, no mirror re-record, no idempotency key. A partial capture on an explicit-splits payment books the full amount to the liable account. Fix: delegate both to CapturesService.capture/release, exactly as the portal and admin controllers already do.

P0

Correct feeMethod/splitsApplied from post-auth truth

applyPaymentResult stores the actual scheme and funding source from additionalData but never recomputes the mirror; 3DS completions don't touch it either, and brand-less MIT charges always record REMAINING-group rates. Fix: recompute the mirror in applyPaymentResult (and seed MIT charges from the stored card's known brand), leaving splitsMismatch as a backstop rather than the primary mechanism.

P1

Add a splitsApplied backfill job

Rows that missed both the /payments write and the capture re-record can never gain a mirror; refunds on them submit without splits, and the daily splits-awaiting-stale check only inspects rows that already have one. Fix: a worker job that reconstructs the mirror from booked AdyenTransferLeg rows.

P2

Unify the two recorded-split dialects

'sub-net'/'client-fee'/'ummah-fee' legs coexist with 'seller-split'/'ummah-fixed'/'ummah-variable'; refund funding and capture scaling match both by reference string. A third producer that forgets a name silently degrades refunds to Adyen's default proportional funding. Fix: a versioned leg schema, consumed by role rather than by reference name.

P2

Tips lose their routing on profile stores

buildResolvedSplits supports a tip leg, but profile stores send no explicit splits and the store split configuration has no tip concept — the tip books inside the seller remainder, and the beneficiary routing recorded in the snapshot is lost. Fix on the pricing side; economics on Pricings, Splits & Fees.

P2

Make authorisedUntil honest

The hold's displayed expiry (~7 days) is decorative — nothing expires or alerts on it, and scheme auth validity varies. Fix: either drive EXPIRED from it with a sweep, or label it an estimate in both portals.