Tax technology · E-invoicing

InvoFuse: invoices out of an ERP, into a compliant Peppol document.

A multi-tenant e-invoicing platform we designed, built and operate. It ingests invoices from customer ERPs through authenticated pipelines, resolves tax and unit-of-measure mappings, evaluates each document against the Peppol PINT-AE rule set, and generates the UBL a tax authority will accept.

ingestion · job #1,200 invoices202 accepted

SOURCE

Customer ERP

Pulled by a pipeline holding a key scoped to one tenant and one connection.

QUEUE

Job + envelope

Persisted and queued in ~0.18s; the caller gets a status URL, not a held connection.

WORKER

Map & evaluate

Tax and UOM mappings resolved, compliance rules evaluated, upserts in batches of 500.

OUTPUT

PINT-AE UBL

A document generated against the spec, with per-invoice compliance state visible first.

keys · per tenantwrites · idempotentretries · resumemetrics · per queue

InvoFuse

Multi‑tenant e‑invoicing platform built and operated by us

PINT‑AE

Peppol PINT-AE v1.0.3 as the compliance target

~0.18s

To accept and queue a 1,200-invoice ingestion payload

3 layers

Independent tenant-isolation controls, not one check

The problem

E-invoicing is a data problem wearing a format’s clothes.

Invoices live inside ERPsEvery customer keeps invoices in a different system with a different shape — tax codes, units of measure and identifiers that do not match what a Peppol document requires.
A spec, not a formatPINT-AE is 173 business rules over a UBL document: presence rules, arithmetic rules, code-list rules. An exporter that produces valid-looking XML still fails most of them.
Volume arrives in burstsMonth-end does not send invoices one at a time. A single pipeline run can push thousands, and the caller that sent them times out long before the work is done.
One tenant must never see anotherA shared platform holding several companies’ invoice books is a single mistake away from a disclosure that cannot be undone.

How it was built.

Each phase fixed something the previous version of the platform got wrong in production, not in theory.

  1. 01

    Model the compliance target first

    The PINT-AE field list and rule set were pulled apart before any code: which rules are presence checks, which are arithmetic, which are code lists, and which fields the source systems do not currently carry at all — Peppol routing identifiers and legal registration IDs were a real gap, not a mapping detail.

    rule tiers + field-gap list
  2. 02

    Make ingestion asynchronous

    Ingestion used to validate and write the whole payload inside the webhook request — a database connection held per in-flight call and a transaction as long as the biggest payload anyone sent. Now the endpoint resolves the connection, persists the job and its envelope, queues it and returns 202 with a status URL.

    202 + status_url, ~0.18s
  3. 03

    Make the work resumable

    A worker resolves tax and unit-of-measure mappings, evaluates compliance and upserts in batches of 500 — about ten statements for a payload that previously took twelve hundred. Every write is an upsert keyed on tenant, source system and external id, so a retry finishes the job instead of starting a second copy.

    checkpointed, idempotent jobs
  4. 04

    Give every key an owner

    Ingestion was authenticated by one secret shared across every tenant and every pipeline: it could not be revoked for one customer, could not be rotated without changing every workflow at once, and left no trail. Keys now belong to a tenant and optionally a single connection.

    scoped, revocable keys

The part that cannot be wrong once

One tenant’s invoice book, invisible to every other.

Invoices are commercial records: who buys what, at what price, how often. A platform that holds several companies’ books has to treat isolation as an architecture decision rather than a filter applied late in a query.

01Tenant comes from the tokenEvery tenant-facing route takes its tenant from the verified session, never from a path, query string or request body. The routes that used to trust a caller-supplied tenant were the first thing rewritten.
02Ownership checked before actionConnection-scoped operations — sync, tax mappings, unit-of-measure mappings, document retrieval — resolve the record through an owned-by-tenant lookup rather than a bare id.
03The webhook is the exception, handledMachine ingestion has no user session, so it reads its tenant off the connection the job names and rejects any payload whose own tenant or source system disagrees with that connection.
04Layers, not a single gateEach control is independently sufficient for the ordinary case. They exist together so that one mistake is not a breach.

Running it

A platform that explains itself at 3am.

Lag, not just depthQueue depth alone is ambiguous — a deep queue draining fast is fine. The platform exports the age of the oldest waiting job per queue as the signal worth alerting on.
Metrics on both processesPrometheus metrics on the API and the worker, with latency by route pattern, ingestion job outcomes, records written, and event-loop lag for the days nothing in the database explains the slowness.
Traces across the hopOpenTelemetry instrumentation follows a request from the ingestion call through the queue into the worker, so a slow job is attributable rather than merely slow.
Jobs that say where they stoppedEach job row carries status, processed count, attempts and error, checkpointed per batch. A job killed halfway through reports how far it got.

API & worker

NestJS, two processes, one codebase

Data

PostgreSQL with TypeORM migrations

Queues

BullMQ on Redis, per-queue dashboards

Web

Next.js 15, React 19, localised UI

Compliance

PINT-AE rules engine + UBL generation

Operations

Prometheus metrics, OpenTelemetry traces

What changed.

Validation and writes inside the webhook requestAccepted in ~0.18s and processed by a worker that can be scaled on its own
A caller timeout decided whether the work countedJob row with status, progress and a status URL to poll
1,200 single-row writes per payloadBatched upserts — roughly ten statements for the same payload
One ingestion secret shared by every tenantKeys scoped to a tenant and connection, revocable and rotatable alone
Tenant read from the request payloadTenant derived from the verified session, with ownership checks behind it
Compliance treated as an output formatA rule engine evaluating PINT-AE presence, arithmetic and code-list rules

Figures above are measured on our own platform under the payload described, not a benchmark claim for every workload. Compliance coverage tracks the published PINT-AE v1.0.3 rule set; the specification is the authority, not our implementation of it.

Facing an e-invoicing mandate with the data still in an ERP?