Skip to content
Last updated: Sep 25, 2026

Invoice — 3-Way Matching, GL Coding & Assessment ​

OwnerClassificationVersionEffectiveNext reviewStatus
Platform EngineeringInternal1.02026-09-152026-12-15Draft

Purpose. Operating guide for the AP invoice pipeline — how an ingested document becomes a matched, GL-coded, assessed invoice that either auto-posts or lands in a processor's queue. It covers the transform → validate chain, the 3-way match contract and the two quirks that reliably break demos (vetted-PO and supplier linkage), GL coding (including the Non-PO hard-fail), the materiality + anomaly assessment leaf, and the edit-vs-notify resolution model. Rule-level thresholds and per-config field mappings are reference material and are not repeated here.

1. The pipeline (ingest → post) ​

The whole chain is orchestrated by processinvoice (Temporal, systemwf) — one execution per documentreceipt, fanning out over every extractedinvoice on that receipt in parallel.

#StageWhereWrites
1Ingestemail / SFTP / upload → documentreceiptsee Email Ingestion
2Extractextractdocument → extractedinvoice + extractedinvoicelineitems (Gemini; values land as text)extracted.* tables
3Insertprocessinvoice — canonical invoice, invoicelineitems, invoiceprocessingdata (IPD), stub invoiceprocessingmetrics (IPM) rowinvoice.*, IPD, IPM
4Routeinvoicehub_businessprocess ruleset → resolves the BPD (INVOICE_PO / INVOICE_NON_PO), else app defaultipd.businessprocessid
5Transformruninvoicetransformations — BPD-scoped processtransformationrule chain incl. matching, assessment, GL codinginvoice.*, IPM, intelligencelog
6Validateruninvoicevalidations — BPD-scoped validationrule chaininvoicevalidationchecks, ipd.exceptioncount/.validationscore
7Assign / ApproveAssignDocumentActivity + ApproveDocumentActivity (compiled sys.rule) → auto-clear / route / resolveipd.status/.action, approvalrequest
8Postauto-cleared invoices post immediately (⚠️ MockERP simulation today — stamps MOCK-POST-…)ipd.status='Posted', postedreferenceno

Stages 5–7 are skippable via ProcessInvoiceStages flags so the FE Re-extract / Re-validate paths can re-run a subset without re-inserting rows.

  • Evidence: packages/system/systemwf/src/workflows/processinvoice/workflow.ts (orchestration, stage flags, idempotency guard, posting sim); .../runinvoicetransformations/workflow.ts; .../runinvoicevalidations/workflow.ts.
  • ⚠️ Posting is simulated. Stage 8 writes a MockERP reference — there is no real ERP PostDocumentActivity yet. Routed (human-approver) invoices do not auto-post; the approver→post step is not wired.

2. Status model (4-axis) ​

The taxonomy is deliberately split so queues and lifecycle don't collide.

AxisFieldValues (typical)
Lifecycleipd.statusCaptured → Extracted → Validated → Approved → Posted
Workflow gateipd.actionUnassigned → Assigned → Pending Approval → Approved → (null when terminal) → On Hold
Assignmentassignedtoid / assignedteamiduser vs team
Exceptionsipd.exceptioncount / .validationscoreHARD (Failed AND accept≠true) count; score = pass ratio

Auto-approve gate: materialitytier=Low AND exceptioncount=0 → auto-clear → post. Any hard failure keeps it with the processor (action=Assigned, a resolve decision) rather than routing to an approver.

3. The 3-way match contract (⚠️ the demo-breakers live here) ​

Matching runs inside runinvoicetransformations as ordered processtransformationrule services. The match legs:

LegService (transform rule)Compares
PO linkPO Identificationinvoice.ponumber → purchaseorder, stamped into invoiceprocessingdata_purchaseorder
Line ↔ POPO Line Item Matchinginvoice line ↔ polineitems → invoicelinematching
2-way price2 Way PO Amount Validation (validation)invoice line price vs PO price
3-way qty3 Way Qty Matching (validation)invoiced vs received (grnlineitems via invoicelinematching)
PO coveragePO Quantity Coverage Check (validation)cumulative invoiced qty ≤ PO qty

Quirks that bite demos — call these out before every dry run:

  • ⚠️ Only VETTED POs match. An arbitrary PO number that isn't a properly seeded/vetted PO yields 0 matches — the invoice presents as Non-PO or match-less even though a PO number is on the face.

  • ⚠️ Supplier must equal the PO's supplier. If the invoice supplier doesn't resolve to the same supplier as the PO, the PO linkage silently breaks and the line match produces nothing.

  • ⚠️ matches=0 can be a false negative — a wfw backlog. The match writes only after the transform child workflow completes. If the Temporal worker is behind, an invoice can look unmatched purely because the run hasn't caught up. Check the worker before concluding a data problem.

  • ◯ GRN is cached. Goods-receipt data feeds the qty leg from a cached masters snapshot (cacheinvoicemasters → default:/invoiceprocessing/masters/all.duckdb). A newly-loaded GRN won't be seen until the cache is refreshed — reset/re-cache before expecting a 3-way to close.

  • Re-run safety: PO Line Item Matching deletes prior invoicelinematching rows for the IPD before writing fresh (#162) so re-runs don't double-count and produce an "over-consumed" false negative.

  • Evidence: runinvoicetransformations/workflow.ts (service switch, line-match clear); runinvoicevalidations/workflow.ts (2-way/3-way/coverage cases); invoicea/services/invoice/match-reasons.ts (variance scopes).

  • ⚠️ Unverifiable in-repo: the match processor scripts themselves are bucket-hosted (serviceregistry.customruleprocessor → DuckDB SQL, uploaded to the object store), not in the git tree. The vetted-PO/supplier scoping is observed pipeline behaviour (and the demo-generator contract), not a readable in-repo join. Treat those two bullets as behavioural, not line-cited.

4. GL coding (⚠️) ​

GL coding runs as the GL Code Identification transform, writing invoiceglmapping allocations (template breakup or supplier-history).

PathBehaviour
PO invoice✅ each matched line inherits the PO's glcode (GL coder, "Step P", reads invoicelinematching).
Non-PO invoice⚠️ hard-fails if no template/history yields a full 100% allocation — GL Coding Validation then fails and gates auto-clear. Non-PO is the coding-sensitive path.
Manual override✅ any invoiceglmapping.iscustom=true row makes the auto-coder skip entirely — manual coding wins; auto-coding is not re-applied on re-validate.
Auto re-runauto rows (iscustom=false) are deleted before re-writing (#299) so a pipeline re-run doesn't stack allocations past 100%.
  • ⚠️ save→revalidate is not fully wired. Editing GL coding on the detail page should re-run validation on save; today that round-trip is incomplete (the auto-coder guard exists, but the FE save→re-validate loop is not end-to-end). Open item — see Document-Receipt Remediation.
  • Evidence: runinvoicetransformations/workflow.ts lines ~790–880 (manual-coding guard, auto-row clear, intelligencelog gl-coding row).

5. The assessment leaf (materiality + anomaly) ✅ shipped 2026-09-13 ​

Runs as additional processtransformationrule services insideruninvoicetransformations, each an UPDATE onto the pre-created IPM row (autoGenerate:false). Order matters — anomaly detectors merge into fraudriskreasons, then the score writer aggregates.

ServiceWrites on invoiceprocessingmetrics
Materiality Classificationmaterialitytier (tier from finbase.materialityconfig)
Anomaly Round Amountmerges a flag into fraudriskreasons (JSON array)
Anomaly New Supplier High Valuemerges into fraudriskreasons
Anomaly Materiality Jumpmerges into fraudriskreasons
Anomaly Bank Account Changemerges into fraudriskreasons
Anomaly Duplicate Similarmerges into fraudriskreasons (composite fuzzy dup)
Fraud Risk Score Writeraggregates weights → fraudriskscore (0–100) and emits one finbase.intelligencelog row (processtype='invoice-anomaly') per flagged invoice
  • ✅ Each anomaly script owns its own staging table so the merges don't collide in one execution; the cross-DB varchar→jsonb assignment cast carries the JSON writes through the standard ImportTables path.
  • ✅ Provenance joins the shared intelligence layer (Finni + Layer 2) via intelligencelog — same contract as the matching / GL-coding intel rows.
  • The materialitytier this leaf writes is the same signal the auto-approve gate reads (§2). Tier definitions: Materiality Tiers.
  • Evidence: runinvoicetransformations/workflow.ts service cases Materiality Classification … Fraud Risk Score Writer (lines ~552–788); intelligence surface: ../intelligence/inventory#ap-invoice.

6. Resolution model — edit vs notify ​

When validation surfaces exceptions, the detail-page resolution wizard groups issues by domain and routes each to one of two dispositions. The rule of thumb: what we own, we edit; what the ERP owns, we notify.

Kind of issueOwnerAction
Invoice fields / lines / GL codingus✅ edit → save → re-validate (re-runs the chain; see §4 caveat)
PO / GRN discrepancyERP (procurement / receiving)◯ read-only → notify — cannot edit here

Match-variance dispositions (match-reasons.ts) map reasons → outcomes:

DispositionMeaningEffect on IPD
accept / write_off / deduction / short_closeagreed / tolerance / damaged / short receiptproceed (may post)
holdpartial delivery — await the restparks On Hold
queryvendor disputeparks On Hold
notifyPO/GRN issue (ERP-owned) — raise to procurement/receivingparks On Hold

Apply-match logic: any hold/query/notify line sets ipd.status='Validated', ipd.action='On Hold' — the invoice waits on an external response and drops from the active processor queue until it returns. A clean disposition set with no held lines can go post-ready.

  • Evidence: invoicea/services/invoice/applymatch.service.ts (anyHeld → action='On Hold'); invoicea/services/invoice/match-reasons.ts (disposition taxonomy, notify = "PO/GRN issue (ERP-owned) … hold meanwhile").

7. Verify ​

sql
-- 1. did the invoice link to a PO? (0 rows = unmatched / vetted-PO or supplier quirk)
SELECT * FROM invoice.invoiceprocessingdata_purchaseorder
WHERE invoiceprocessingdataid = :ipd;

-- 2. line-level match rows (empty = matching didn't run or was cleared)
SELECT count(*) FROM invoice.invoicelinematching WHERE invoiceprocessingdataid = :ipd;

-- 3. assessment leaf landed?
SELECT materialitytier, fraudriskscore, fraudriskreasons
FROM invoice.invoiceprocessingmetrics WHERE invoiceprocessingdataid = :ipd;

-- 4. hard exceptions gating auto-clear
SELECT rulename, status, accept FROM invoice.invoicevalidationchecks
WHERE invoiceprocessingdataid = :ipd ORDER BY id DESC;

-- 5. intelligence provenance (anomaly + gl-coding rows)
SELECT processtype, decision, confidencelevel FROM finbase.intelligencelog
WHERE sourceobject = 'invoiceprocessingdata' AND sourceobjectid = :ipd;

If the assessment/match rows are empty but the invoice reads Validated, suspect a wfw backlog (§3) — the transform child may not have completed. In development the worker bundles workflows from source on respawn; in production a changed systemwf workflow needs build:workflow before deploy.

Revision history ​

VersionDateAuthorChange
1.02026-09-15Platform EngineeringInitial draft — pipeline stages, 4-axis status, 3-way match contract + vetted-PO/supplier/GRN-cache/wfw-backlog quirks, GL coding (Non-PO hard-fail, manual-wins, save→revalidate gap), assessment leaf (materiality + 5 anomaly detectors + fraud-score writer, shipped 2026-09-13), edit-vs-notify resolution + On Hold parking, verify SQL.

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