UMMAH FLOWS · 10 Verified against code · Aug 2026

Financial integrity · reconciliation flows

Reconciliation & Financial Integrity

Adyen never sends Ummah a per-payment "settled" event — so the platform proves its money is right by double-entry: every payment's split is written twice, once by us as a local mirror and once by Adyen as booked transfer legs, and nothing is called settled until the two agree to the minor unit. This page walks the per-payment verdict, the daily 02:00 UTC sweep with its eight checks, the back-office mismatch surfacing, and — honestly — the places where the proof still has holes.

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;
Ummah-owned write Adyen rails neutral store clean verdict mismatch
The mirror is a prediction, not a copy: for profile stores no 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.

TermLives onWhat it means
splitsAppliedTransaction (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).
splitsReconciledAtTransactionTimestamp stamped when the booked buckets matched the mirror — the clean verdict. Nulled when a capture re-records the mirror.
splitsMismatchTransaction (Json?)The dirty verdict: {diffs, ours, adyen, legCount}. Stays on the row until manually addressed — never auto-corrected.
AdyenTransferLegown tableOne row per balancePlatform.transfer.* event; category, status, direction, amountMinor, currency, balanceAccountId.
pspPaymentReferenceAdyenTransferLegAdyen's categoryData key linking a platformPayment leg back to Transaction.pspReference.
reconcileSplitsworker TransferEventServiceThe per-payment judge: buckets booked incoming legs against the mirror behind a sum gate (Section 3).
reconcileForPspworker TransferEventServiceRe-runs the verdict for a payment — invoked when a CAPTURE webhook succeeds, so the judgement is redone against the captured amount.
feeMethodTransactionThe scheme group used to price the mirror — derived pre-auth from the SDK's brand hint, never corrected afterwards (a gap — Section 7).
Reserve.heldMinorReserveThe rolling-reserve pot: accrues rollingPercentBps of the seller leg at every clean settlement, and is re-targeted by the daily run.
ReconciliationRunown tableAudit record of every daily/manual sweep: trigger, checks JSON, mismatchCount, rollup OK/MISMATCH/DEGRADED.

Section 3

Per-payment reconciliation

Adyen Balance Platform PaymentsService · backend TransferEventService · worker queue webhook-in.adyen

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.

  1. The mirror is written first. PaymentsService.payments upserts the Transaction (status INITIATED) with splitsApplied = buildProfileSplits(...) before calling Adyen. Profile stores send no splits[] — Adyen splits automatically from the store's attached configuration — so the mirror legs (seller-split, ummah-fixed, ummah-variable, client-fixed, client-variable, plus the adyen-fee 0-marker) are a prediction of Adyen's booking, computed with the same half-to-even rounding.
  2. Adyen books the real legs. Seller remainder to the store's balance account, Commission (no account) to the liable account, AdditionalCommission to the parent Client's account, and its real processing fee as a PaymentFee leg per feesBorneBy.
  3. Each leg arrives as a webhook. balancePlatform.transfer.created/updated events with category platformPayment are upserted into AdyenTransferLeg, keyed by adyenTransferId and linked via pspPaymentReference.
  4. reconcileSplits buckets 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;
Adyen feed Ummah logic waiting clean settle mismatch / unverified
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 legs vs booked legs vs reconciliation bucket
Mirror leg (splitsApplied)AmountBooked as (platformPaymentType)Bucket
seller-split → store BA£96.00BalanceAccount, incomingseller
ummah-fixed · Commission, no account£1.00Commissioncommission
ummah-variable · Commission, no account£1.00Commissioncommission
client-variable · Commission with account£2.00AdditionalCommission → Client BAadditionalCommission
adyen-fee · 0-marker£0.00PaymentFee (Adyen's real fee)excluded
Counted buckets£100.00= amountMinorverdict: 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.

VerdictThe sum gate is the right design — partial buckets are not wrong buckets, and since the fix of 2026-07-20 the detector has been quiet unless something is genuinely off. But two doors bypass the judgement entirely (the /v1 capture and FX settlements), and the whole loop compares Adyen's webhooks against Ummah's own arithmetic — there is no third, external source of truth yet.

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

ReconciliationProcessor · worker QUEUE_RECONCILIATION 0 2 * * * UTC Ops · back-office

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.

The eight checks, in execution order
#CheckWhat it scansTrips when
1splits-mismatchedTransactions with splitsMismatch not nullAny row exists — lingering mismatches keep every run non-OK
2splits-awaiting-stalesplitsApplied set, splitsReconciledAt nullOlder than 24 h — legs that never completed the sum gate
3transactions-stuckAUTHORIZED / CAPTURE_PENDING / CAPTUREDOlder than 3 d
4refunds-stuckIn-flight refundsOlder than 3 d — hand-off to Refunds & Approvals
5payouts-stuckPayouts in PROCESSING / SENTOlder than 3 d
6recoveries-stuckDispute recovery transfersOlder than 24 h
7balance-refreshBalanceSnapshotService.refreshAll() — full re-pull of every balance account from AdyenTotal failure ⇒ rollup DEGRADED (note: re-run manually once Adyen recovers)
8reserve-retargetReserve.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 scheduling / config ledger sweeps Adyen re-pull clean rollup alerting rollup
The run audits Ummah's own ledger and re-pulls balances — it never fetches Adyen's settlement or accounting report files, so a systematic error present in both the webhook feed and the local ledger would pass every check.
VerdictA disciplined internal audit with a real rollup and a loud alert — but a closed loop. Until the PR-12 report ingestion lands (Section 7), "OK" means "consistent with ourselves and with Adyen's webhooks", not "consistent with Adyen's books".

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

Adyen webhooks TransferEventService · worker Ops · back-office panel

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 alarm-dedupe guard matters operationally: Adyen redelivers webhooks at-least-once, and without it every redelivery of an already-judged leg set would page the admins again.

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.

VerdictRefusing to auto-correct is the right posture for a money system: a mismatch means either the platform's maths or Adyen's booking is wrong, and guessing which would silently move money. Loud once, persistent forever, human-resolved — correct.

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.

Endpoints
Method · pathGuardPurpose
POST /api/admin/reconciliation/runRequireScope('STAFF') + merchant.writeEnqueue a trigger:'MANUAL' run on QUEUE_RECONCILIATION; hour-bucketed jobId collapses duplicates.
GET /api/admin/reconciliation/summaryRequireScope('STAFF')Mismatched count · awaiting over 15 min · clean in the last 30 days.
GET /api/admin/reconciliation/mismatchesRequireScope('STAFF')Up to 100 transactions with the splitsMismatch payload (both sides + diffs).
GET /api/admin/reconciliation/awaitingRequireScope('STAFF')Mirrors written but not yet judged (splitsReconciledAt null).
GET /api/admin/reconciliation/runs · /runs/latestRequireScope('STAFF')Run history and the latest rollup with its checks JSON.

Data model

  • Transaction: splitsApplied Json? · splitsReconciledAt DateTime? · splitsMismatch Json? · feeMethod
  • AdyenTransferLeg: unique adyenTransferId, category, platformPaymentType, pspPaymentReference, amountMinor, currency, balanceAccountId, direction, status
  • ReconciliationRun: trigger, rollup status, checks JSON, mismatchCount, notes, finishedAt
  • Reserve: heldMinor, rollingPercentBps, minHoldMinor, holdDays

Queues & delivery guarantees

  • QUEUE_RECONCILIATION: repeatable 0 2 * * * UTC, jobId reconciliation-daily
  • webhook-in.adyen: carries every balancePlatform.transfer.* event; ingress dedupes via AdyenWebhookEvent.dedupeKey (bp: + sha256 of the raw body), jobId = sha256(dedupeKey), processedAt re-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.

P0

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).

P0

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).

P0

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.

P1

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.

P1

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.

P1

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()).