Invoice created / updated
Kafka listener on InvoiceBusinessDocumentTopic (group INVOICE_VERIFICATION_GROUP). Gate-resolved tracks only. On CREATED also auto-assigns a reviewer.
CATHAGO · altair engine v10 + checks-v1.0 · task CU-86c7x8puw
The complete picture of the native invoice-verification feature — the matching engine and checks, the document lifecycle from inbox to approved, the reviewer workflow in kepler, and the domain model, config and known gaps underneath. Four tabs; every claim traced to the backend + frontend knowledge bases.
altair's in-process engine (ENGINE_VERSION "v10.0") matches every invoice line against the order,
the delivery note and the agreed price list, then a checks engine (checks-v1.0) runs financial,
tax, master-data, duplicate and temporal rules on top. Everything is gated per tenant and frozen into an
append-only audit report.
Each invoice line is matched per track by a normalized article key. All three converge on InvoiceMatchingService.match(companyAccountId, invoiceId, Set<MatchTrack>).
Invoice line ↔ order line
Candidates = referenced-order lines with the same normalized article key. Best = min |orderedQty − invoiceQty|. Quantity outside tolerance ⇒ MISMATCHED. A unit-price guard rejects a 0.00-priced order line vs a priced invoice line.
order__line_consumption ledger — remaining = ordered − consumed by other invoices; no capacity ⇒ OVER_CONSUMPTION.Invoice line ↔ delivery note
First article-key match across referenced deliveries; cross-supplier allowed. A quantity mismatch keeps the matched line so 3-way reconciliation can still read the delivered qty.
Invoice price ↔ price list
Resolution order: a manual PRICELIST reference → the line's own priceListId → master-catalog lookup. Compares invoice unit price vs the price entry's pricePerUnit within tolerance.
3-way reconciliation. When order matched and a delivery line is known, the engine checks billed ≤ delivered ≤ ordered — BILLED_MORE_THAN_DELIVERED is critical (paying for goods not received), DELIVERED_MORE_THAN_ORDERED a warning. Comparison is unit-of-measure aware: "200 KG" reconciles against "10 SAC" via the extracted alternative unit, then the master product's package factor; genuinely incomparable units emit an honest "differing units" verdict rather than a fabricated mismatch.
Kafka event on invoice create/update, or a reviewer hits Run / Re-run.
InvoiceVerificationListenerWhich of the 3 tracks may run for this tenant, project, supplier & status.
AutomationGateServicePer line: order → delivery → price; reconcile; write per-track verdicts + ledger + references.
InvoiceMatchingServiceImplReload the doc, run all 11 checks + the 3-way quantity check sequentially.
VerificationCheck beansCompute counters + outcome, append a report, log a Timeline activity.
VerificationReportEntityReviewer checklist gates Approve — or touchless auto-approve if configured.
InvoiceAutoApproveServiceKafka listener on InvoiceBusinessDocumentTopic (group INVOICE_VERIFICATION_GROUP). Gate-resolved tracks only. On CREATED also auto-assigns a reviewer.
Adding/removing a reference re-runs the match synchronously via /rematch so the badge + verdicts reflect the completed match, not the pre-match snapshot.
POST …/verification/run requests MatchTrack.all(). A track runs when allowed OR unconfigured; a configured-but-disabled track is dropped.
POST …/rematch?track=ORDER|DELIVERY|PRICE — "Find order / delivery / price-list match" from the detail toolbar.
AutomationGateService.resolveAllowedTracks reads the tenant's active INVOICE automations and filters each by the invoice's current status (empty status list = matches all). A track is allowed iff ≥1 eligible workflow.
resolveEDeliveryOnly reads the delivery workflows' compareEDeliveryNote; under that scope only mobile-channel e-delivery notes match.serviceLinesSkipped), with an explicit per-line verdict.invoice__matching_config (per companyAccount, scoped by tenant + project + supplier) supersedes the old INVOICE_VERIFICATION_* automations.Silent no-op. A "Re-run all" from an unconfigured tenant goes through the gate and can return an empty track set — nothing runs, matchSummary.skipped=true with a reason, and kepler shows a snackbar instead of a false all-green.
Each check is a Spring bean auto-registered via List<VerificationCheck>. Contract: missing input ⇒ skipped, never an empty flux or a throw. A critical fail blocks readiness; a warning is approve-with-reason.
| checkId | Class | Category | Severity |
|---|---|---|---|
STR_MANDATORY_FIELDS | MandatoryFieldsCheck | Structural | warning |
FIN_NET_TAX_GROSS | NetTaxGrossCheck | Financial | warning |
FIN_LINE_SUM | LineSumCheck | Financial | warning |
TAX_RATE_ALLOWED | TaxRateAllowedCheck | Tax | warning |
TAX_VATID_FORMAT | VatIdFormatCheck | Tax | warning |
MD_IBAN_CHECKSUM | IbanChecksumCheck | Master data | critical |
MD_BIC_FORMAT | BicFormatCheck | Master data | warning |
MD_BANK_CHANGE | SupplierBankChangeCheck | Master data | critical |
DUP_INVOICE | DuplicateInvoiceCheck | Duplicate | critical |
TMP_INVOICE_DATE | InvoiceDateCheck | Temporal | warning |
TMP_DUE_DATE | DueDateCheck | Temporal | info |
MATCH_3WAY_QTY | synthesized in the report service | Match | critical |
Outcome rollup. criticalFailures > 0 or over-consumption ⇒ report FAILED; warnings or a plain mismatch / no-reference ⇒ PASSED_WITH_WARNINGS; a gate-skipped match also maps to PASSED_WITH_WARNINGS; clean ⇒ PASSED. Each run is written to an append-only VerificationReportEntity and logged as a VERIFICATION_RUN Timeline activity — never updated in place.
Verification used to be three n8n webhook workflows the frontend called and waited on. The v10 engine pulled all of that in-process — same output shape (documentPipelines), but reactive, gated, audited, fast.
Invoice-Price · Invoice-Order · Order-price verification
InvoiceMatchingService + checks, reactive WebFlux, no external hop
| Legacy n8n workflow | Native equivalent | |
|---|---|---|
| Invoice - Price verification | → | resolvePricelistMatch — assigned, active price lists only |
| Invoice - Order automation | → | resolveOrderMatch + order__line_consumption ledger |
| Delivery matching | → | resolveDeliveryMatch — cross-supplier, unit-aware |
| pipeline write-back HTTP nodes | → | persistVerdicts — same documentPipelines shape |
| "final invoice evaluation" sub-workflow | → | Checks engine + VerificationReportEntity |
The journey a supplier invoice takes — from the moment its PDF arrives, through AI extraction and human triage, into a verified BusinessDocument, to a reviewer's approval. Verification is done at approved; what happens afterwards is a separate downstream integration, noted at the end.
A PDF arrives → it becomes an IncomingFile (staging record) → AI extracts its fields → a human triages anything unclear → n8n turns the clean file into an invoice BusinessDocument → that save fires a Kafka event → verification runs → a reviewer approves. Five phases, four systems, one document.
Two n8n servers, don't mix them up. Extraction runs on the main n8n.cathago.de; email arrival lives on the separate integration-prod.cathago.de.
The process email documents workflow fetches the Gmail message + PDF attachments; an LLM reads the header addresses to identify the sender.
Emails matched to a company account, a supplier, and the tenant's INCOMING_FILE automation. Internal staff addresses excluded; unmatched mail falls back to Unmapped cat_u_aca_1.
PDF bytes stored in earth (S3), then an IncomingFile created in altair — channel EMAIL, source EXTERNAL. altair forces status PROCESSING on create.
On /company/incoming-files the upload dialog stores the file and creates an IncomingFile (channel WEB, source INTERNAL).
Arrival fires the pdf-splitter webhook, which continues into the Incoming Files Processor.
Processor (rkW7jqv75O2JwHR2, ~102 nodes) GETs the IncomingFile, pulls the PDF, extracts raw text, assembles a strict JSON schema (header + li* line fields + tenant custom fields) with a German B2B prompt (temp 0, "null, don't guess").
gemini-3.5-flash returns documentType, supplier/payment/project details, and every line with qty/prices/tax + any referenced order/delivery numbers. Reading order preserved (feeds sourceIndex).
Supplier/project/buyer fuzzy-matched (Postgres); a Mongo scan on incoming_file catches duplicates. A duplicate is set REJECTED, not silently dropped.
Mapped fields PATCHed back. Status becomes PROCESSED, WARNING, or ERROR (e.g. no document number). Errors/warnings pinned to the exact field + line.
A user opens /company/incoming-files/{id}/view: correct fields, edit lines inline, use the Fix flow (jumps to the flagged field). Then Re-Run / Apply Changes.
Re-processing skips the AI. "Apply Changes" / "Re-Run" reuse the stored fields and only re-run account-matching + dup-check + BD creation — Vertex is not called again. Extraction only happens on the first pass.
Only when PROCESSED and not in-approval, a sub-workflow POSTs into altair. altair builds the BusinessDocumentEntity (INVOICE / CREDIT_NOTE, status OPEN, channel API / source EXTERNAL).
Lines become LineItemEntity rows (bulkUpdate); extracted order/delivery numbers ride along as documentReferences (raw, not yet resolved).
On save, altair publishes CREATED to InvoiceBusinessDocumentTopic — the single seam that wakes verification.
REST, not Kafka. The IncomingFileTopic event exists but is unwired inside altair; it's the REST POST above that creates the invoice. The IncomingFile keeps a documentReferenceId → BD; deleting the file cascades to the BD + S3 doc.
InvoiceVerificationListener consumes the topic (INVOICE/CREDIT_NOTE, CREATED/UPDATED), auto-assigns on CREATED, then asks the gate which tracks may run.
3 tracks + 11 checks + 3-way qty; frozen into an append-only report. (Full internals on the Engine & checks tab.)
Correct fields, tick per-line/per-field values, resolve warnings, @-mention teammates. Approve hard-gated on the mandatory rows. (Full UI on the Reviewer workflow tab.)
Approving stamps approvedBy/At → APPROVED. If opted in and all-green with no criticals, InvoiceAutoApproveService approves with no human touch.
Verification's scope ends at APPROVED. What an approved invoice is used for next is separate work, wired per customer.
Example: e-invoice / DATEV export. For one customer today (DATABAU Nahe-Hunsrück), a standalone n8n workflow on integration-prod listens for APPROVED invoices, builds a ZUGFeRD / Factur-X file and drops it in a Google Drive folder the tax advisor imports into DATEV (no direct DATEV API). Other customers have their own export workflows. These are separate integrations, out of scope for this feature.
altair pins only the initial state (PROCESSING, forced on create); every later transition is whatever the pipeline or a user PATCHes in — there is no server-side transition guard.
The engine does the matching; a human decides "can I pay this?". The kepler UI spans two screens — the
invoice list and the invoice detail — plus a settings dialog. Feature root:
cathago/home-layout/company/invoice/.
/company/invoices
search calls, pageSize=1)./company/invoices/:id/view
getApprovalChecklist() builds 7 rows and gates the Approve button. Bucketing (readinessBucketOf): rejected → blocked; ready|skipped → passed; pending → blocked if the row is mandatory, else warning.
| Row | Green when… | Kind |
|---|---|---|
| Project | project linked (auto-seeded) | human check |
| Supplier | supplier account linked | human check |
| Buyer | buyer linked | human check |
| Order reference | an ORDER reference exists/matched, or reviewer Verified | engine check |
| Delivery reference | a DELIVERY reference exists/matched, or reviewer Verified | engine check |
| Pricelist match | ≥1 line's PRICE pipeline is COMPLETED | engine check |
| Line items | every line approved/skipped | rollup |
The mandatory set is configurable per tenant (mandatoryReadinessSteps, default {project, supplier, order, delivery, lineItems}). A bottom-bar Pflicht n/m chip shows positive progress; the list badge and detail eyebrow use the same math so they always agree. Approve gate (canApprove()): every required row ready-or-skipped and nothing rejected.
cat-verification-report-card — a single scrollable page; the three check buckets stack in one bounded body with the tiles as a sticky scrollspy nav. Comments and History are lens tabs.
29bff6b5a.detail + evidence (label / observed / expected).Each line renders as a card: compact when approved, expandable to a recon matrix with tracks INV / ORD / DLV / PRL. Values surface the counterpart ("Qty 5.00 m — O ✓ 5.00 m").
Per-line status badge: Approved (green) only for a manual approval; Matched (orange) for a system-only auto-match; else Open / Rejected. Required line fields = quantity, unitPrice, netPrice, taxRate, grossPrice; footer = net, tax, gross. Pricelist has no stored line net, so that cell reads n/a rather than green-ticking a derived net — net is validated against the ORDER line total. Reviewers can also manually Connect order/delivery line per row when article keys differ.
POST /discover-references resolves the order/delivery numbers the invoice carries to real docs and links them, then matches.match-suggestions (host-derived counterpart types, supplier-scoped, number/article only — amount/date only refine rank).cat-files-panel overlays a rubber-stamp on the PDF (UI overlay, bytes unchanged): reviewer name, timestamp, tenant colour/shape/size + an editable reviewer comment. Rejected shows a red stamp + reason.The domain model, tenant configuration, the full API surface, the Mongo collections, and the known gaps — the "under the hood" layer for anyone extending the feature.
Append-only, one doc per run, never updated — the audit trail.
Document-level aggregate of one run.
Status MATCHED / MISMATCHED / OVER_CONSUMPTION / NO_REFERENCE. Persisted as documentPipelines (n8n-compatible shape).
Anti-double-billing ledger. Row present = claim current; engine delete+insert per run (idempotent).
Per-line reconciliation. Issue OK / BILLED_MORE_THAN_DELIVERED (critical) / DELIVERED_MORE_THAN_ORDERED (warning).
trim → keep letters/digits → uppercase → strip leading zeros. "ABC-123-X" ≡ "abc 123 x" ≡ "0ABC123X". null/blank ⇒ unmatchable.
Reviewer decision APPROVED (@JsonAlias VERIFIED) / SKIPPED / REJECTED, server-stamped, + a comment thread.
Four decision maps + assignee + stamp fields drive the whole checklist.
Check enums. Category = STRUCTURAL / FINANCIAL / TAX / MASTER_DATA / DUPLICATE / TEMPORAL / MATCH. Severity = CRITICAL (blocks) / WARNING / INFO. Outcome = PASS / FAIL / WARN / SKIPPED / NOT_APPLICABLE. A CheckResult carries evidence {label, observed, expected, refId} and a nullable lineItemId (null = invoice-level).
@Id = tenantId · per tenant
per companyAccount · scope
tenantProjectAssignments[], allSuppliers, supplierIds).INVOICE_VERIFICATION_* automations; in-scope ⇒ all tracks, else legacy fallback.Touchless auto-approve gate (all true): autoApproveAllGreen on AND matchSummary all-green AND criticalFailures == 0 AND every mandatory readiness row satisfied AND invoice still OPEN. On pass → approved as SYSTEM_AUTO_APPROVAL; the list shows an "Auto" pill. Off by default → opt-in per tenant.
altair under /api/documents (context path /v3 on staging & prod), every call scoped by ?companyAccountId=. Kepler reaches these via BusinessDocumentService.
| Endpoint | Purpose |
|---|---|
| POST/documents/{id}/verification/run | Manual full verification (all tracks + all checks), persist + return the report. |
| GET/documents/{id}/verification | Latest persisted report (detail load). |
| GET/documents/{id}/verification/history | All runs newest-first (audit trail). |
| POST/documents/{id}/rematch?track= | Synchronous v10 match over linked docs; optional single track. |
| POST/documents/{id}/discover-references | "Find matching document" — resolve captured numbers to real docs, link, match. |
| POST/documents/{id}/match-suggestions | "Best matches" — ranked candidates, read-only. |
| PATCH/documents/{id} | All reviewer payloads: readiness decisions, per-line/field ticks, comments, resolve, assignee. Retries on version conflict. |
| PATCH/documents/{id}/assignee | Reassign reviewer; stamps the Assignee snapshot. |
| POST/documents/search | List view; each row embeds verificationSummary; Assignee + Skonto filters. |
| GET/tenant/verification-config | Read tenant settings. |
| PUT/tenant/verification-config | Patch tenant settings (null field = section untouched). |
| DEL/documents/{id} | Invoices & credit notes → 403 by design (audit trail). |
| Collection | Holds |
|---|---|
common__business_document | The invoice: pipelines, readiness maps, assignee, approval stamp. |
common__line_item | Line rows (keyed by documentReferenceId); verdict evidence + catalog stamp. |
document__verification_report | Append-only run reports. |
order__line_consumption | Anti-double-billing ledger. |
tenant__verification_config | Per-tenant settings. |
incoming_file | Upstream staging record (ingestion). |
InvoiceBusinessDocumentTopic by InvoiceVerificationListener (group INVOICE_VERIFICATION_GROUP).EarthMemberClient for assignee snapshots (~80ms/reassign) + round-robin role members.It resolves numbers already on the invoice; it doesn't search the account for a candidate by supplier + article overlap. No printed number ⇒ nothing auto-links (mitigated by the content-match + Best-matches fallback).
updateAggregates classifies over-consumption via message.contains("fully consumed"). Reword the reason and the counter (and FAILED outcome) silently breaks.
No best-candidate scoring or remaining-quantity ledger like the order track; multiple delivery lines with the same article bind to the first.
Pipeline write, consumption rewrite, references, connections and catalog stamps are separate reactive steps; a mid-chain failure can leave partial evidence (mitigated only by an idempotent re-run).
readiness-summary.util.ts (list) and the detail checklist must stay in sync by hand; the list still treats engine criticals as failures while the detail folds them into warnings — possible badge-colour drift.
invoice-detail and invoice-line-item-table far exceed the house 4/5-line-method rule, with a few stale as any casts. Refactor debt.
altair PdfStampServiceImpl has zero callers since a revert; kepler document-verification-header / verification-line-items are off-surface. Candidates for deletion.
kepler reRunAutomation + WORKFLOW_PATH_* coexist with the native endpoints; removal pending full rollout. The dead useNativeVerificationTrigger toggle was already removed.