Skip to main content
A ReconciliationRule is a per-organization configuration row that controls three things: which match strategies are allowed, how much amount drift is tolerated before a case needs review, and how long the system should wait before treating a leg as delayed. Rules are managed via /v1/reconciliation-rules.
Today, the matcher applies a minimal tolerance lookup (described below), not the full priority-ordered rule-selection engine described in this page’s “Rule selection order” section. That fuller selection logic is the target design and is tracked separately — this page documents both what runs today and the configuration surface that already exists on ReconciliationRule.

Anatomy of a rule

ReconciliationRuleCreateRequest / ReconciliationRuleSnapshot fields: PATCH (reconciliationRuleUpdateSchema) accepts any subset of the above (at least one field), and omitted fields are left untouched — there are no defaults on the update schema so a partial PATCH can’t accidentally reset a field to its create-time default.

Amount tolerance — what actually runs today

reconcile() in @reconlayer/reconciliation-engine is the deterministic core. Given:
it computes:
  1. explainedDelta = providerFee + networkFee + developerFee + fxSpread + roundingDelta
  2. unexplainedDelta = (expected.amount − actual.amount) − explainedDelta
  3. compares |unexplainedDelta| against tolerance
and returns one of four results: All arithmetic is done on decimal strings with arbitrary precision (via bigint-backed helpers like addDecimalStrings, subtractDecimalStrings, compareDecimalStrings) — this matters because PaymentIntent/FlowLeg/ReconciliationCase amounts are Decimal(38,18) in Postgres, wide enough for 18-decimal stablecoin precision.

How tolerance is resolved today

resolveAmountTolerance() in apps/api/src/services/matcher.ts is explicitly documented as a minimal lookup, not the full priority engine:
  1. If the case’s PaymentIntent.paymentType is set, look for the most-recently-updated active rule with that paymentType and a non-null amountTolerance.
  2. Otherwise (or if none found), fall back to the most-recently-updated active rule with any non-null amountTolerance for the organization.
  3. If neither exists, tolerance is undefinedreconcile() defaults it to "0", i.e. an exact match is required.

Match strategies

The four boolean allow*Match flags correspond to MatchType enum values used on MatchLink: buildMatchCandidateIdentifiers() constructs the ordered candidate list (provider_idtx_hashreference_exact) from a leg’s providerTransferId, txHash, and resolved externalReference. findPaymentIntentCandidate() walks these candidates in order and returns the first one that resolves to an existing PaymentIntent with a ReconciliationCase.
The current matcher implementation builds candidate identifiers and checks them in priority order, but it does not currently gate each lookup on the corresponding allow*Match flag — the flags exist on ReconciliationRule as the configuration surface for this behavior. Treat them as the intended contract; verify against apps/api/src/services/matcher.ts if you depend on a flag disabling a specific strategy today.

Required legs and case completeness

shouldMatchLeg(reconciliationScope) returns false only for reconciliationScope: 'ignored'required and optional legs are both eligible for matching. The case summary reports:
  • requiredLegsTotal: count of legs with reconciliationScope: 'required'.
  • requiredLegsPresent: of those, how many have status: 'confirmed'.
requireAllRequiredLegs is the rule-level switch for whether a case can be tentatively reconciled before requiredLegsPresent === requiredLegsTotal.

Selecting which leg to evaluate

When multiple legs match the same case in one ingestion pass, evaluateMatchedCase() picks a single leg to compare against expectedAmount using selectCaseEvaluationLeg(). Only legs that (a) pass shouldMatchLeg and (b) have both amount and currency set are eligible. Among those, the engine sorts by:
  1. Scoperequired (0) before optional (1) before ignored (2, but these were already excluded).
  2. Phasedestination (0) is preferred, then intermediary_out, transfer, intermediary_in, source (4), then unspecified (5). The leg closest to the end of the route wins.
  3. Statusconfirmed (0) > pending (1) > missing (2) > reversed (3) > failed (4).
  4. Sequence — higher sequence numbers win (most-downstream leg in a route group).
  5. Recency — later occurredAt wins as a final tiebreaker.
The winning leg’s amount/currency becomes ReconciliationCase.actualAmount, and providerFee/networkFee/fxSpread are refreshed from the raw payload’s event_object.receipt.{developer_fee, gas_fee, exchange_fee} when present, falling back to the case’s existing values.

Matching window vs. SLA — two different controls

These fields look similar but mean different things:
  • timeWindowMinutes is a matching tolerance — “accept this leg as a candidate match for this expectation if it occurred within N minutes of [a reference time].”
  • SLA fields (expectedCompletionMinutes, delayedSettlementThresholdMinutes, sourceSlaMinutes, intermediaryInSlaMinutes, transferSlaMinutes, intermediaryOutSlaMinutes, destinationSlaMinutes) are operational timing rules — “how long should the system wait before flagging this as delayed or at risk.”
Example: a SWIFT payout might set destinationSlaMinutes: 4320 (3 days) — the system can wait up to 3 days for the destination leg before raising timing_delay, but that’s unrelated to whether a given bank-statement row falls inside the matching timeWindowMinutes.

slaRisk on the case summary

Independent of any rule, ReconciliationCaseSummary.slaRisk is computed as: the case has status: 'open' and slaAgeMinutes > 240 (a fixed 240-minute threshold), where slaAgeMinutes is the age of the case from createdAt to now (or to reconciledAt/updatedAt once the case is no longer open).

Rule selection order (target design)

docs/reconciliation-ingestion-flows.md specifies the intended priority order for selecting a rule, most-specific first:
  1. exact rule for organizationId + paymentType + sourceType
  2. fallback rule for organizationId + paymentType
  3. fallback rule for organizationId + sourceType
  4. org default rule with only organizationId set
Once selected, the rule determines: which match strategies are allowed, how much amount drift is acceptable, the matching time window, which legs are required vs. optional, and how long to wait before raising timing_delay.

Example rules

A strict cross-border rule for PSP-reported evidence:
A tolerant bank-statement rule that allows fuzzy matching and doesn’t require every leg:

Core Concepts

The objects rules operate on: PaymentIntent, FlowLeg, ReconciliationCase.

List reconciliation rules

API reference for /v1/reconciliation-rules.