Section 1
The model in one view
Ummah does not invoice fees and it does not sweep commission at month-end. The money divides itself at the moment the card is authorised, on Adyen's balance platform, according to a template Ummah controls — the Split Profile. Everything else in this document is detail on who edits that template, how the legs land, and what happens when money flows backwards.
Where a payment goes
FIG 1 · money 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":38,"rankSpacing":52,"padding":10}}}%%
flowchart LR
shopper(["Donor taps card
£100.00"]) --> adyen["Adyen authorises &
auto-splits at source
store's split configuration"]
adyen -->|"remainder
£96.00"| store[("Sub-merchant
store balance account")]
adyen -->|"commission
£2.00"| liable[("Ummah
liable balance account")]
adyen -->|"additionalCommission
£2.00"| client[("Marketplace Client
balance account")]
adyen -.->|"paymentFee — Adyen's own cost"| bearer{"feesBorneBy?"}
bearer -.->|"SUBMERCHANT
passthrough"| store
bearer -.->|"PLATFORM
blended"| liable
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,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 plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class adyen adyen; class liable liable; class client client; class store sub; class shopper,bearer plain;
SplitProfile — payment pricing
The only live mechanism that prices payments. Commission (% + fixed) to Ummah, an optional second cut to a marketplace Client, a fee-bearer switch, and optional per-scheme rates. Synced to Adyen as a store-attached split configuration; a store without one cannot take payments at all (422 store_not_split).
FeeProfile — event pricing
Prices non-payment events — TRANSFER, CHARGEBACK, DECLINE, REFUND — as Adyen internal transfers booked by the worker into Ummah's liable account, with an optional Client cut on top for sub-merchants. Payouts are free by decision (2026-08-11); the PAYOUT enum value is reserved.
Calculator — pricing intelligence
Resolves the exact Ummah/Client cuts from the store's live profile (Adyen's own half-to-even rounding) and estimates card costs from the admin-editable PlatformCostAssumption rate card. Warns in red when a blended profile loses money on a card. Staff and merchants see the same engine.
A fourth mechanism — the per-method FeeSchedule engine — was retired in place behind FEATURE_PRICING (its routes 404 by default). It still silently computes the frozen snapshot every checkout session; only profile-less stores would ever use it, and the checkout guard makes those unreachable. It appears in this document only as a failure-mode footnote.
The deep reference — the comprehensive internal pricing bible, Ummah Pay — Fees, Card Types, Currencies & Pricing, carries the full cost-by-card-type taxonomy, the split fee groups, the currency/FX scenarios and the worked £100 examples this document builds on. Section 7 works out what a true IC++ product would mean on the split engine.
Section 2
The vocabulary
Eight terms carry the whole pricing conversation. Precise meanings, as implemented:
| Term | What it means in Ummah |
|---|---|
| Commission | Ummah's cut of a payment: commissionPercentBps (basis points, 115 = 1.15%) + commissionFixedMinor (pence). Emitted as Adyen splitLogic.commission, which books automatically to Ummah's liable balance account — no account id needed. |
| Additional commission | The marketplace Client's cut on its sub-merchants' payments: additionalCommission* + the Client's own balance-account id. The third leg of the 3-way split. One Client per profile; only the rate can vary per scheme. |
| Remainder | What's left after the cuts — the merchant's net. Adyen adds it to the paid store's balance account (addToOneBalanceAccount). |
| Fee bearer feesBorneBy | Who pays Adyen's real processing costs (interchange + scheme + markup). SUBMERCHANT = deducted from the paid store's account (passthrough — merchant pays actuals, Ummah's commission is clean margin). PLATFORM = deducted from the liable account (blended — Ummah absorbs costs inside its commission). |
| Method groups | Per-scheme pricing in exactly three buckets: VISA_MC (one rate for both), AMEX, and REMAINING — which doubles as the mandatory catch-all: Adyen rule ANY/ANY. Without a catch-all, an unmatched payment books 100% to the liable account and the merchant receives nothing. |
| Liable account | Ummah's own balance account on the Adyen balance platform (TrustXPay operating entity). Receives every commission leg and every fee charge — and is also what Adyen debits for chargebacks. Commission income and absorbed losses share this account, which is why the ledger must keep them apart. |
| The mirror splitsApplied | Ummah never sends split amounts on a profile-store payment — Adyen computes them. The platform records its own prediction of the legs (same names, same banker's rounding) on the Transaction, then reconciles it against the legs Adyen actually booked. Agreement ⇒ SETTLED; divergence ⇒ splitsMismatch + ops alarm. |
| FeeCharge | The deduction ledger for FeeProfile events: one row per charge with a unique sourceRef (e.g. chargeback-<disputeId>), booked as up to two idempotent internal transfers (platform leg → liable account; Client leg → parent's account), retried until the money lands. |
Section 4
How a payment splits
The subtlety worth internalising: Ummah does not send split amounts on profile-store payments. It attaches the rules to the store once, then lets Adyen do the arithmetic on every authorisation — and audits Adyen leg-by-leg afterwards.
Payment, auto-split and reconciliation
FIG 3 · 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 Ummah backend
participant S as Shopper browser
participant A as Adyen
participant W as Ummah worker
M->>B: POST /v1/checkout/sessions (sk_ key)
B->>B: assertStoreIsSplit — profile + Adyen store + BA
else 422 store_not_split
B-->>M: cs_ session token + pk_ key
S->>B: POST /sdk/checkout/payments (card, brand hint)
B->>B: record splitsApplied mirror
rates for the brand's scheme group
B->>A: POST /payments — store=ST…, NO splits[]
A->>A: auto-split per the store's
split configuration rules
A-->>S: resultCode Authorised (3DS if needed)
A--)W: webhook AUTHORISATION
W->>W: Transaction → AUTHORIZED
A--)W: balancePlatform.transfer.* — one event per booked leg
W->>W: reconcileSplits: booked legs vs mirror
alt legs sum & match
W->>W: Transaction → SETTLED · reserve accrued
splitsReconciledAt stamped
else divergence
W->>W: splitsMismatch + audit + ops alarm
end
buildProfileSplits, half-to-even rounding to match Adyen exactly) that becomes splitsApplied.Worked example — £100 at the standard rate
Ummah's standard UK rate is 1.15% + £0.15 (UK credit card, GBP, presented in the UK). On a £100 payment through a direct merchant's store:
What happens to Adyen's own cost (~£0.62 on this card) depends entirely on the fee bearer — which is the difference between Scenario 1 and Scenario 2 below.
Section 5
Scenarios
Ten scenarios cover the full space: direct merchants, the marketplace, money flowing backwards, and the ways the machine can bite. Each one states the configuration, walks the money, and closes with a verdict.
Ummah prices a direct merchant — blended
Setup. At invite, the admin must pick a defaultSplitProfileId — the invite refuses without one. On activation the default store inherits it; every later store inherits it too. The merchant never chooses, and never can.
What happens on £100 (UK Visa credit). Adyen books commission £1.30 to the liable account and remainder £98.70 to the store. Adyen's real cost (interchange + scheme + markup, ~£0.62) is deducted from the liable account — Ummah absorbs it inside the £1.30.
| Line | Amount | Lands where |
|---|---|---|
| Customer pays | £100.00 | — |
| Merchant receives (remainder) | £98.70 | Store balance account |
| Ummah commission 1.15% + £0.15 | £1.30 | Liable account |
| Adyen's actual cost (est.) | −£0.62 | Deducted from the liable account |
| Ummah net margin | £0.68 | Estimate — actuals are never captured (§7) |
The Amex trap. A blended single rate must clear the worst card, not the average. Amex costs ~3.95% flat: on £100 that is ~£3.95 against £1.30 of commission — Ummah loses ~£2.65. The back-office calculator computes exactly this and raises its red ummahLoss alert. Blended profiles therefore demand either per-scheme rates (S3) or the passthrough posture (S2).
split-profiles.service.ts:465–573 · admin-merchants.service.ts:320–354 · calculator.engine.ts:21–145 (ummahLoss)
Ummah prices a direct merchant — passthrough (the Tier-1 posture)
Setup. The Tier-1 rate sheet is component-based: a locked £0.10 processing fee + a locked payment-method margin (0.20% on UK cards), with interchange and scheme fees passed through at Adyen's actual cost. In the engine this is one switch: feesBorneBy = SUBMERCHANT makes Adyen deduct its real costs from the merchant's store account, so Ummah's commission is clean margin.
| Line | Amount | Character |
|---|---|---|
| Customer pays | £100.00 | — |
| Ummah commission 0.20% + £0.10 | £0.30 | Locked — priced by Ummah |
| Interchange (UK credit, actual) | ~£0.30 | Passthrough — billed at cost |
| Scheme fee + Adyen markup (actual) | ~£0.43 | Passthrough — billed at cost |
| Merchant receives (approx.) | ~£98.97 | Varies with the shopper's actual card |
Why the platform prefers this posture. The Phase-3 decision record is explicit: blended goes negative on Amex (−£2.58/£100) and even Visa credit at thin rates. Under passthrough, cost risk sits with the merchant and Ummah's margin is invariant per card. The sheet's per-line values for interchange are display estimates — Adyen deducts actuals.
feesBorneBy flips every Adyen cost at once. The Tier-1 sheet as written survives this; a future rate card with a locked scheme-fee line would not.PRICING_TIER1_ALIGNMENT.md §2–3 · PHASE_3_SPLITS_PLAN.md D1 · statements.service.ts:24–338
Per-scheme pricing — Visa/MC vs Amex vs everything else
Setup. The back-office form has a "price each card scheme separately" toggle that opens three rate cards. Sync writes one Adyen rule per scheme — visa and mc share the VISA_MC rate, amex gets its own, and ANY carries the REMAINING rate as the mandatory catch-all. Missing groups fall back to the profile's base rate, never to zero.
| Card presented | Rule Adyen matches | Ummah fee | Merchant net |
|---|---|---|---|
| Visa credit | visa → VISA_MC | £1.30 | £98.70 |
| Mastercard debit | mc → VISA_MC | £1.30 | £98.70 |
| American Express | amex → AMEX | £2.90 | £97.10 |
| Apple Pay (Visa inside) | ANY → REMAINING | £1.30 | £98.70 |
| Alipay / WeChat Pay | ANY → REMAINING | £1.30 | £98.70 |
The last two rows are the problem. Wallets and high-cost APMs share one bucket. The Tier-1 sheet wants wallets at 0.20% and Alipay/WeChat at 3.00% — those two rates cannot coexist in a three-bucket engine. The raw Adyen rules[] escape hatch can express the full grid, but it is invisible to the back-office UI and — verified in code — self-erasing: any later edit re-syncs the profile from the managed fields and silently deletes the raw rules at Adyen (S10).
schema.prisma:669–746 (SplitMethodGroup) · split-profiles.service.ts:527–573 (desiredRules) · SplitProfileFormDialog.tsx:607–767
Ummah prices the whole marketplace — Client and sub-merchants
Setup. When the admin invites a marketplace Client it makes three decisions in one dialog: the Client's own store pricing (its default profile), the canCreateSubMerchants capability, and — the number that matters most — Ummah's locked cut on every sub-merchant payment (subMerchantCommission% + £). Zero is legitimate: some Clients pay a one-off fee instead.
The admin can also build the sub-merchant profiles itself (with per-scheme rates if wanted — something the Client cannot do), setting the Client's cut as additionalCommission routed to the Client's balance account. Assignment is per sub-merchant store; a sub-merchant invite inherits the parent's default profile so the sub is never unsplit.
The 3-way split, as verified on Adyen TEST
FIG 4 · PSP TZGGMW3F9CWD2HV5
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":50,"padding":10}}}%%
flowchart LR
pay(["£100.00 donation
sub-merchant's store"]) --> cfg["One split configuration
on the sub's store"]
cfg -->|"seller remainder"| s[("Sub-merchant store BA
£96.00")]
cfg -->|"commission — fixed £1.00
+ variable 1% £1.00"| u[("Ummah liable BA
£2.00")]
cfg -->|"additionalCommission 2%"| c[("Client BA
£2.00")]
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class s sub; class u liable; class c client; class pay,cfg plain;
commission needs no account (auto-books to the liable BA) while additionalCommission carries the Client's BA explicitly — a cross-account-holder booking Adyen accepts. Adyen allows one split configuration per store, and one is all it takes.InviteMerchantDialog.tsx:278–335 · admin.dto.ts:83–107 · split-profiles.service.ts:465–513
The Client prices its own sub-merchants
What the Client actually controls. Its portal "Splits" page creates profiles with exactly two numbers — its own cut % and £. Everything else is decided for it: Ummah's commission is stamped from the locked rate (shown read-only with "Set by Ummah — you cannot change this"), the cut is routed to the Client's own balance account, the fee bearer is forced to passthrough. The Client then assigns the profile to a sub's store — with an invite-time picker, or later from /splits.
Client creates and deploys a sub-merchant split
FIG 5 · sequence with the server-side stamp
%%{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"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30}}}%%
sequenceDiagram
participant C as Client (portal)
participant B as Ummah backend
participant A as Adyen Management API
C->>B: POST /api/portal/split-profiles { name, cut 3% + £0 }
B->>B: gate: canCreateSubMerchants + own BA provisioned
B->>B: STAMP commission = admin-locked subMerchantCommission*
additionalCommission → Client's own BA
feesBorneBy := SUBMERCHANT · createdByMerchant := true
C->>B: POST /:id/sync
B->>B: re-stamp Ummah's commission from the current locked rate
B->>A: create/update splitConfiguration
rules added before old ones deleted — catch-all never lapses
A-->>B: splitConfigurationId
C->>B: POST /:id/assign { storeId }
B->>B: store must belong to one of THIS Client's subs
else 422 store_not_in_marketplace
B->>A: PATCH store — attach configuration + store BA
Note over C,A: every payment on that store now splits 3 ways automatically
What the Client cannot do — and whether that is right:
- Price its own stores. Correct forever — that is Ummah's revenue line.
- Vary its cut per scheme. An artificial limitation: staff can add per-scheme rules to the very same profile, so the data model supports it. Worth opening up once the buckets are extended (R10).
- Choose the fee bearer. Correct — the sub pays acquiring costs; letting the Client flip costs onto Ummah's liable account would be a pricing grant, not a preference.
- See Ummah's locked rate before creating its first split. A real bug-shaped gap: the portal previews it from an arbitrary existing profile, so a fresh Client sees nothing (R7).
portal-split-profiles.dto.ts:13–80 · split-profiles.service.ts:357–461, 606–632 · splits/page.tsx:204
Ummah renegotiates the locked rate — what propagates, when
Path A — edit the merchant's locked fields (at invite, or via staff surfaces): the new subMerchantCommission* takes effect on each Client profile the next time that profile syncs. Nothing pushes automatically — a profile that never syncs again keeps charging the old rate at Adyen.
Path B — staff edits a Client-created profile directly (back-office deep-link): the service also rewrites the merchant's locked fields so the next sync doesn't revert the edit. Deliberate and correct — but it means editing one profile moves the locked rate for all of that Client's profiles at their next sync. The back-office warns about exactly this.
split-profiles.service.ts:158–192 (staff edit rewrites lock) · 606–632 (re-stamp on sync) · SplitProfileFormDialog.tsx:769–794 (warning)
Refunds — who funds what, on the way back
The invariant: the customer always gets the full refund. The only question is which balance accounts fund it. Ummah deliberately never lets Adyen default to proportional reversal — that would silently claw back Ummah's and the Client's cuts on every refund. Instead every refund carries explicit bearer-aware splits built from the recorded payment legs.
Refund funding decision
FIG 6 · both axes
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":34,"rankSpacing":46,"padding":10}}}%%
flowchart TB
r(["Refund £100 of a settled payment"]) --> q1{"Ummah's fee portion —
refundFeeBearer?"}
q1 -->|"MERCHANT · default"| m1["Store BA funds the FULL £100
Ummah keeps its £1.30
merchant's refund cost = the fee"]
q1 -->|"PLATFORM"| m2["Store BA funds £98.70
Ummah returns £1.30 from the liable BA
pro-rata on partial refunds"]
m1 --> q2{"Sub-merchant payment?
clientRefundFeeBearer"}
m2 --> q2
q2 -->|"SUBMERCHANT · default"| c1["Sub's store also funds
the Client-cut portion —
Client keeps its cut"]
q2 -->|"CLIENT"| c2["Client's BA returns
its scaled cut"]
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
class r,q1,q2 plain; class m1,m2 ummah; class c1,c2 client;
Tier-1 note. The sheet says "processing fee incl. refunds". Under MERCHANT bearer, Ummah keeping the original fee is economically similar but not the same as charging +£0.10 per refund. If the business wants a literal per-refund fee, that exists — as a REFUND row on the FeeProfile (S9). Both at once would double-charge; pick one (open question Q1).
adyen-split.service.ts:375–463 · refunds.service.ts:239–293 (totalMinor: txn.amountMinor) · submerchant.controller.ts:219–240
Chargebacks — the recovery waterfall and the fee
Two separate money movements. (1) The moment a CHARGEBACK event arrives, the profile-driven chargeback fee books — win or lose, once per dispute, mirroring how Adyen bills the platform. (2) If the dispute is ultimately lost, the disputed amount is recovered into the liable account through a waterfall — because Adyen debited Ummah's liable account immediately and knows nothing of the merchant tree.
Recovery waterfall for a lost £100 dispute
FIG 7 · tranches, in order
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":30,"rankSpacing":42,"padding":10}}}%%
flowchart LR
lost(["Dispute LOST
£100 owed to the liable BA"]) --> t1["1 · RESERVE
rolling reserve held
for this merchant"]
t1 -->|shortfall| t2["2 · STORE
the store that
took the payment"]
t2 -->|shortfall| t3["3 · SIBLINGS
richest first —
ring-fenced stores excluded"]
t3 -->|shortfall| t4["4 · CLIENT
parent Client's
balance account"]
t4 -->|shortfall| t5["5 · ABSORBED
Ummah writes it off
ops notified"]
classDef step fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
classDef last fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef start fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
class lost start; class t1,t2,t3,t4 step; class t5 last;
Tier-1 note. The sheet's £8.20 chargeback fee is now representable — a CHARGEBACK FeeProfile row with £8.20 fixed (plus an optional Client cut on top for subs). The alignment doc's “blocked” verdict predates the FeeProfile build and should be updated.
recovery-plan.ts:23–153 · disputes-intake.service.ts:66–343 · fee-trigger.service.ts:36–113
Non-payment fees — transfers, declines, refunds; payouts free
How it books. No profile assigned ⇒ the merchant is explicitly fee-free. With one, each event books from a rate row (% + fixed, platform leg always, Client leg added on top only when the payer is a sub-merchant) as internal transfers with unique idempotent references. Failed bookings (empty balance) retry automatically and surface in the back-office ledger with manual retry.
Which instrument prices which event
FIG 8 · decision tree
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":30,"rankSpacing":44,"padding":10}}}%%
flowchart TB
e{"Billable event"} -->|"card payment"| sp["SplitProfile
split inside the authorisation"]
e -->|"refund"| rb["Bearer-aware reversal · S7
+ optional REFUND FeeCharge"]
e -->|"internal transfer
(chargeFee-flagged)"| tf["FeeProfile TRANSFER
e.g. £0.25"]
e -->|"chargeback"| cb["Recovery waterfall · S8
+ FeeProfile CHARGEBACK e.g. £8.20"]
e -->|"declined auth"| dc["FeeProfile DECLINE"]
e -->|"payout to bank"| po["FREE — decided 2026-08-11
PAYOUT enum reserved"]
e -->|"monthly account / KYC"| ab["Ummah absorbs —
platform P&L, not merchant pricing"]
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef ok fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class e plain; class sp,rb,tf,cb,dc ummah; class po,ab ok;
schema.prisma:914–1017 · fee-charge.processor.ts:20–140 · admin-fees.controller.ts:30–147
Failure modes — where the machine bites today
| Failure | What happens | Severity |
|---|---|---|
| The /v1 capture bypass | POST /v1/payments/:id/capture (sk-key surface) skips the split-aware CapturesService entirely: no status guard, no splits sent or re-recorded, no idempotency key. A partial capture on an explicit-splits payment books the whole amount to the liable account. Same for /cancel vs release. | P0 |
| Platform earnings overcount | The earnings SQL counts every Commission leg as Ummah income — including Client-cut legs (Commission with an account, booked to the Client's BA). Every sub-merchant payment inflates reported Ummah revenue by the Client's cut. Same defect in the P&L and fees reports. The correct bucketing already exists in the reconciliation code. | P0 |
| Wrong-scheme mirror | feeMethod comes from the SDK's pre-auth brand hint, never corrected from Adyen's authoritative post-auth data. Stored-card MIT charges send no brand at all ⇒ every MIT on a per-scheme profile records REMAINING rates — guaranteed mismatch noise on subscription-heavy merchants, and mismatch alarms that train ops to ignore alarms. | P0 |
| Unsplit sub-merchant invites | The Client-API and staff sub-merchant invite paths create the sub without a split profile (the portal path resolves one). On activation the store is born profile-less with only a log warning — the sub cannot take a single payment until someone notices and assigns by hand. | P1 |
| The self-erasing escape hatch | Raw rules[] survive only the first sync. Any later edit auto-resyncs from the managed fields and deletes the raw rules at Adyen — silently. The calculator and the mirror ignore raw rules anyway, so a raw-rules profile mis-quotes and mis-records until reconciliation flags it. | P1 |
| Currency-naive fixed fees | All rules are currency: ANY; £0.15 fixed books as €0.15 on a EUR payment. Fine for a GBP-only launch — undefined for anything else. FX-converted settlements additionally settle unverified and skip reserve accrual. | P1 |
| Earmark that doesn't hold | Disputed amounts are earmarked in local bookkeeping only. Nothing subtracts them from payable balance — a merchant can withdraw funds already claimed by an open dispute, pushing recovery onto siblings, the Client, or the write-off tranche. | P1 |
| No commercial ceilings on splits | The retired FeeSchedule had bounds (≤8%, Amex ≤12%, fixed ≤£1). SplitProfile DTOs accept up to 100% + £1,000,000 fixed with no ceiling — for both admin and Client cut inputs. One typo away from a 25% commission syncing straight to Adyen. | P1 |
checkout.controller.ts:90–106 · payments.service.ts:608–650 · platform-earnings.service.ts:100–115 · checkout.service.ts:341–351 · submerchant.service.ts:413–427 · split-profiles.service.ts:576–586 vs 725–814
Section 6
Amex vs Visa/MC, answered precisely
The questions this document was commissioned to settle, answered from code:
If Amex, Mastercard and Visa carry separate splits and Ummah sets the pricing for merchants and sub-merchants — what happens?
Everything works, at three-bucket resolution. The admin's per-scheme editor writes Visa+Mastercard (one shared rate), Amex, and a catch-all. Adyen matches the rule at authorisation — the platform never has to know the brand in advance for the money to be right. Both Ummah's commission and the Client's cut can differ per scheme when the admin builds the profile. Two limits: Visa and Mastercard cannot be priced differently from each other, and every non-card-scheme method (wallets, Alipay, Pay by Bank) shares the one catch-all rate.
And if the merchant (Client) sets the pricing for its sub-merchants — what happens then?
The Client's cut is flat across schemes: the portal accepts one % + £ pair, full stop. Ummah's per-scheme locked commission still applies underneath (if the admin configured scheme rates on the locked side, they ride along — re-stamped into the Client's profile on every sync). So a Client-priced sub-merchant today has: per-scheme Ummah commission (if admin set it), flat Client cut, passthrough Adyen costs. Nothing breaks; the Client simply has less expressive power than Ummah — by design, though the flat-cut restriction is a product choice worth revisiting (R10), since staff can already add scheme rates to the same profile.
Where does the money knowledge live — before or after the card is seen?
Both, and they must agree. Before: the SDK's BIN-lookup brand hint picks which rates the platform records (the mirror). At Adyen: rule matching on the actual payment method decides what is booked. The reconciliation gate compares the two per payment. This dual bookkeeping is the platform's honesty mechanism — which is why the wrong-scheme mirror defects in S10 matter more than they look: they erode the one signal that proves pricing correctness daily.
Rule matching at Adyen, and the bucket collision
FIG 9 · card → rate
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":26,"rankSpacing":48,"padding":10}}}%%
flowchart LR
visa["Visa"] --> vmc["rule visa · rule mc
VISA_MC rate
e.g. 1.15% + £0.15"]
mc["Mastercard"] --> vmc
amex["American Express"] --> ax["rule amex
AMEX rate
e.g. 2.75% + £0.15"]
ap["Apple Pay"] --> any["rule ANY — catch-all
REMAINING rate
one rate for all of these"]
gp["Google / Samsung Pay"] --> any
ali["Alipay · WeChat Pay"] --> any
pbb["Pay by Bank"] --> any
other["Any future method"] --> any
classDef card fill:#F1F4F8,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
classDef rate fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef clash fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
class visa,mc,amex,ap,gp,ali,pbb,other card; class vmc,ax rate; class any clash;
Section 7
The pricing models — industry standard, and what IC++ means on Ummah
Three models decide who bears the card-cost risk: blended, IC+ / IC++, and passthrough. This section defines them the way the industry (Stripe, Adyen) uses them, kills the "charged twice in a marketplace" myth, and — the important part — works out what a true IC++ product would actually look like on Ummah's split engine, given that the splits themselves can't itemise the real cost. The comprehensive internal reference is the Fees, Card Types, Currencies & Pricing bible — this section builds on it.
How the industry prices
Stripe's default is flat/blended — one "% + fixed" per card, tiered by origin (UK online standard 1.5% + 20p, premium 2.8% + 20p; US 2.9% + $0.30), where Stripe absorbs the interchange variance and prices the average. It offers Interchange Plus only on custom/enterprise terms, negotiated on request — not the self-serve default. Adyen (Ummah's acquirer) is natively interchange++/passthrough underneath. The four models:
| Model | Merchant is billed | Bears the Amex risk | Transparency |
|---|---|---|---|
| Blended | One flat rate for every card | Acquirer / platform — prices the average, loses on Amex | One line; real cost hidden |
| IC+ (Interchange Plus) | Interchange at cost + one combined markup (scheme fee folded into the markup) | Merchant | Interchange visible; scheme not split out |
| IC++ (Interchange Plus Plus) | Interchange at cost + scheme fee at cost, itemised + the acquirer markup (the only kept part) | Merchant | Most transparent — every component a line |
| Passthrough | The umbrella term: merchant pays the acquirer's actual cost. IC+ and IC++ are itemised passthrough; "plain" passthrough passes cost through with a single blended markup, not itemised | Merchant | Varies |
The relationships that matter: passthrough is the family; IC++ is its fully-itemised member (IC++ is passthrough, broken into interchange + scheme + markup lines). IC+ folds the scheme fee into the markup; IC++ passes it through separately. Blended is the opposite of all three — a fixed rate where the acquirer eats the variance.
In a marketplace, is the merchant charged a fee and then the sub-merchant charged the same fee again?
No — the processing fee is charged exactly once, then a commission is split off on top. A marketplace payment has three different takers, each taking once: (1) the acquirer's processing cost (Adyen's real fee), paid once; (2) the platform's commission (Ummah's cut) — a markup on top, not a second processing fee; (3) the marketplace client's cut, once. Nobody is double-charged. On a £100 sub-merchant payment the sub-merchant nets the remainder after those three are taken — the same money is never processed-fee'd twice.
What a true IC++ would look like on Ummah — and the settlement answer
Here is the crux you put your finger on: on the splits, Ummah cannot itemise the real cost — because interchange is only known at settlement. At authorisation the card networks haven't determined interchange yet, so there is no real number to put in a split leg. That constraint shapes everything, and it means IC++ on Ummah is inherently two-phase:
IC++ on Ummah is two-phase: markup at auth, cost breakdown at settlement
FIG 8 · what books when
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"12.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":24,"rankSpacing":34,"padding":10}}}%%
flowchart TB
subgraph AUTH["At authorisation — amounts you KNOW"]
p(["£100 payment"]) --> mk["Ummah markup — the '++'
fixed Commission leg → liable BA
e.g. 0.20% + £0.10 = £0.30"]
p --> net["Merchant net
Remainder → merchant BA"]
p --> cost["Adyen cost leg → merchant BA
type set, amount unknown
merchant shown an ESTIMATE"]
end
subgraph SETTLE["At settlement (T+1/T+2) — amounts Adyen COMPUTES"]
a["Adyen deducts the ACTUAL cost
from the merchant BA:
interchange + scheme + Adyen markup"] --> ing["Ummah ingests the settled
cost breakdown (settlement detail)"]
ing --> stmt["Itemised IC++ statement to merchant:
interchange £x · scheme £y · markup £z · Ummah £0.30"]
end
cost -.->|"real number lands here"| a
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class mk ummah; class net sub; class a,ing adyen; class p,cost,stmt plain;
So the mechanics resolve cleanly:
- Ummah's markup — booked at auth. Your commission is a fixed
Commissionsplit leg to the liable account (a % + fixed you set). It is deterministic and does not wait for settlement. - The card cost — passed through, settled later. The Adyen cost leg (
PaymentFee, or Adyen's granular acquiring-fee types) tells Adyen to debit its actual cost from the merchant's balance account. You never set that amount — Adyen fills it at settlement, because that is when interchange is known. - Itemisation is a settlement-time read, not a checkout-time split. To show the merchant "interchange £0.30 · scheme £0.02 · Adyen markup £0.13," Ummah must ingest Adyen's settled cost breakdown after the fact. The split legs at checkout carry the structure; settlement supplies the numbers.
- At checkout the merchant sees an estimate. Because the cost is unknown until settlement, the "you'll receive ~£99.15" shown at pay time is an estimate; the statement reconciles to the real figure a day or two later. (This is the same estimate-vs-settled rule as the FX and passthrough discussions.)
£100 on a UK Visa credit card — itemised
| Line | Amount | Booked | Character |
|---|---|---|---|
| Customer pays | £100.00 | auth | — |
| Interchange (UK credit) | −£0.30 | settlement | at cost → merchant BA |
| Scheme fee | −£0.02 | settlement | at cost → merchant BA |
| Adyen markup + fixed | −£0.20 | settlement | at cost → merchant BA |
| Ummah markup (the ++) | −£0.30 | auth | Commission → liable BA |
| Merchant nets | ~£99.18 | settled | sees every line |
The same £100 on Amex — the merchant bears it, transparently
| Line | Amount | Character |
|---|---|---|
| Customer pays | £100.00 | — |
| Amex acquiring cost (interchange-equivalent + scheme) | −£3.50 | at cost → merchant BA (settlement) |
| Ummah markup (the ++) | −£0.30 | Commission → liable BA |
| Merchant nets | ~£96.20 | sees the £3.50 as its own line |
What Ummah must build to offer it
Ingest the settled cost breakdown
Consume Adyen's settlement-detail / cost data so the actual interchange, scheme fee and Adyen markup land per transaction. Today the PaymentFee split leg is a 0-value marker and is excluded from reconciliation — the real cost lives only in Adyen's ledger. This is the audit's R9 and the single hard prerequisite for IC++.
Store the components, not a blob
Add per-transaction cost columns (interchange, scheme, markup) alongside a commissionMinor — the same denormalisation the earnings fix needs — so IC++ statements are an indexed read, not a JSON scan.
Emit the granular Adyen cost types (optional)
Ummah's split type union already declares AcquiringFees / AdyenMarkup / AdyenFees but emits none — it uses the single PaymentFee. Using the granular types lets Adyen attribute the cost by component; combined with Build 1 it produces a fully-itemised statement.
A merchant IC++ statement + honest checkout copy
Surface the itemised breakdown post-settlement, and label the checkout figure an estimate ("final at settlement, at cost"). Never show a passthrough/IC++ estimate as a guaranteed payout.
feesBorneBy = PLATFORM) and passthrough (feesBorneBy = SUBMERCHANT) — the cost is genuinely passed to the merchant under passthrough, but as one lump, not itemised, and never captured from settlement. That is passthrough with a blended markup, economically close to IC++ but not IC++. Turning it into true IC++ is Builds 1–4 above — nothing in the split shape changes, only the settlement feed and the statement.Section 8
Gap analysis — the Tier-1 rate card vs the engine
Row-by-row, can the platform charge what the business wants to sell?
| Tier-1 line | Status | Detail |
|---|---|---|
| Processing fee £0.10/txn | Works | commissionFixedMinor = 10. The "incl. refunds" clause needs a decision, not code (Q1). |
| Blended headline (1.15% + £0.15) | Works | Single-rate or per-scheme profile; Amex guard in the calculator. |
| Interchange / scheme / FX passthrough | Works | feesBorneBy = SUBMERCHANT — Adyen deducts actuals from the merchant. |
| Amex distinct rate (2.75%) | Works | AMEX method group, UI-managed. |
| Transfer fee £0.25 · chargeback £8.20 · decline · refund fees | Works | FeeProfile rows, booked as internal transfers with a ledger. Built 2026-08-11. |
| Payout fee | Free by decision | Trigger built and removed same day; enum reserved if the decision reverses. |
| 9-column per-method grid (wallets 0.20% vs Alipay 3.00%) | Blocked | Three buckets cannot hold five rates. The escape hatch technically can — and self-erases on the next edit. The gap to close first. |
| Amex US OptBlue (3.30%) | Blocked | Needs a card-region condition the model doesn't carry (Adyen-side availability unconfirmed). |
| Risk surcharge £0.05, optional | Workaround only | A second profile with the surcharge folded into fixed — not per-merchant toggleable, not separately reportable. |
| Locked + passthrough mixed per component | Coarse | feesBorneBy flips all Adyen costs at once. Sufficient for this sheet; insufficient for a locked-scheme-fee variant. |
| Non-GBP fixed amounts | Undefined | currency: ANY books £0.15 as 0.15 of anything. GBP-only until Q5 is answered. |
| Realised margin (the 0.64%) | Missing | No actuals feed, no margin ledger. The commercial margin exists in the calculator's estimates and nowhere else. |
| Volume ladders / tiers | Missing | No pricing object supports them; flat % + fixed only. |
Section 9
Architect's recommendations
The verdict first: the pricing architecture is right. Splitting at authorisation on Adyen's rails, one template per store, an admin-locked platform margin with a Client-composable cut, and a mirror-and-reconcile audit loop — that is the correct shape for this business, and it is proven against the processor's own ledger. Do not redesign it. The work is to close the correctness holes (P0), lift the three-bucket ceiling (P1), and build the margin telemetry the business already believes it has (P2).
Close the /v1 capture & cancel bypass
Route POST /v1/payments/:id/capture and /cancel through the split-aware CapturesService — same guards, same splits, same idempotency. Until then, one partial capture from an API integration books an entire payment to the liable account. A five-line controller change plus tests.
Fix the earnings SQL before anyone trusts a revenue number
Exclude Commission-with-account legs from "collected" in platform-earnings and commissionFromSplits in the report registry, mirroring the bucketing reconciliation already does. Today every marketplace payment overstates Ummah's revenue by the Client's cut — the worst possible bug to discover during due diligence.
Make the mirror truthful
On AUTHORISATION, recompute feeMethod + splitsApplied from Adyen's authoritative additionalData (paymentMethodVariant) instead of trusting the SDK hint — which also fixes the guaranteed-wrong MIT mirror. While there, scale refund pro-rata by captured amount, not authorised (S7).
One invite spine, fail-closed on pricing
Make every sub-merchant invite path resolve a split profile the way the portal path does (explicit choice → parent default → 422). A sub that activates unsplit is a support ticket wearing a growth metric. Fold the marketplace gates (isMarketplace vs canCreateSubMerchants) into one predicate while there.
Extend the method groups — retire the escape hatch for Tier-1
Add WALLETS (applepay, googlepay, samsungpay), APM_HIGH (alipay, wechatpay), PAY_BY_BANK, and optionally AMEX_US to SplitMethodGroup; extend the rule generator, splitGroupFor, the BO editor, and the calculator together (they must stay in lockstep — that is the invariant that keeps the mirror honest). This single change makes the whole Tier-1 grid UI-manageable. Then make raw rules[] either survive re-sync or refuse to save — a one-shot self-erasing hatch is worse than none.
Commercial bounds everywhere money is typed
Port the retired FEE_BOUNDS discipline to SplitProfile and FeeProfile DTOs (server-side) and their dialogs (client-side): commission ≤ 8% (Amex ≤ 12%), fixed ≤ £1, Client cut ≤ a per-Client ceiling the admin sets when enabling sub-merchants. Guardrails are what make delegated pricing (S5) safe to scale.
Make rates visible: a contract-rate surface + the locked-rate control
Merchants currently learn their price from a calculator and raw split legs. Ship (a) a portal "Your rates" page rendered from the live SplitProfile (+ FeeProfile rows), (b) an endpoint exposing the admin-set locked rate so a fresh Client sees Ummah's cut before creating its first split, and (c) a dedicated admin control for subMerchantCommission* with resync-all and per-profile drift indicators (S6). Transparency is a pricing feature, not a docs page.
Risk surcharge as a field, not a fork
One optional riskSurchargeFixedMinor on the profile (or per store), folded into generated rules and reported as its own line. Kills the two-profile workaround and makes the surcharge separately auditable — which is the actual business requirement (Q2).
Ingest settlement actuals; open the margin ledger
Consume Adyen's settlement-detail reports to land per-transaction interchange, scheme fee and markup against the Transaction. Only then does "Ummah margin 0.64%" become a ledger fact instead of a calculator estimate — and IC++ pricing, per-merchant profitability, and blended-loss alerts on real traffic all become possible. Pair with a liable-account ledger that separates earned commission, absorbed costs, chargeback write-offs and fee income (the pieces exist in RecoveryEvent and FeeCharge; give them one view).
Grow Client pricing power deliberately
Once R5 and R6 land: per-scheme Client cuts in the portal (the data model already supports it), and per-currency fixed amounts if Tier-1 goes beyond GBP (Q5). Sequenced last on purpose — delegation expands only after guardrails and buckets exist.
Delete the dead pricing generation
The retired FeeSchedule engine still computes a frozen snapshot every session, feeds nothing reachable, and duplicates fee math in the worker by comment-discipline. Excise the snapshot for profile stores, remove the legacy splitConfig write path, and keep exactly one pricing truth. Dead-but-load-bearing code is where the next pricing bug is already living.
Target state — the same machine, with the holes closed
FIG 10 · after R1–R9
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":34,"rankSpacing":50,"padding":10}}}%%
flowchart LR
subgraph CONFIG["Pricing configuration"]
sp["SplitProfile
6 method groups · bounds ·
surcharge field · currency-scoped fixed"]
fp["FeeProfile
bounded rates"]
end
subgraph FLOW["Every payment"]
pay["Auto-split at auth"] --> mirror["Mirror from Adyen's
authoritative method data"]
mirror --> recon["Reconcile — quiet
because it is now truthful"]
end
subgraph TRUTH["Margin telemetry"]
rep["Settlement-detail ingestion
actual interchange + scheme + markup"]
ledger["Liable-account ledger
earned · absorbed · written-off · fees"]
end
sp --> pay
fp --> ledger
recon --> ledger
rep --> ledger
ledger --> price["Pricing decisions on facts:
real margin per merchant, per scheme"]
price -.->|"re-price"| sp
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef ok fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
class sp,fp ummah; class pay,mirror,recon plain; class rep,ledger,price ok;
Section 10
Open business questions
Five decisions block final Tier-1 configuration. None is engineering's to make alone:
- Q1 · "Processing fee incl. refunds." Literal +£0.10 per refund (⇒ a REFUND FeeProfile row), or "we keep the payment-time fee on refund" (⇒ MERCHANT bearer, already the default)? Both at once double-charges.
- Q2 · Risk surcharge. Opt-in per merchant or per store? Must it report separately? (Determines whether R8 is a field or also a report line.)
- Q3 · Chargeback £8.20. Always, or only when Adyen bills? And if a dispute is later won, is the fee credited back? (Today: booked win-or-lose, no credit path.)
- Q4 · Non-card cost inputs. Interchange for Alipay/WeChat/Amex/Pay-by-Bank and 3DS pricing are still TBC with Adyen — the calculator's cost card needs real numbers before quotes on those methods mean anything.
- Q5 · Currency scope. Is Tier-1 GBP-only? If not, fixed amounts need per-currency values (schema + rule generation work) — today £0.10 books as €0.10.