Invoice — 3-Way Matching, GL Coding & Assessment
| Owner | Classification | Version | Effective | Next review | Status |
|---|---|---|---|---|---|
| Platform Engineering | Internal | 1.0 | 2026-09-15 | 2026-12-15 | Draft |
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.
| # | Stage | Where | Writes |
|---|---|---|---|
| 1 | Ingest | email / SFTP / upload → documentreceipt | see Email Ingestion |
| 2 | Extract | extractdocument → extractedinvoice + extractedinvoicelineitems (Gemini; values land as text) | extracted.* tables |
| 3 | Insert | processinvoice — canonical invoice, invoicelineitems, invoiceprocessingdata (IPD), stub invoiceprocessingmetrics (IPM) row | invoice.*, IPD, IPM |
| 4 | Route | invoicehub_businessprocess ruleset → resolves the BPD (INVOICE_PO / INVOICE_NON_PO), else app default | ipd.businessprocessid |
| 5 | Transform | runinvoicetransformations — BPD-scoped processtransformationrule chain incl. matching, assessment, GL coding | invoice.*, IPM, intelligencelog |
| 6 | Validate | runinvoicevalidations — BPD-scoped validationrule chain | invoicevalidationchecks, ipd.exceptioncount/.validationscore |
| 7 | Assign / Approve | AssignDocumentActivity + ApproveDocumentActivity (compiled sys.rule) → auto-clear / route / resolve | ipd.status/.action, approvalrequest |
| 8 | Post | auto-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
MockERPreference — there is no real ERPPostDocumentActivityyet. 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.
| Axis | Field | Values (typical) |
|---|---|---|
| Lifecycle | ipd.status | Captured → Extracted → Validated → Approved → Posted |
| Workflow gate | ipd.action | Unassigned → Assigned → Pending Approval → Approved → (null when terminal) → On Hold |
| Assignment | assignedtoid / assignedteamid | user vs team |
| Exceptions | ipd.exceptioncount / .validationscore | HARD (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:
| Leg | Service (transform rule) | Compares |
|---|---|---|
| PO link | PO Identification | invoice.ponumber → purchaseorder, stamped into invoiceprocessingdata_purchaseorder |
| Line ↔ PO | PO Line Item Matching | invoice line ↔ polineitems → invoicelinematching |
| 2-way price | 2 Way PO Amount Validation (validation) | invoice line price vs PO price |
| 3-way qty | 3 Way Qty Matching (validation) | invoiced vs received (grnlineitems via invoicelinematching) |
| PO coverage | PO 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=0can be a false negative — awfwbacklog. 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 Matchingdeletes priorinvoicelinematchingrows 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).
| Path | Behaviour |
|---|---|
| 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-run | auto 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.tslines ~790–880 (manual-coding guard, auto-row clear,intelligenceloggl-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.
| Service | Writes on invoiceprocessingmetrics |
|---|---|
Materiality Classification | materialitytier (tier from finbase.materialityconfig) |
Anomaly Round Amount | merges a flag into fraudriskreasons (JSON array) |
Anomaly New Supplier High Value | merges into fraudriskreasons |
Anomaly Materiality Jump | merges into fraudriskreasons |
Anomaly Bank Account Change | merges into fraudriskreasons |
Anomaly Duplicate Similar | merges into fraudriskreasons (composite fuzzy dup) |
Fraud Risk Score Writer | aggregates 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
materialitytierthis leaf writes is the same signal the auto-approve gate reads (§2). Tier definitions: Materiality Tiers. - Evidence:
runinvoicetransformations/workflow.tsservice casesMateriality 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 issue | Owner | Action |
|---|---|---|
| Invoice fields / lines / GL coding | us | ✅ edit → save → re-validate (re-runs the chain; see §4 caveat) |
| PO / GRN discrepancy | ERP (procurement / receiving) | ◯ read-only → notify — cannot edit here |
Match-variance dispositions (match-reasons.ts) map reasons → outcomes:
| Disposition | Meaning | Effect on IPD |
|---|---|---|
accept / write_off / deduction / short_close | agreed / tolerance / damaged / short receipt | proceed (may post) |
hold | partial delivery — await the rest | parks On Hold |
query | vendor dispute | parks On Hold |
notify | PO/GRN issue (ERP-owned) — raise to procurement/receiving | parks 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
-- 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.
8. Related
- Materiality Tiers — the tier config the assessment leaf reads.
- Email Ingestion — how documents arrive (stage 1).
- Document-Receipt Remediation — re-extract / re-validate paths, save→revalidate gap.
- Intelligence Inventory — AP / Invoice — the intel surface (matching + anomaly/materiality leaf).
Revision history
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-09-15 | Platform Engineering | Initial 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. |