> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reconlayer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reconciliation Rules

> How ReconLayer matches evidence to expectations, evaluates amount deltas, and tracks SLAs — and how to configure it per organization.

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`](/api-reference/reconciliation-cases/list-reconciliation-rules).

<Note>
  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`.
</Note>

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

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:

```ts theme={null}
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:

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

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 `undefined` — `reconcile()` 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`:

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

<Warning>
  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.
</Warning>

## 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. **Scope** — `required` (0) before `optional` (1) before `ignored` (2, but these were already
   excluded).
2. **Phase** — `destination` (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. **Status** — `confirmed` (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:

```json theme={null}
{
  "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:

```json theme={null}
{
  "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
}
```

<CardGroup cols={2}>
  <Card title="Core Concepts" icon="layer-group" href="/architecture/core-concepts">
    The objects rules operate on: PaymentIntent, FlowLeg, ReconciliationCase.
  </Card>

  <Card title="List reconciliation rules" icon="list" href="/api-reference/reconciliation-cases/list-reconciliation-rules">
    API reference for `/v1/reconciliation-rules`.
  </Card>
</CardGroup>
