Skip to content
Last updated: Sep 25, 2026

Run the Reconciliation Matching Engine ​

OwnerClassificationVersionEffectiveNext reviewStatus
Platform EngineeringInternal0.22026-09-012026-12-01Draft

Purpose. Operating guide for the reconciliation matching engine — how matched output becomes reconciliation work owned by a preparer, the bridge that makes that link, and the match-status semantics that are easy to read wrong. Rule-level detail (per-config rules, thresholds, field mappings) is reference material and is not repeated here.

The chain (matching → account → preparer) ​

Matching output, reconciliation items, and preparer assignment live in different tables joined by one bridge. Miss the bridge and matching looks account-less.

finbase.matching_output              ← engine output (per source↔target candidate)
   │  matchtype ∈ {Automatic, Manual, Suggested, Partial}   ← the REAL status
   │  matchstatus ∈ {Matched, Unmatched}                    ← coarse (see semantics)
   │  matching_config_id · sourceentity · sourceid
   ▼
reconciliation.openitemtransitionrule    ← THE BRIDGE (matchingconfigid ↔ accountid)
   │  accountid · matchingconfigid · sourceentity · glmapping · field mappings
   ▼  (applied by the transitionopenitems workflow)
reconciliation.reconciliationitem        ← open items landed on an account
   │  accountid · matchingconfigid · matchingitemid · istransitioneditem
   ▼
reconciliation.periodsummary             ← the account × period reconciliation
   │  accountid · preparerid
   ▼
PREPARER   (periodsummary.preparerid → finbase.businessuser → sys.user)

The account is stamped onto each reconciliation item from the transition rule (accountid = rule.accountid) — the rule is the matching_config ↔ account declaration. There is no other place that link is made.

Two feeds into reconciliation items — and which one runs today ​

Don't conflate these. An open item reaches a reconciliation by one of two paths:

  • A · Direct import — the path that runs today. Open items are loaded via reconciliationitemimport (or seed). They carry no matching linkage (matchingconfigid NULL, istransitioneditem unset, createdby NULL). Every current reconciliationitem came this way.
  • B · Matching transition — the newer, not-yet-wired feed. matching_output → transitionopenitems (via openitemtransitionrule) → reconciliationitem stamped with matchingconfigid/accountid. Needs the bridge (0 rules today), so it does not run — which is why the chain above is severed and every item's matchingconfigid is NULL.

So the matching engine and the reconciliation open items are, today, two separate worlds: matching output never becomes reconciliation work.

Open-item lifecycle & rollover ​

  • A reconciliationitem is one row: opendate → (open) → closedate, with itemeffectiveperiod. There is no periodid — the dates are the period linkage.
  • Rollover = persistence, not copy. An open item (closedate NULL) stays open across periods, ageing by opendate (agingbucket), until it is cleared/closed. There is no per-period duplication — the same row appears in every period it is open. (The period-maintenance workflows have no "copy to next period" step, consistent with this persist-until-closed model.)
  • The "rolled-over open items" a preparer sees are therefore the reconciliationitem rows — already reflected in the Unreconciled figure (period + preparer scoped via periodpreparermetrics), not in matching counts.

Trigger ​

MethodDetail
MatchingThe recon matching workflow produces finbase.matching_output for a matching_config (applicationcode in recon/reconhub).
Transitiontransitionopenitems (Temporal, reconciliationwf) reads an openitemtransitionrule, pulls its config's matchstatus='Unmatched' output, and writes reconciliationitem rows stamped with the rule's accountid.
Assignmentsetupreconciliation builds periodsummary rows for a period and carries the preparer/reviewer/approver assignment (from accountsettings).

The wfw worker and Temporal must be running; triggers async-start and return a handle, not a result.

Stages ​

  1. Match — the engine writes matching_output, one row per source↔target candidate.
  2. Transition — transitionopenitems moves Unmatched (open) items onto an account as reconciliationitem rows (auto-matched items are cleared and are not transitioned).
  3. Reconcile / certify — the assigned preparer works the account's items; review and certification follow.

These fail green — matching succeeds and the data simply never reaches a preparer.

RequirementSymptom if missing
openitemtransitionrule seeded with accountid for each recon matching_configmatching_output fills, but reconciliationitem.accountid/matchingconfigid stay NULL — matching is account-less and cannot roll up to a preparer
transitionopenitems actually run after matchingopen items never appear on the account's reconciliation
matching_config.source_entity resolves via tabledefinition.targettablethe transition activity throws (no source table to read)
preparer assigned on periodsummary/accountsettingsitems land on an account owned by no one

The bridge (openitemtransitionrule.accountid) is an onboarding config decision — it is not something a workflow invents. Seeding matching alone leaves the chain severed at the bridge.

Match-status semantics (read before counting) ​

  • matchstatus is coarse — only {Matched, Unmatched}. The real status is in matchtype: Automatic > Manual > Suggested > Partial > else Unmatched.
  • A Suggested match is deliberately stored as matchstatus='Unmatched' + matchtype='Suggested', N rows per source (one per candidate targetid, sharing a matchsetid). So count(matchstatus='Unmatched') overstates breaks — suggestions are candidates awaiting review, not exceptions.
  • Canonical derivation exists — matching_output.service.ts :: getEntityStatusDistribution collapses each source via MAX(CASE … matchtype …), with Matched = All − Unmatched. Always derive from matchtype, never from raw matchstatus.
  • Open vocabulary decisions are tracked in blitz#1380 (the mo_unmatched_unique partial index omits targetid and rejects legitimate 1:N suggestion sets; the prisma comment claiming matchstatus ∈ {auto_matched, suggested, no_match} is stale). Until resolved: derive from matchtype.

Preparer-scoped rollup (the intended query) ​

getEntityStatusDistribution is scoped per (matching_config, sourceentity). A preparer/period rollup joins through the bridge:

preparer's periodsummaries (preparerid, periodid)
  → accountid
  → openitemtransitionrule.accountid ⇒ matchingconfigid       (the bridge)
  → finbase.matching_output (matching_config_id)
  → aggregate by matchtype  (Automatic / Suggested / Unmatched / …)

Blocked only on the bridge being seeded (needs the accountid each recon config reconciles). Without it, matching can only be surfaced engine-level (by config / applicationcode), not per preparer.

matching_output has no period either — it's run-scoped ​

Beyond the missing account link, matching_output carries no periodid — only run_id, run_timestamp, createdon. So an aggregate over it is all-time / all-runs cumulative (e.g. 2,039 Automatic spans all 3 runs), not "this close". Period scoping would need a run_id → period or sourceid → reconrecord.date → period mapping — neither exists cleanly today.

Consequence for dashboards: matching counts are neither period- nor preparer-scoped, so they do not belong on a period+preparer view (the preparer dashboard). They fit an engine / controller / admin surface where a tenant-wide "N auto-matched" is the right altitude. On the preparer dashboard, the real rolled-over open items are the Unreconciled figure (from periodpreparermetrics), which is period+preparer scoped — matching is not a substitute for it.

What's derivable (today / once wired / caveats) ​

The schema is sufficient to derive everything we'd want — it's a wiring question, not a model-shape problem.

Derivable today (already wired):

  • Open items per preparer / per period / by type / aged — reconciliationitem (accountid, opendate/closedate, itemeffectiveperiod) → periodsummary (accountid, preparerid). The opendate→closedate lifecycle is the rollover, so carry-forward + ageing fall out for free.
  • Preparer reconciled / unreconciled / auto-certified $ and % — periodpreparermetrics.

Derivable once the bridge is seeded (openitemtransitionrule + transitionopenitems):

  • Unmatched/suggested items attributed to an account + preparer (the transition stamps accountid + matchingconfigid and the item carries lifecycle dates → period- and preparer-scoped automatically).
  • Auto-match rate per account/preparer via the config↔account bridge (openitemtransitionrule.accountid ⇒ matchingconfigid ⇒ matching_output).

Caveats (true even when fully wired):

  1. Auto-matched items never become reconciliationitems (transitionopenitems transitions unmatched only). Attribute auto-matched through the config↔account bridge — a different join than open-items-per-preparer. Both work; not the same path.
  2. Period-scoping matching needs a convention — matching_output has no periodid. Transitioned items get period free (via opendate); auto-matched needs a run → period (or source-date → period) mapping. A decision, not a given.
  3. Granularity ceiling — one transition rule stamps one accountid per config. A config whose output spans many accounts (e.g. PAY2CREDIT across many customers) needs per-account rules or a mapping to split it, else everything lands on one account.

Net: derive whatever we need, provided we (a) seed the config↔account bridge, (b) adopt a run→period convention for auto-matched, (c) model configs↔accounts at the reporting granularity. None of these fight the schema.

Verify ​

sql
-- 1. engine output present, by REAL status (not raw matchstatus)
SELECT matchtype, matchstatus, count(*)
FROM finbase.matching_output GROUP BY 1,2 ORDER BY 3 DESC;

-- 2. is the bridge configured? (0 rows = severed chain)
SELECT count(*) FROM reconciliation.openitemtransitionrule WHERE isactive;

-- 3. did the transition stamp accounts onto items?
SELECT count(*) total,
       count(matchingconfigid) linked,
       count(accountid)        with_account
FROM reconciliation.reconciliationitem;

-- 4. can a preparer be reached from a matched item?
SELECT ps.preparerid, count(*)
FROM reconciliation.reconciliationitem ri
JOIN reconciliation.periodsummary ps ON ps.accountid = ri.accountid
WHERE ri.matchingconfigid IS NOT NULL
GROUP BY 1;

Current state (2026-08-29) ​

The demo/use-case data is seeded at the matching layer only:

  • finbase.matching_output ≈ 2,086 rows — Automatic/Matched 2,048 · Manual/Matched 6 · Suggested/Unmatched 32. Populated config: 31 PAY2CREDIT (reconhub, paymentadvice ↔ creditcardsales). Configs 27 RECON-BANK-GL / 28 RECON-SUB-GL exist but have 0 output (never run). matching_output spans 3 runs (run_id), so any aggregate is all-time.
  • reconciliation.openitemtransitionrule = 0 rows → bridge unconfigured.
  • reconciliation.reconciliationitem ≈ 284 rows, all open (closedate NULL), itemeffectiveperiod = 2026-07-31, none transitioned (istransitioneditem unset, createdby NULL) → direct-imported (path A), matchingconfigid all NULL. These are the real rolled-over open items; matching output has not reached them.

To make matching preparer-visible: seed an openitemtransitionrule (config → accountid), run transitionopenitems, confirm assignment on periodsummary.

Operational constraints ​

  • The wfw worker must be running. In development it bundles workflows from source on respawn; in production a changed reconciliationwf workflow requires build:workflow before deploy.
  • transitionopenitems transitions Unmatched items only — auto-matched items are terminal and never become reconciliation items; attribute auto-matched to a preparer via the config↔account bridge, not via reconciliation items.
  • How-to: Run the Cash Application Engine — shares finbase.matching_config / matching_output; same matchtype semantics.
  • Reference (blitz repo): src/libs/recon/docs/ — matching config JSON + seed SQL.

Revision history ​

VersionDateAuthorChange
0.12026-08-29Platform EngineeringInitial draft — matching→account→preparer chain, the transition-rule bridge, matchtype vs matchstatus (#1380), preparer rollup query, verification, current state.
0.22026-09-01Platform EngineeringAdded the two-feeds distinction (direct import vs matching transition) + open-item lifecycle/rollover (persist-until-closed, no periodid); documented that matching_output is run-scoped (no period) → matching counts belong on engine/controller views, not the period+preparer dashboard.

Finaisse Internal — Confidential. Access-restricted; not for external distribution.