A real reconciliation example, before any architecture talk.
We have an invoice from Northline Logistics for $1,260.00, due April 15. Our AP team initiates an ACH payment on April 14. The bank descriptor that comes back, three business days later, looks like this:
date: 2026-04-17
amount: -1234.50 USD
descriptor: ACH STRIPE PAYOUT po_3PqR9K2eZvKYlo2C
A naive matcher comparing 1260.00 to 1234.50 says no match. A naive matcher comparing "Northline Logistics" to "STRIPE PAYOUT" says no match. A naive matcher checking the date — April 14 vs. April 17 — sees a 3-day gap (which is normal for ACH but still a "miss" by exact comparison).
The actual answer is: yes, this is the payment. Stripe routed the ACH for us, took a $25.50 fee, and the payout reference (po_3PqR9K2eZvKYlo2C) ties back to a payment record we already have in the system. The two-business-day gap is the standard ACH settlement window. Every signal individually looks weak; together, they're decisive.
That's the entire shape of the Cadense reconciliation engine. Single signals don't survive contact with real bank feeds. Multiple independent signals, combined with explicit thresholds, do.
TL;DR
- Reconciliation is built on five independent signals: amount, date, identity, history, topology. None of them on their own can drive an auto-match.
- Text normalization is aggressive — accents stripped, legal entity suffixes (Inc/LLC/Ltd) removed, whitespace and punctuation collapsed — so "Acme Office Supply, LLC" and "ACME OFFICE SUP" share most of their tokens.
- Weekend/holiday grace days account for banking realities. A payment due Friday that settles Monday is zero banking days late.
- Auto-match requires combined confidence ≥
0.72and no ambiguity within0.05of the next-best candidate. Otherwise it becomes a suggestion for review.- Exceptions are first-class: 15 distinct categories with severity and recommended action.
The three brittle approaches we rejected
Before settling on multi-signal scoring, we built (and discarded) each of these:
| Approach | Why it fails in real AP |
|---|---|
| Pure rules ("if descriptor contains X, match payment Y") | Ossifies the moment your bank changes descriptor format. Doesn't generalize to new vendors. Maintenance burden grows with vendor count. |
| Pure amount matching with date tolerance | Defeated by processor fees, FX, partial payments, batched settlements. False positives across same-amount payments to different vendors. |
| Pure ML / LLM matcher | Opaque. Cannot explain why A matched B and not C. Unacceptable for a controller signing off on the reconciliation. |
The middle path — multiple independent signals, a rules layer for tweaks, a clear exception taxonomy, and explicit thresholds — is what survived.
What the engine reasons over
Three datasets have to land in the same shape before matching starts:
- Invoices — what we owe, to whom, when.
- Payments — what we initiated. State machine:
pending,submitted,succeeded,failed,returned,voided. - Bank transactions — what the bank actually saw clear, with whatever descriptor the rail gave us.
Invoices, payments, and bank transactions normalize into candidate match shapes. Each candidate scores across amount, date, identity, history, and topology before thresholding routes it to auto-match, suggestion, or exception review.
The orchestration entry point in packages/ap-reconciliation/src/reconcile.ts looks roughly like this:
export async function reconcileWorkspace(
workspaceId: string,
period: { from: Date; to: Date },
options: ReconcileOptions
): Promise<ReconciliationResult> {
const settings = await loadSettings(workspaceId)
// Extend the payment lookback by dateToleranceDays so a transaction // settling on Apr 2 can match a payment initiated Mar 28.const paymentHorizon = subDays(period.from, settings.dateToleranceDays)
const [invoices, payments, bankTxns] = await Promise.all([
loadInvoices(workspaceId, period),
loadPayments(workspaceId, paymentHorizon, period.to),
loadBankTransactions(workspaceId, period),
])
return analyzeReconciliation({
invoices, payments, bankTxns, settings,history: await loadFeedbackHistory(workspaceId),
rules: await loadRules(workspaceId),
})
}The paymentHorizon extension matters more than it sounds: it lets a bank transaction settling on April 2 match a payment that was initiated on March 28, which happens constantly and breaks naive period-bounded matchers.
The five signals — applied to the Northline example
For every (bank transaction, candidate payment, candidate invoice) tuple, the engine computes five independent scores. Let's walk each one through the real example from the top.
Signal 1: Amount
Compares the bank transaction amount (-1234.50) to the expected amount (payment amount 1260.00). The variance is 25.50.
Tolerance is configurable two ways:
- Cardinal —
amountToleranceMinor, default2000(~$20 in USD minor units). - Percentage — e.g., 5% of the payment amount.
The matcher uses whichever is more permissive at the candidate's amount. 25.50 exceeds the $20 cardinal tolerance, so by default this would fail amount-only matching. But there's a Stripe fee rule active that allows fee-adjusted matches up to maxFeeToleranceMinor (default 5000 or 1%, whichever is lower). The variance falls inside that tolerance and is annotated fee_adjusted. Amount score: 0.91.
Signal 2: Date
Compares the payment date (April 14) to the bank settlement date (April 17). Calendar delta: 3 days. April 14 is a Tuesday, April 17 is a Friday — no weekend crossed in this case. ACH settlement of 2–3 business days is well within dateToleranceDays (default 5). Date score: 0.94.
If the gap had crossed a weekend, the engine would add weekendHolidayDateGraceDays to the tolerance — a payment due Friday settling Monday is zero banking days late.
Signal 3: Identity
This is the hardest signal and the one that consumed the most code. The engine compares vendor names from the invoice/payment side (Northline Logistics) to the descriptor on the bank side (ACH STRIPE PAYOUT po_3PqR9K2eZvKYlo2C).
Three sub-mechanisms:
- Aggressive text normalization.
normalizeTextForReconciliationstrips accents (café→cafe), lowercases, removes punctuation, collapses whitespace, and drops legal entity suffixes (Inc,LLC,Ltd,S.A.,GmbH). After that, "Acme Office Supply, LLC" and "ACME OFFICE SUP" share most of their tokens. - Token Jaccard + substring containment. We compute Jaccard similarity over normalized tokens and check whether one string contains the other. Either signal can lift the identity score.
- Trace token extraction. Bank descriptors often embed identifiers — ACH IMAD/OMAD, wire references, check numbers, processor payout IDs (
po_*for Stripe,SET_*for Adyen). The engine extracts these via regex and boosts identity when they match a known payment.
For our example, vendor name overlap between "Northline Logistics" and "STRIPE PAYOUT" is zero. But the descriptor contains po_3PqR9K2eZvKYlo2C, which we recognize as a Stripe payout ID, and that maps directly to the payment record we initiated. Identity score: 0.86 — driven entirely by the trace-token match.
If the trace token hadn't been there, identity would have scored near zero and the candidate would have failed the combined threshold. This is by design: weak identity has to be salvaged by some other concrete signal (a unique reference, an exact amount, a confirmed history alias) — never by date plus amount alone.
Signal 4: History
If the workspace has prior reconciliation history, the engine uses it. A user-confirmed match between vendor X and bank descriptor pattern Y becomes a learned alias that boosts future matches between the same pair (up to a max contribution of 0.9). A user-rejected suggestion does the opposite — penalizes future matches with the same shape.
For our example, suppose this workspace has previously reconciled three Northline invoices via Stripe payouts. The history boost fires, contributing +0.05 to the combined confidence.
This is the only place the engine "learns," and it learns cautiously. The history boost only fires when there's a structural match (same vendor, similar descriptor, same rail). It doesn't try to generalize.
Signal 5: Topology
Topology asks: what's the structure of this match? Some shapes are inherently more confident than others.
| Topology | Score modifier | Notes |
|---|---|---|
| One-to-one (single payment, single bank transaction, single invoice) | Highest | The clean case |
| One-to-many (one payment covers multiple invoices) | High | Confident if amounts add up |
| Many-to-one (multiple payments aggregated into one settlement) | Medium | Typical for processor net settlements |
| Many-to-many | Flagged | Surfaces as a suggestion regardless of score |
| Fee-adjusted | High | Allowed if a fee rule is present |
| Cross-currency | Flagged | Allowed only with explicit FX rates |
Our Northline example is one-to-one with a fee adjustment. Topology score: 0.90.
Combining the signals into a decision
Each signal returns a score in [0, 1]. They're combined into a single confidence by a weighted sum (with weights expressed in settings.ts). For our Northline example, the combined score lands around 0.90. Above the auto-match threshold, no ambiguity, no rule blocking it — auto-matched.
Two thresholds drive the decision:
minAutoMatchConfidence(default0.72) — above this and not blocked by a rule, the match is auto-applied.minSuggestConfidence(often the same, sometimes lower) — above this but not auto-matched, the candidate becomes a suggestion that surfaces in the reconciliation queue.
There's also an ambiguityMargin (default 0.05). If the top two candidates score within that margin of each other, the match is flagged as ambiguous and demoted to a suggestion regardless of how high the top score is. We'd rather a controller pick between two near-identical options than have the engine guess.
The three modes
Workspaces can run the engine in one of three modes, which is just a preset of thresholds:
| Mode | Use it when | Auto-match threshold | Ambiguity margin |
|---|---|---|---|
| Balanced (default) | Steady-state operations | 0.72 | 0.05 |
| Strict | First weeks, audit prep | 0.85+ | 0.10 |
| Exploratory | Triaging an old account | 0.55 | 0.02 |
Strict mode is what most teams use during their first reconciliation cycle. Once they trust the engine on their data, they drop back to balanced.
The exception taxonomy
A reconciliation run produces matches and suggestions, but it also produces exceptions — situations the engine recognizes as not-quite-a-match-but-worth-knowing-about. The taxonomy from types.ts:
| Exception | Trigger |
|---|---|
missing_bank_transaction | Payment with no bank counterpart yet |
unmatched_bank_outflow | Bank transaction we can't tie to anything |
amount_mismatch | Close on identity and date, off on amount beyond tolerance |
currency_mismatch | Payment in one currency, bank settlement in another, no FX configured |
duplicate_payment | Two payments for the same invoice |
vendor_mismatch | Strong amount/date evidence, vendor names don't reconcile |
payment_failed_or_returned | Bank explicitly returned the payment |
stale_pending_payment | Pending longer than stalePendingDays (default 30) |
date_outside_tolerance | Match would be valid except date is too far off |
ambiguous_match | Multiple high-confidence candidates within ambiguity margin |
fee_unexplained | Amount variance that looks like a fee but no fee rule covers it |
partial_payment | Bank cleared less than the invoice amount |
overpayment_unapplied | Bank cleared more than the invoice; no credit applied |
duplicate_bank_outflow | Two bank transactions look like the same payment was sent twice |
unsupported_cross_currency | Cross-currency match attempted without FX rates |
Each exception has a severity and a recommended action, so the AP team gets a queue that's already triaged.
Persistence and dry runs
The match record stores linked invoices, payments, bank transactions, signal scores, topology, and status. Rules and reviewer feedback sit beside the match so future runs can explain why confidence moved.
Matches and suggestions are grouped by their structure (the set of invoices/payments/bank transactions they touch) and persisted to the database. Subsequent runs can detect whether a previously-suggested match has been confirmed, modified, or rejected.
The engine also supports a dry-run mode (persist=false) that returns the same outputs without writing — useful for previewing the effect of a tolerance change before committing.
Where the engine doesn't learn (yet)
Some honest limits:
- The five signal weights are hand-tuned, not learned. No gradient descent. If your business has a quirk that should re-weight the signals, you write a rule.
- Identity matching is token-based. Jaccard + substring containment goes a long way, but semantic similarity (synonyms, abbreviations, transliterations) isn't in the box. If your vendor master spells a vendor differently from how the bank does, an alias is the lever — not the matcher.
- History needs volume. The history-based boost only fires when there's a prior match for the same (vendor, descriptor pattern, rail) shape. New vendors fall back to signal-only scoring.
- Stale-pending threshold is global.
stalePendingDaysis per-workspace, not per-vendor or per-rail. A wire that sits pending 25 days might be normal; an ACH that sits 25 days is broken. Treating both with the same threshold is a known compromise.
Why this shape
The five-signal model exists because the alternatives don't survive contact with real bank feeds. A pure rules engine ossifies. A pure ML matcher can't explain itself. The middle path — multiple independent signals, a rules layer for tweaks, a clear exception taxonomy, and explicit thresholds — is what we ended up trusting.
The reconciliation engine is part of a larger AP brain. It shares the cached vendor and category indexes with the categorization engine, and feeds reconciled spend into the spend anomaly detector. Each of the three was designed to fail the same way: drop into a lower-confidence state and surface for review, rather than silently produce a wrong answer.
If you want to read the matcher itself, the entry point is analyzeReconciliation in packages/ap-reconciliation/src/matcher.ts. The signals fan out from there.
FAQ
How does Cadense handle weekend and holiday delays in payment settlement?
When the date delta between a payment and its bank settlement crosses a weekend, the engine adds extra grace days configured by weekendHolidayDateGraceDays. A payment due Friday that settles Monday is exactly 3 calendar days late but zero banking days late, and the engine knows the difference.
What confidence threshold does the engine require to auto-match?
minAutoMatchConfidence defaults to 0.72. Above that and not blocked by a rule, the match is auto-applied. Between 0.55 and 0.71 it becomes a suggestion that surfaces in the reconciliation queue. The thresholds are configurable per workspace and there are three preset modes: balanced, strict, and exploratory.
How does the engine handle Stripe and Adyen processor net settlements?
Processor settlements that bundle many transactions into a single payout are flagged as high-risk topology and require explicit opt-in via allowProcessorNetSettlement. The engine extracts payout IDs (po_*, SET_*) from the bank descriptor and matches them against settlement records.
Does the engine learn from user corrections?
Yes, but cautiously. When a user confirms or rejects a suggestion, that becomes feedback for future matches with the same (vendor, descriptor pattern, rail) shape — boosting confidence on future similar pairs and penalizing future similar candidates that the user previously rejected. It does not generalize beyond exact structural matches.
What happens if two candidates score within a few points of each other?
The engine demotes both to suggestions. ambiguityMargin defaults to 0.05 — if two candidates are within that margin, the match is flagged as ambiguous and surfaced for user review rather than auto-matched. We'd rather a controller pick between two near-identical options than have the engine guess.
What kinds of exceptions does the engine surface?
Fifteen categories including missing_bank_transaction, unmatched_bank_outflow, amount_mismatch, currency_mismatch, duplicate_payment, vendor_mismatch, payment_failed_or_returned, stale_pending_payment, ambiguous_match, fee_unexplained, partial_payment, overpayment_unapplied, duplicate_bank_outflow, and unsupported_cross_currency. Each carries a severity and a recommended action.