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:
FieldTypePurpose
namestringHuman label, e.g. "Acme cross-border strict rule".
paymentTypestablecoin | bank | cross_border | other | nullScopes the rule to a payment category.
sourceTypeImportSourceType | null (excludes api_expectation)Scopes the rule to an evidence source, e.g. psp_report, bank_statement.
isActivebooleanInactive rules are ignored by lookups. Defaults to true.
amountTolerancedecimal string | nullAbsolute tolerance — see below.
timeWindowMinutesint | nullCandidate-matching time window — see below.
expectedCompletionMinutesint | nullBroad expectation for the whole route to complete.
delayedSettlementThresholdMinutesint | nullPoint after which unresolved movement is treated as delayed (timing_delay).
sourceSlaMinutes, intermediaryInSlaMinutes, transferSlaMinutes, intermediaryOutSlaMinutes, destinationSlaMinutesint | nullPer-phase SLA budgets.
allowProviderIdMatchbooleanEnables provider_id match strategy. Default true.
allowTxHashMatchbooleanEnables tx_hash match strategy. Default true.
allowReferenceExactMatchbooleanEnables reference_exact match strategy. Default true.
allowAmountAndTimeWindowMatchbooleanEnables amount_and_time_window match strategy. Default true.
requireAllRequiredLegsbooleanWhether every reconciliationScope: required leg must be present before the case is considered complete. Default true.
metadataJSON | nullFree-form rule context.
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:
type ReconciliationInput = {
  organizationId: string;
  paymentIntentId: string;
  expected: { amount: string; currency: string };
  actual?: { amount: string; currency: string };
  providerFee?: string;
  networkFee?: string;
  developerFee?: string;
  fxSpread?: string;
  roundingDelta?: string;
  tolerance?: string; // absolute amount tolerance; defaults to "0" (exact match)
};
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:
resultWhen
missing_actualNo actual amount yet — unexplainedDelta = expectedAmount.
currency_mismatchexpected.currency !== actual.currency. Amounts are never subtracted across currencies — flagged for FX handling instead.
matched|unexplainedDelta| <= tolerance.
mismatch|unexplainedDelta| > tolerance.
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:
FlagMatchTypeIdentifier source
allowProviderIdMatchprovider_idFlowLeg.providerTransferId looked up against PaymentIntentReference{ type: 'provider_transfer_id' }.
allowTxHashMatchtx_hashFlowLeg.txHash looked up against PaymentIntentReference{ type: 'tx_hash' }.
allowReferenceExactMatchreference_exactA FlowLegReference{ type: 'external_reference' } (or a reference recovered from the raw payload’s event_object.client_reference_id / externalReference) looked up against PaymentIntent.externalReference.
allowAmountAndTimeWindowMatchamount_and_time_windowReserved 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_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:
{
  "name": "Acme cross-border strict rule",
  "paymentType": "cross_border",
  "sourceType": "psp_report",
  "amountTolerance": "1.00",
  "timeWindowMinutes": 30,
  "expectedCompletionMinutes": 180,
  "delayedSettlementThresholdMinutes": 240,
  "sourceSlaMinutes": 120,
  "intermediaryInSlaMinutes": 30,
  "transferSlaMinutes": 20,
  "intermediaryOutSlaMinutes": 60,
  "destinationSlaMinutes": 4320,
  "allowProviderIdMatch": true,
  "allowTxHashMatch": true,
  "allowReferenceExactMatch": true,
  "allowAmountAndTimeWindowMatch": false,
  "requireAllRequiredLegs": true
}
A tolerant bank-statement rule that allows fuzzy matching and doesn’t require every leg:
{
  "name": "Beta bank and statement tolerant rule",
  "paymentType": "bank",
  "sourceType": "bank_statement",
  "amountTolerance": "5.00",
  "timeWindowMinutes": 1440,
  "expectedCompletionMinutes": 4320,
  "delayedSettlementThresholdMinutes": 5760,
  "destinationSlaMinutes": 4320,
  "allowProviderIdMatch": false,
  "allowTxHashMatch": false,
  "allowReferenceExactMatch": true,
  "allowAmountAndTimeWindowMatch": true,
  "requireAllRequiredLegs": false
}

Core Concepts

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

List reconciliation rules

API reference for /v1/reconciliation-rules.