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:
| Field | Type | Purpose |
|---|---|---|
name | string | Human label, e.g. "Acme cross-border strict rule". |
paymentType | stablecoin | bank | cross_border | other | null | Scopes the rule to a payment category. |
sourceType | ImportSourceType | null (excludes api_expectation) | Scopes the rule to an evidence source, e.g. psp_report, bank_statement. |
isActive | boolean | Inactive rules are ignored by lookups. Defaults to true. |
amountTolerance | decimal string | null | Absolute tolerance — see below. |
timeWindowMinutes | int | null | Candidate-matching time window — see below. |
expectedCompletionMinutes | int | null | Broad expectation for the whole route to complete. |
delayedSettlementThresholdMinutes | int | null | Point after which unresolved movement is treated as delayed (timing_delay). |
sourceSlaMinutes, intermediaryInSlaMinutes, transferSlaMinutes, intermediaryOutSlaMinutes, destinationSlaMinutes | int | null | Per-phase SLA budgets. |
allowProviderIdMatch | boolean | Enables provider_id match strategy. Default true. |
allowTxHashMatch | boolean | Enables tx_hash match strategy. Default true. |
allowReferenceExactMatch | boolean | Enables reference_exact match strategy. Default true. |
allowAmountAndTimeWindowMatch | boolean | Enables amount_and_time_window match strategy. Default true. |
requireAllRequiredLegs | boolean | Whether every reconciliationScope: required leg must be present before the case is considered complete. Default true. |
metadata | JSON | null | Free-form rule context. |
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:
explainedDelta = providerFee + networkFee + developerFee + fxSpread + roundingDeltaunexplainedDelta = (expected.amount − actual.amount) − explainedDelta- compares
|unexplainedDelta|againsttolerance
result | When |
|---|---|
missing_actual | No actual amount yet — unexplainedDelta = expectedAmount. |
currency_mismatch | expected.currency !== actual.currency. Amounts are never subtracted across currencies — flagged for FX handling instead. |
matched | |unexplainedDelta| <= tolerance. |
mismatch | |unexplainedDelta| > tolerance. |
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:
- If the case’s
PaymentIntent.paymentTypeis set, look for the most-recently-updated active rule with thatpaymentTypeand a non-nullamountTolerance. - Otherwise (or if none found), fall back to the most-recently-updated active rule with any
non-null
amountTolerancefor the organization. - If neither exists,
toleranceisundefined—reconcile()defaults it to"0", i.e. an exact match is required.
Match strategies
The four booleanallow*Match flags correspond to MatchType enum values used on MatchLink:
| Flag | MatchType | Identifier source |
|---|---|---|
allowProviderIdMatch | provider_id | FlowLeg.providerTransferId looked up against PaymentIntentReference{ type: 'provider_transfer_id' }. |
allowTxHashMatch | tx_hash | FlowLeg.txHash looked up against PaymentIntentReference{ type: 'tx_hash' }. |
allowReferenceExactMatch | reference_exact | A FlowLegReference{ type: 'external_reference' } (or a reference recovered from the raw payload’s event_object.client_reference_id / externalReference) looked up against PaymentIntent.externalReference. |
allowAmountAndTimeWindowMatch | amount_and_time_window | Reserved for fuzzy fallback matching when no exact identifier is available — manual_override is also a valid MatchType for human-applied links. |
buildMatchCandidateIdentifiers() constructs the ordered candidate list
(provider_id → tx_hash → reference_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.
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 withreconciliationScope: 'required'.requiredLegsPresent: of those, how many havestatus: '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:
- Scope —
required(0) beforeoptional(1) beforeignored(2, but these were already excluded). - Phase —
destination(0) is preferred, thenintermediary_out,transfer,intermediary_in,source(4), then unspecified (5). The leg closest to the end of the route wins. - Status —
confirmed(0) >pending(1) >missing(2) >reversed(3) >failed(4). - Sequence — higher
sequencenumbers win (most-downstream leg in a route group). - Recency — later
occurredAtwins as a final tiebreaker.
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:timeWindowMinutesis 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.”
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:
- exact rule for
organizationId + paymentType + sourceType - fallback rule for
organizationId + paymentType - fallback rule for
organizationId + sourceType - org default rule with only
organizationIdset
timing_delay.
Example rules
A strict cross-border rule for PSP-reported evidence:Core Concepts
The objects rules operate on: PaymentIntent, FlowLeg, ReconciliationCase.
List reconciliation rules
API reference for
/v1/reconciliation-rules.