Self-hosted EMEA accounts-payable automation that replaces $100K/yr SaaS. Ingest → extract → rules → AI confidence → human review → 27-country VAT → BI, in one auditable transaction.
A mid-sized EMEA business receives 1,000+ supplier invoices per day across PDF, scanned image, UBL XML (PEPPOL), Factur-X and email attachments. OCR vendors get the easy fields right and the long-tail fields wrong. SaaS AP platforms like Coupa or Bill.com cost north of €100K/yr per business unit and are hard to extend with country-specific tax rules. The result is finance teams either eating the SaaS cost or stitching together brittle scripts with no audit trail. This is the platform underneath.
Filesystem watcher, IMAP poller, and PEPPOL endpoints converge into one ingest queue. Each document is hashed (SHA-256) before persistence — duplicates are absorbed at the DB unique constraint, not the application layer.
UBL/Factur-X structured invoices go through a dedicated XML extractor; PDFs and scans go through a pluggable extractor with bounding-box capture for evidence. Every extracted field carries a provenance pointer.
30+ YAML-defined rules (VAT format, country support, intracommunity B2B, amount reconciliation) run as a single pass. A Llama-3.1 confidence scorer runs in parallel with a heuristic fallback — if the LLM 5xxes or times out, the page does not stall.
Anything below the auto-approve threshold with no blocking violations routes to a review queue. Reviewers atomically claim tasks via UPDATE…WHERE state='open' RETURNING — no double-claim is possible even under concurrent operators.
A country-aware tax engine computes treatment (standard, reduced, intracommunity zero, reverse-charge) and writes both invoice and per-line tax rows. The BI mart upserts on conflict (ON CONFLICT DO UPDATE) and is re-runnable.
Job enqueue and the originating DB write live in the same transaction via a pipeline_event table; a separate dispatcher process drains it with SELECT FOR UPDATE SKIP LOCKED. Eliminates the classic "enqueued before commit, job runs against missing row" race.
Adds a dispatcher process to operate and a few hundred ms of dispatch latency. Worth it for at-least-once correctness across crashes.
The audit log is hash-chained for tamper-evidence. All writers SELECT … FOR UPDATE on a single-row audit_chain_tip table before appending, serialising writes without a global lock or retry loop. Verified under 16 concurrent threads.
Writes to the audit chain are serialised. Acceptable because audit writes are infrequent relative to extraction throughput.
Reviewer claim is one SQL statement that flips state from 'open' to 'claimed' and returns the row. If two reviewers race, one gets the row and the other gets ClaimConflict → HTTP 409. No SELECT-then-UPDATE window, no advisory locks.
The scorer interface returns a structured ScoreResult; the Llama backend is one implementation, the heuristic backend is another, both register in the same scorer registry. If the LLM provider becomes unreliable or expensive, the platform doesn't change.