Section 1
The proof machine
There is no settlement webhook. A comment in the worker spells it out: Adyen's SettleScheduled and SettledAcquirer states track the acquirer leg inside Customer Area and are never webhooked per payment. Settlement on Ummah is therefore an inference — a conclusion the platform is only allowed to draw once a complete, matching set of booked split legs has arrived.
The mechanism has two independently written ledgers. At pay time (and again at capture) the backend writes down the split it expects Adyen to book — the mirror, Transaction.splitsApplied, computed with the same half-to-even rounding Adyen uses so arithmetic can never cause a false alarm. Meanwhile Adyen books the real split via the store's attached split configuration and streams each booked leg back as a balancePlatform.transfer.* webhook, which the worker persists as AdyenTransferLeg rows. The worker's reconcileSplits sits between the two and issues the verdict: agreement promotes the transaction to SETTLED and accrues the rolling reserve; divergence stamps a splitsMismatch and wakes the admins. What the split rates are and who sets them is the territory of Pricings, Splits & Fees — this page is about proving the booked money matches them.
Two ledgers, one verdict
FIG 1 · mirror vs booked
%%{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
PAY["Pay time
POST /payments"]:::ummah --> MIR["Transaction.splitsApplied
the mirror, half-to-even"]:::ummah
CAPT["Capture re-record
CapturesService"]:::ummah --> MIR
ADY["Adyen auto-split
store splitConfiguration"]:::adyen --> WH["balancePlatform.transfer.*
webhooks"]:::adyen
WH --> LEG["AdyenTransferLeg
one row per booked leg"]:::plain
MIR --> REC{"reconcileSplits"}:::ummah
LEG --> REC
REC -->|"buckets agree"| OKN["SETTLED · splitsReconciledAt
reserve accrual"]:::sub
REC -->|"buckets diverge"| MMN["splitsMismatch
audit + admin alarm"]:::danger
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;
splits[] are sent to Adyen at all, so if the platform's rate choice is wrong (see the feeMethod gap in Section 7), Adyen books the truth and the mirror is what diverges.Transaction.splitsApplied
The mirror
Written by PaymentsService.payments before the Adyen call (status INITIATED) and re-written by CapturesService for the captured amount. Built by buildProfileSplits with bpsOfHalfToEven banker's rounding — verified to match Adyen's booking to the penny on TEST PSP TZGGMW3F9CWD2HV5.
AdyenTransferLeg
The booked truth
One row per balancePlatform.transfer.* event, upserted by the worker's TransferEventService.upsertLeg: unique adyenTransferId, platformPaymentType of BalanceAccount / Commission / AdditionalCommission / PaymentFee, linked to the payment by pspPaymentReference.
ReconciliationRun
The daily proof
One row per 02:00 UTC (or manual) run: eight checks recorded as JSON, a mismatchCount, notes, and a rollup verdict of OK / MISMATCH / DEGRADED. Anything non-OK fires the reconciliation.alert admin notification.
Why keep a mirror at all — why not just trust Adyen's legs?
Because the legs only say where the money went; the mirror says where it should have gone under the store's SplitProfile rates. Comparing the two catches platform bugs (wrong rate group recorded), configuration drift at Adyen, and split rules that silently stopped matching — none of which a single ledger could see. The trade-off is that both feeds still originate from Adyen webhooks plus Ummah's own arithmetic; the independent cross-check against Adyen's settlement report files is a stated but unbuilt capability (Section 7).
ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:45-89 · ummah-platform-backend/src/modules/checkout/payments.service.ts:199-232 · ummah-platform-backend/src/core/adyen/adyen-split.service.ts:498-619 · ummah-platform-backend/prisma/schema.prisma:1185-1191,1477-1495
Section 2
The vocabulary
Ten identifiers carry the whole feature. Everything below is a real column, method, or queue name — verbatim from the code.
| Term | Lives on | What it means |
|---|---|---|
splitsApplied | Transaction (Json?) | The locally recorded mirror of the split Adyen is expected to book. Written at pay time and re-written at capture; never backfilled (a gap — Section 7). |
splitsReconciledAt | Transaction | Timestamp stamped when the booked buckets matched the mirror — the clean verdict. Nulled when a capture re-records the mirror. |
splitsMismatch | Transaction (Json?) | The dirty verdict: {diffs, ours, adyen, legCount}. Stays on the row until manually addressed — never auto-corrected. |
AdyenTransferLeg | own table | One row per balancePlatform.transfer.* event; category, status, direction, amountMinor, currency, balanceAccountId. |
pspPaymentReference | AdyenTransferLeg | Adyen's categoryData key linking a platformPayment leg back to Transaction.pspReference. |
reconcileSplits | worker TransferEventService | The per-payment judge: buckets booked incoming legs against the mirror behind a sum gate (Section 3). |
reconcileForPsp | worker TransferEventService | Re-runs the verdict for a payment — invoked when a CAPTURE webhook succeeds, so the judgement is redone against the captured amount. |
feeMethod | Transaction | The scheme group used to price the mirror — derived pre-auth from the SDK's brand hint, never corrected afterwards (a gap — Section 7). |
Reserve.heldMinor | Reserve | The rolling-reserve pot: accrues rollingPercentBps of the seller leg at every clean settlement, and is re-targeted by the daily run. |
ReconciliationRun | own table | Audit record of every daily/manual sweep: trigger, checks JSON, mismatchCount, rollup OK/MISMATCH/DEGRADED. |
Section 3
Per-payment reconciliation
From the moment a payment authorises, booked legs stream in over seconds. The worker refuses to judge until it has seen them all — then it judges hard.
- The mirror is written first.
PaymentsService.paymentsupserts the Transaction (statusINITIATED) withsplitsApplied = buildProfileSplits(...)before calling Adyen. Profile stores send nosplits[]— Adyen splits automatically from the store's attached configuration — so the mirror legs (seller-split,ummah-fixed,ummah-variable,client-fixed,client-variable, plus theadyen-fee0-marker) are a prediction of Adyen's booking, computed with the same half-to-even rounding. - Adyen books the real legs. Seller remainder to the store's balance account,
Commission(no account) to the liable account,AdditionalCommissionto the parent Client's account, and its real processing fee as aPaymentFeeleg perfeesBorneBy. - Each leg arrives as a webhook.
balancePlatform.transfer.created/updatedevents with categoryplatformPaymentare upserted intoAdyenTransferLeg, keyed byadyenTransferIdand linked viapspPaymentReference. reconcileSplitsbuckets and waits — the mechanism below.
Bucketing & the sum gate
Only incoming legs with status booked or captured count — outgoing legs are refund reversals and belong to Refunds & Approvals. Three buckets are formed: BalanceAccount legs → seller; Commission without an account → commission; Commission with an account → additionalCommission, because Adyen books accounted Commission as AdditionalCommission (the mirror's client-fixed/client-variable legs are counted the same way at transfer-event.service.ts:309). PaymentFee legs are excluded from the verdict entirely: the mirror holds a £0.00 marker while Adyen books its real fee — which lands in AdyenTransferLeg but is never decomposed or used (see Pricings, Splits & Fees on why that starves cost-plus pricing of actuals).
No verdict is issued until the counted buckets sum to capturedMinor || amountMinor. Legs stream in over seconds, and a partial bucket is not a wrong bucket — this sum gate is the false-alarm fix of 2026-07-20. Because both sides always total the full payment amount, the gate is purely a completeness test; the real comparison is the per-bucket one that follows it.
reconcileSplits — mirror vs booked, sum-gated
FIG 2 · the verdict
%%{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 TB
LEGS["Incoming platformPayment legs
status booked or captured"]:::adyen --> FXQ{"Leg currency =
payment currency?"}:::plain
FXQ -->|"no — FX conversion"| FXS["SETTLED unverified
reserve accrual skipped"]:::danger
FXQ -->|"yes"| BUCK["Bucket by type
seller · commission · additionalCommission"]:::ummah
BUCK --> GATE{"Buckets sum to
capturedMinor or amountMinor?"}:::plain
GATE -->|"not yet"| WAITN["No verdict
legs still streaming"]:::warn
GATE -->|"complete"| CMP{"Each bucket equals
the splitsApplied mirror?"}:::ummah
CMP -->|"clean"| OKN["SETTLED + splitsReconciledAt
Reserve.heldMinor accrues"]:::sub
CMP -->|"diverges"| MMN["splitsMismatch diffs
notifyAdmins split.mismatch"]:::danger
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;
PaymentFee legs bypass this flow entirely — excluded before bucketing because the mirror only ever holds a 0-marker for them. Note the FX branch exits before any verification: a cross-currency settlement is promoted on trust alone.On a complete set the transaction is promoted to SETTLED (fixing capturedMinor for immediate captures) and the rolling reserve accrues — an atomic, once-only increment of Reserve.heldMinor by rollingPercentBps of the seller leg. Then the verdict is stamped: splitsReconciledAt when clean, or splitsMismatch with the diffs plus an error log, a transaction.split_mismatch audit entry, and a notifyAdmins('split.mismatch') alarm when not.
A worked verdict — TEST PSP TZGGMW3F9CWD2HV5
A £100.00 sub-merchant payment on a profile priced at 1% + £1.00 commission with a 2% additionalCommission for the parent Client (rates are illustrative of that TEST profile — headline pricing lives in Pricings, Splits & Fees):
Mirror leg (splitsApplied) | Amount | Booked as (platformPaymentType) | Bucket |
|---|---|---|---|
seller-split → store BA | £96.00 | BalanceAccount, incoming | seller |
ummah-fixed · Commission, no account | £1.00 | Commission | commission |
ummah-variable · Commission, no account | £1.00 | Commission | commission |
client-variable · Commission with account | £2.00 | AdditionalCommission → Client BA | additionalCommission |
adyen-fee · 0-marker | £0.00 | PaymentFee (Adyen's real fee) | excluded |
| Counted buckets | £100.00 | = amountMinor | verdict: clean |
Buckets sum to the payment amount, each bucket equals its mirror counterpart, so the row is promoted SETTLED, splitsReconciledAt is stamped, and the reserve accrues its percentage of the £96.00 seller leg. Zero-value legs (here a would-be client-fixed) are simply not emitted.
Capture re-runs the judgement
A manual-capture hold changes the amount being judged. CapturesService re-records the mirror for the captured amount — nulling splitsReconciledAt and clearing any splitsMismatch — and moves the row to CAPTURE_PENDING. When the CAPTURE webhook confirms success, the handler calls reconcileForPsp to re-run the whole verdict against capturedMinor. A row whose legs already promoted it to SETTLED is never downgraded. The one door that dodges all of this is the sk-key POST /v1/payments/:id/capture, which captures at Adyen raw — no re-record, no status guard — leaving a stale mirror that only the mismatch alarm will catch (Section 7).
The FX blind spot
When the settled currency differs from the payment currency (found live 2026-08-18), reconcileSplits has no Adyen FX rate to verify against: the transaction is promoted to SETTLED with no amount verification, no alarm, and no reserve accrual — Reserve.heldMinor is single-denomination, so cross-currency seller legs simply never feed it.
ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:246-470 · ummah-platform-backend/src/modules/checkout/payments.service.ts:199-232 · ummah-platform-backend/src/modules/captures/captures.service.ts:88-146 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:328-333 · ummah-platform-worker/src/core/adyen/adyen-split.service.ts:474-595
Section 4
The daily 02:00 run
Per-payment reconciliation is event-driven; the daily run is the safety net underneath it — a BullMQ repeatable job (jobId reconciliation-daily) that sweeps for everything the event stream should have resolved but did not. Staff can also trigger it by hand: POST /api/admin/reconciliation/run enqueues trigger:'MANUAL' on the same queue, with an hour-bucketed jobId that collapses duplicate clicks.
| # | Check | What it scans | Trips when |
|---|---|---|---|
| 1 | splits-mismatched | Transactions with splitsMismatch not null | Any row exists — lingering mismatches keep every run non-OK |
| 2 | splits-awaiting-stale | splitsApplied set, splitsReconciledAt null | Older than 24 h — legs that never completed the sum gate |
| 3 | transactions-stuck | AUTHORIZED / CAPTURE_PENDING / CAPTURED | Older than 3 d |
| 4 | refunds-stuck | In-flight refunds | Older than 3 d — hand-off to Refunds & Approvals |
| 5 | payouts-stuck | Payouts in PROCESSING / SENT | Older than 3 d |
| 6 | recoveries-stuck | Dispute recovery transfers | Older than 24 h |
| 7 | balance-refresh | BalanceSnapshotService.refreshAll() — full re-pull of every balance account from Adyen | Total failure ⇒ rollup DEGRADED (note: re-run manually once Adyen recovers) |
| 8 | reserve-retarget | Reserve.heldMinor := max(minHoldMinor, rollingPercentBps of trailing holdDays settled seller net) | Never — informational, not a mismatch |
Each ledger sweep (checks 1–6) records up to 10 sample ids into the run's checks JSON so an operator can jump straight to offending rows. The seller net used by the re-target is computed from splitsApplied Commission legs — amount minus refunds minus commission of both kinds.
The 02:00 sweep, check by check
FIG 3 · daily run
%%{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 TB
TRIG["02:00 UTC repeatable job
or manual admin run"]:::ummah --> RUNROW["ReconciliationRun row
trigger SCHEDULED or MANUAL"]:::plain
RUNROW --> SWEEPS["Six ledger sweeps
splits-mismatched · awaiting-stale over 24 h
txns, refunds, payouts over 3 d · recoveries over 24 h"]:::warn
SWEEPS --> BAL{"balance-refresh
refreshAll from Adyen"}:::adyen
BAL -->|"every BA failed"| DEG["DEGRADED
Adyen unreachable"]:::danger
BAL -->|"refreshed"| RET["reserve-retarget
informational only"]:::ummah
RET --> ROLL{"Rollup verdict"}:::plain
DEG --> ROLL
ROLL -->|"all clean"| OKV["OK"]:::sub
ROLL -->|"any check trips"| AL["MISMATCH or DEGRADED
notifyAdmins reconciliation.alert"]:::danger
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;
ummah-platform-worker/src/workers/reconciliation.processor.ts:16-294 · ummah-platform-worker/src/workers/reconciliation.logic.ts:15-31 · ummah-platform-backend/src/modules/reconciliation/reconciliation.controller.ts:15-59
Section 5
Mismatch lifecycle
A mismatch is deliberately a dead end for automation: the platform records exactly what disagreed, alarms once, and then waits for a human. Nothing anywhere auto-corrects a splitsMismatch.
From divergent leg to human resolution
FIG 4 · mismatch lifecycle
%%{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 AD as Adyen Balance Platform
participant W as Worker · TransferEventService
participant TX as Transaction row
participant OPS as Ops · back-office
AD-->>W: balancePlatform.transfer.updated · platformPayment leg
W->>TX: upsertLeg → AdyenTransferLeg
W->>W: reconcileSplits · buckets vs splitsApplied
alt buckets diverge from the mirror
W->>TX: splitsMismatch = diffs + ours + adyen + legCount
W-->>OPS: audit transaction.split_mismatch + notifyAdmins split.mismatch
Note over W: redelivered identical verdicts never re-alarm
OPS->>TX: GET /api/admin/reconciliation/mismatches
Note over TX,OPS: never auto-corrected — cleared only by manual action or a capture re-record
else clean
W->>TX: SETTLED + splitsReconciledAt + reserve accrual
end
AD-->>W: CAPTURE webhook · success
W->>W: reconcileForPsp re-runs the verdict for the captured amount
The back-office panel is the ops surface for all of it — five read endpoints under /api/admin/reconciliation (all RequireScope('STAFF')): summary gives the mismatched count, mirrors awaiting a verdict for over 15 minutes, and the clean count over the last 30 days; mismatches returns up to 100 transactions with the full splitsMismatch payload, both sides and the diffs; awaiting lists the not-yet-judged; runs and runs/latest expose the daily-run history. A lingering mismatch also keeps every subsequent ReconciliationRun at MISMATCH via check 1 — the system nags until someone acts.
ummah-platform-backend/src/modules/reconciliation/reconciliation.service.ts:41-117 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:424-466
Section 6
Implementation notes
Everything reconciliation-facing lives under one admin module in the backend plus two worker processors; the webhook repo feeds them.
| Method · path | Guard | Purpose |
|---|---|---|
POST /api/admin/reconciliation/run | RequireScope('STAFF') + merchant.write | Enqueue a trigger:'MANUAL' run on QUEUE_RECONCILIATION; hour-bucketed jobId collapses duplicates. |
GET /api/admin/reconciliation/summary | RequireScope('STAFF') | Mismatched count · awaiting over 15 min · clean in the last 30 days. |
GET /api/admin/reconciliation/mismatches | RequireScope('STAFF') | Up to 100 transactions with the splitsMismatch payload (both sides + diffs). |
GET /api/admin/reconciliation/awaiting | RequireScope('STAFF') | Mirrors written but not yet judged (splitsReconciledAt null). |
GET /api/admin/reconciliation/runs · /runs/latest | RequireScope('STAFF') | Run history and the latest rollup with its checks JSON. |
Data model
Transaction:splitsApplied Json?·splitsReconciledAt DateTime?·splitsMismatch Json?·feeMethodAdyenTransferLeg: uniqueadyenTransferId,category,platformPaymentType,pspPaymentReference,amountMinor,currency,balanceAccountId,direction,statusReconciliationRun:trigger, rollup status, checks JSON,mismatchCount,notes,finishedAtReserve:heldMinor,rollingPercentBps,minHoldMinor,holdDays
Queues & delivery guarantees
QUEUE_RECONCILIATION: repeatable0 2 * * *UTC, jobIdreconciliation-dailywebhook-in.adyen: carries everybalancePlatform.transfer.*event; ingress dedupes viaAdyenWebhookEvent.dedupeKey(bp:+ sha256 of the raw body), jobId = sha256(dedupeKey),processedAtre-checked before dispatch- At-least-once processing throughout — hence leg upserts, forward-only verdicts, and the re-alarm guard
ummah-platform-backend/src/modules/reconciliation/reconciliation.controller.ts:15-59 · ummah-platform-backend/src/modules/reconciliation/reconciliation.service.ts:41-117 · ummah-platform-webhook/src/webhooks/ingress.service.ts:50-107 · ummah-platform-worker/src/workers/webhook-in-adyen.processor.ts:56-62 · ummah-platform-backend/prisma/schema.prisma:1185-1191,1477-1495
Section 7
Gaps & recommendations
The proof machine is genuinely good at what it checks. These are the places where it checks nothing — ordered by how much money is at stake.
No external truth: Adyen settlement/accounting reports are never ingested
There is no REPORT_AVAILABLE handler and no CSV parser in the backend, worker, or webhook repo; the statements module explicitly defers the line-for-line accounting cross-check as "PR-12 scope" (statements.service.ts:33-34). Both reconciliation feeds derive from Adyen webhooks plus local arithmetic. Fix: ingest Adyen's settlement-detail reports and reconcile leg sums line-for-line — this also unlocks fee actuals, since booked PaymentFee amounts sit undecomposed in AdyenTransferLeg today (see Pricings, Splits & Fees on the missing cost-plus actuals feed).
FX-converted settlements are promoted unverified and skip the reserve
When the settled currency differs from the payment currency, the transaction goes SETTLED with no amount verification, no alarm, and no Reserve.heldMinor accrual (single-denomination reserve). Non-GBP flows carry a thinner safety net end to end. Fix: persist Adyen's converted amounts, reconcile in the settled currency, and accrue reserve on the converted seller net (or per-currency reserves).
Platform-earnings overcount: the Client's cut is booked as Ummah income
PlatformEarningsService.collected() / dailySeries() and commissionFromSplits in the report registry filter splitsApplied legs on type = 'Commission' only — never excluding legs that carry an account, i.e. the client-fixed/client-variable legs Adyen actually books to the Client's balance account as AdditionalCommission. Gross also counts unsettled AUTHORIZED/CAPTURE_PENDING rows. The correct bucketing already exists at transfer-event.service.ts:309. Fix: exclude accounted Commission legs in both SQL paths; economics context in Pricings, Splits & Fees.
The mirror trusts a client-supplied brand hint — and MIT charges always record REMAINING
feeMethod comes from the SDK's pre-auth BIN-lookup claim and is never corrected against the authoritative additionalData scheme post-auth, so on per-scheme-priced profiles the mirror can be wrong while Adyen is right — surfacing only as a mismatch alarm. MIT stored-card charges are the guaranteed case: mitCharge sends {type:'scheme'} with no brand, so every one records REMAINING-group rates. Fix: recompute feeMethod and the mirror from the AUTHORISATION webhook's additionalData.
No splitsApplied backfill job — mirror-less rows are invisible
The mirror is written only at /payments and capture time. A row that missed both (crash between writes, legacy pre-split payments) can never gain one; splits-awaiting-stale only scans rows that have splitsApplied, so such rows escape the daily run entirely, and refunds on them submit without splits. Fix: a worker backfill job that reconstructs mirrors from the booked AdyenTransferLeg set.
POST /v1/payments/:id/capture bypasses the split-aware capture path
The sk-key capture calls Adyen raw: no splits[], no status guard, no mirror re-record, no idempotency key. A partial capture on an explicit-splits payment books the whole amount to the liable account while the mirror stays at the auth amount — a guaranteed mismatch at best, misbooked money at worst. Fix: route the v1 surface through CapturesService (same for /v1/payments/:id/cancel vs release()).