@beleggo/cli
v0.4.0
Published
Beleggo CLI — batch validate, fix, generate and convert EN 16931 e-invoices (XRechnung UBL/CII, ZUGFeRD, Peppol BIS 3)
Maintainers
Readme
Batch validate, fix, generate and convert EN 16931 e-invoices from the command line — the same engine the Beleggo web app uses (Saxon-JS + the official KoSIT/Peppol Schematron), so results are identical.
Cross-checked against the official KoSIT validator (v1.6.3) across 16 conformance cases, alongside the EU Interoperability Test Bed and Service-BW — the verdict is identical to the Beleggo web studio, the CLI and the MCP server.
Driving this from an AI agent? If your agent has a shell, this CLI is what it wants. If it speaks MCP but cannot run commands, install
@beleggo/mcpinstead — same engine, results as structured JSON.
Install
npx -y @beleggo/cli <command> ...
# or install globally:
npm i -g @beleggo/cli && beleggo <command> ...Ships as a self-contained bundle — the whole engine is inlined, so the only
things installed alongside it are saxon-js and xmllint-wasm (they load their
own runtime assets). No JVM, no native build step. Run
beleggo <command> --help for any command's options.
Validate
# a folder (searched recursively), a glob, or explicit files
beleggo validate invoices/
beleggo validate "invoices/**/*.xml" extra/one.pdf
beleggo validate invoices/ --format json > report.json
beleggo validate invoices/ --format ndjson # one verdict per line, as each finishes
beleggo validate invoices/ --fail-on warning # warnings count as failures
beleggo validate file.xml --rules peppol-bis3 # force a ruleset
beleggo validate file.xml --explain # what went wrong, and how to fix it.xml files validate directly; .pdf files are treated as ZUGFeRD (the embedded
XML is extracted and validated).
The machine contract, for putting this in a job rather than reading it:
| Exit code | Meaning |
|---|---|
| 0 | every input was read and passed the --fail-on threshold |
| 1 | every input was read; at least one failed it, or broke a house rule |
| 2 | bad arguments, or an output file could not be written |
| 3 | at least one input could not be read or judged, so the run is incomplete — it outranks 1, because a partial answer is not an answer |
Every machine-readable output carries a schemaVersion. Fields may be added
without bumping it, so ignore keys you do not know; a removal, a rename or a
change of meaning bumps it. --format ndjson writes one JSON object per line —
a run line, a result line per file in completion order, then a summary
line — so a caller can act on the first verdicts without waiting for the last.
| Option | What it does |
|---|---|
| --format | Output format — pretty (default), json, or ndjson (one JSON object per line, written as each file finishes) |
| --rules | Force a ruleset instead of auto-detecting: peppol-bis3, xrechnung-ubl, nlcius-ubl, ro-cius-ubl, hr-cius-ubl, pt-cius-ubl, en16931-ubl, xrechnung-cii, en16931-cii, zugferd |
| --fail-on | Which severity fails the run — error (default), warning, or none. none means no finding fails it; an unreadable input still exits 3 |
| --strict | Treat warnings as failures (the older name for --fail-on warning) |
| --explain | Add a plain-language what/fix, the offending value and a proposed code correction under each error (pretty output only) |
| --lang | Language for --explain — de or en (default: en) |
| --concurrency | Max files validated in parallel (default: CPU count) |
| --report | Pro — write a report file (.csv or .json) |
| --evidence | Pro — add a reproducible evidence pack per file to a JSON --report: input SHA-256, pinned ruleset versions, self-integrity digest |
| --rules-pack | Apply a custom "house rules" JSON pack — required fields, allowed currencies, an IBAN allowlist. Violations are listed and make the run exit non-zero |
| --tax | Add the advisory VAT layer: the §§ 14/14a UStG content check, a deterministic VAT risk analysis priced in euro, and a tax determination (place of supply, exemption, rate, liable person) with the paragraph behind each finding. Advisory — it never changes the EN 16931 verdict or the exit code, and it is not tax advice |
| --journal | Pro — append this run to a validation journal (JSONL): one line per document with its digest, verdict, BMF error class and the ruleset date it was judged under. Read it back with beleggo journal |
| --client | Name the Mandant this run was for; recorded on every journal line, so one Kanzlei journal stays readable across clients |
A drop folder — the interface every system already has
beleggo folder /srv/erp-ausgang # one pass
beleggo folder /srv/erp-ausgang --watch 30 # …every 30 seconds
beleggo folder /srv/erp-ausgang --move # take the input, don't copy it
# the folder is on a server where nobody is logged in — write the unit for it
beleggo folder /srv/erp-ausgang --watch 60 --service systemd \
--service-user beleggo --log /var/log/beleggo.log > beleggo-erp-ausgang.serviceThe other system writes invoices into a folder; this files each one by its verdict and leaves the verdict beside it. No plugin, no credentials, no network — a folder is the one interface every ERP, DMS, scanner and EDI converter already speaks, in both directions.
<root>/in/ the other system writes here
<root>/out/valid/ read, and it passed the --fail-on threshold
<root>/out/invalid/ read, and it did not
<root>/error/ could not be read or judged at all
<root>/state/ what has already been handled
<root>/beleggo-log.csv one appended row per documentThree terminal folders, so an integration that cannot parse JSON branches on the
directory alone. Beside each document is its <name>.beleggo.json sidecar with
the verdict itself — the same schemaVersion and the same per-file record
validate --format json emits, so a consumer that reads one reads the other.
What it promises, which is the part that decides whether a file interface survives production:
| | |
|---|---|
| Nothing half-read | A file whose size or timestamp is still moving is left for the next pass, not judged half-written |
| Nothing half-written | Every output is written under a temporary name and renamed into place |
| Nothing done twice | Each document is remembered by its bytes and its name, so a restart, a re-run or a second copy is a no-op — while an edited document, or the same one under a new name, is a new question |
| Nothing destroyed | The input is copied, not moved, unless you say --move. Two different documents arriving under one name both survive; the second keeps its own file |
| Nothing silent | A file that is not an .xml or .pdf goes to error/ with the reason, rather than being quietly ignored |
--watch re-sweeps on a timer rather than subscribing to filesystem events. A
sweep is idempotent, so polling recovers from a missed event, a restart, a
machine that was asleep, and a network share where file events never fire at
all. That is the more robust design, not the cheaper one — and without
--watch the command is one pass with an exit code, which is what a scheduler
that prefers to own the timing wants.
Running it where the folder actually is
The folder an ERP writes its outbox to is on a server, and there is nobody logged
in to run a command on it. --service writes the unit that sweeps it there —
a systemd timer, a launchd plist, or a Windows scheduled task — to stdout, with
its name and how to install it on stderr, so a redirect puts the file in place.
It generates; it does not install. Installing needs elevation, writes outside anything you pointed us at, and differs per distribution. Generating is a pure function you can read before you use it.
Every unit runs one pass per firing and lets the operating system hold the
clock, which is what --watch supplies the cadence for. There is then no
resident process to wedge, each pass's exit code reaches the scheduler — so
systemctl status and Task Scheduler's Last Result show what the folder
found — and Windows, which cannot host a console program as a service at all
without a third-party wrapper, needs no special case.
The generated units are narrow by default: a systemd unit gets
ProtectSystem=strict with the folder (and any --log directory) as the only
thing it may write, plus NoNewPrivileges, PrivateTmp and ProtectHome.
Without --service-user they run as root or SYSTEM, and the install notes say
so rather than leaving you to notice.
--log matters most on Windows, where a scheduled task captures no console
output at all: the file is the only account of what the sweep did. It rolls to
<file>.1 past 8 MB, because a folder swept every minute writes for years on
machines where nothing rotates logs.
The Windows XML declares no encoding: Task Scheduler wants UTF-16, the text leaves on stdout as UTF-8, and what your shell writes depends on your shell — so the byte-order mark decides and the file parses whichever way it was saved.
Pro.
| Option | What it does |
|---|---|
| --watch | Keep sweeping every N seconds instead of passing once |
| --move | Remove each input from in/ once it has been filed |
| --rules | Force a ruleset instead of auto-detecting |
| --fail-on | Which severity files a document under out/invalid — error (default), warning or none |
| --settle | How long a file must hold still before it is read, in ms (default: 500) |
| --concurrency | Max documents handled in parallel (default: CPU count) |
| --quiet | Only report documents that were handled |
| --log | Append what happens to this file as well as the console; rolls to <file>.1 past 8 MB |
| --service | Print the unit that runs this on a server — systemd, launchd or windows — and stop. Needs --watch for the cadence |
| --service-user | The account the generated unit runs as. Without it, systemd runs as root and a scheduled task as SYSTEM |
A loopback endpoint — the shape an ERP can call
beleggo serve --loopback
curl -s --data-binary @rechnung.xml \
-H "Host: 127.0.0.1:7469" -H "Authorization: Bearer $(cat <token file>)" \
http://127.0.0.1:7469/validateEvery other interface here hands something back through a file. That is enough to
report a verdict and not enough to act on one: for a verdict to become a
field on the ERP's own document, inside the ERP's own transaction, something on
the ERP's side has to ask for it and write the answer down. None of those systems
can link a Node library. All of them can make an HTTP call — ABAP's
cl_http_client, Business Central's HttpClient, X++, Odoo's Python, Sage's
VBA, and every RPA tool ever sold.
| Path | |
|---|---|
| POST /validate | The request body is the document — XML or PDF bytes, told apart by their first bytes rather than by a header you set. The answer is the same record validate --format json gives for one file, plus its digest and a valid/invalid/error outcome. ?rules= forces a ruleset, ?failOn= sets which severity counts as invalid — error, warning or none |
| GET /health | Version, pinned rulesets, how busy it is |
A document that was read answers 200 whatever its verdict — the verdict is
the answer, not a failure, and a caller that treated "this invoice is broken" as
an HTTP error would retry it forever. A body that is not a document at all
answers 422.
Why this is safe to run
It never leaves the machine, so no network boundary is crossed. But it is a listener, and on a shared machine that means every other process and user can reach it. These are not hardening applied afterwards; they are the reason it ships at all:
127.0.0.1only. There is no bind address to configure, so there is none to get wrong.- A token per run, written to a file only its owner can read and required on
every path including
/health— one rule, no exceptions to reason about. It is compared in constant time, and the file goes when the run does. - The
Hostheader must be the literal127.0.0.1:<port>. A page on the open web can point a name it controls at127.0.0.1and have your browser call this port — DNS rebinding. The connection really is local; theHostheader is what still carries their name. - Any request carrying an
Originis refused, and no CORS header is ever sent that would let one through. - Bytes, never paths. There is no file name anywhere in the API, so path traversal is not mitigated here — it is absent.
Bodies are capped, and an oversized one is refused before it is sent where the
client offers Expect: 100-continue and drained-then-refused otherwise, so the
caller is told why instead of finding the connection cut. Past
--max-inflight the answer is 503 busy rather than an unbounded queue.
Pro.
| Option | What it does |
|---|---|
| --loopback | Required. There is no other mode; naming it means a process list and a unit file say what this is |
| --port | Port to listen on (default: 7469) |
| --token-file | Where to write this run's token (default: a file in the temporary directory, removed on exit). On Windows, file modes are not enforced as they are on Unix — put it somewhere you have set an ACL on |
| --max-inflight | Validations at once; past this the answer is 503 busy (default: 4) |
| --log | Append the request log to this file as well as the console |
| --idle | Stop after this long with no request. Off by default — an ERP that calls at nine should not find the endpoint gone because nobody called overnight |
Inventory — take stock of what is already there
beleggo inventory invoices/2025 invoices/2026
beleggo inventory invoices/ --out reports/ # PRO: inventory.csv + inventory.json
beleggo inventory invoices/ --archive originals/ # PRO: the structured records + manifest
beleggo inventory mandant/ --all # the whole folder, invoices or notvalidate answers "is this invoice valid". inventory answers the question a
business that is behind has to answer first: what is in this folder — what is
an e-invoice at all, whose structured original is missing, which documents would
fail today, which suppliers to chase, and how much input VAT hangs on the
defective ones.
Findings are classed the way the BMF-Schreiben of 15.10.2025 classes them — Formatfehler (not EN 16931, so not an e-invoice), Geschäftsregelfehler (a rule-set violation) and Inhaltsfehler (a qualified §§ 14/14a content defect) — and every output carries the run date and the pinned ruleset versions, because the question a tax audit asks is not "did it pass" but passed what, on what date, under which rules.
Exit code is 0 when the run completed and 2 on usage/IO errors. Defects never fail the run: this is a report, not a gate.
It is a stock-take, not a repair. It reports what is there on the run date; it
does not make a past document compliant, and it is not tax advice. Where a record
was embedded in a hybrid PDF, --archive extracts a copy — the PDF itself
remains the document to retain.
| Option | What it does |
|---|---|
| --out | Pro — write inventory.csv (one row per document) and inventory.json (summary, per-supplier and per-month views, every entry) into a directory |
| --archive | Pro — write every structured record found into a directory, extracted from hybrid PDFs where it was embedded, plus a manifest.json with a SHA-256 per file |
| --journal | Pro — append this run to a validation journal (see beleggo journal) |
| --client | Name the Mandant this run was for; recorded on every journal line, so one Kanzlei journal stays readable across clients |
| --all | Take stock of every file in the folder, not only .xml and .pdf. Files that are not invoices are named by their content (xlsx, docx, eml, …) and counted separately — they never enter the invoice figures, because a spreadsheet is not an invoice whose structured original is missing |
| --format | Console output — pretty (default) or json |
| --lang | Language of the §§ 14/14a content check — de (default), en or fr |
| --concurrency | Max files validated in parallel (default: CPU count) |
Journal — prove that checking happened
beleggo validate invoices/ --journal pruefprotokoll.jsonl # PRO: append this run
beleggo inventory invoices/ --journal pruefprotokoll.jsonl # PRO: same log
beleggo journal pruefprotokoll.jsonl # read it back
beleggo journal pruefprotokoll.jsonl --csv protokoll.csv --since 2026-01-01An evidence pack proves one document. The journal proves that checking happened — which is what the BMF-Schreiben of 15.10.2025 asks for when it says validation must be nachvollziehbar, with the results retained and producible.
One JSONL line per document, appended and never rewritten: timestamp, file, SHA-256 of the structured record that was checked, the verdict, the BMF error class, the counts, and the ruleset set-date it was judged under. A file that could not be read records no verdict rather than being filed as invalid, and a damaged line is reported when reading, never silently dropped.
Writing a journal is Pro; reading one back is not.
| Option | What it does |
|---|---|
| --csv | Write the records as a semicolon-separated CSV |
| --format | Console output — pretty (default) or json |
| --since | Only records at or after this ISO date |
| --file | Only records whose document path contains this |
| --client | Only records tagged with this Mandant (exact match) |
Master data
beleggo stammdaten rechnungen/ # from the invoices alone
beleggo stammdaten --records kreditoren.csv --profile datev-partner
beleggo stammdaten rechnungen/ --records kreditoren.csv --profile sap-bp
# no bundled profile for your system? draft one from its own header row
beleggo stammdaten --profile-from-header kreditoren.csv > profil.json
beleggo stammdaten --records kreditoren.csv --profile profil.jsonEvery other command judges a document. This one judges the partner records documents are built from — which creditors are missing the fields EN 16931 needs, which identifiers are malformed, which partners are filed twice. Two sources, either or both: an ERP export read through a column mapping, and the invoices already on disk, from which the parties are reconstructed and cross-examined. With both, the export is the population being audited and the invoices are the evidence against it — a partner whose master record was corrected last spring but whose forty invoices still carry the old VAT id is a finding no per-document validator can see.
These are findings, not clearance. A record with nothing against it has been found complete and well-formed, never confirmed correct: whether a VAT id is registered to that company is a qualified confirmation from BZSt/VIES, an online step this command deliberately does not take. Exit code is 1 if any party carries a blocking finding.
--profile-from-header exists because a mapping is the only thing that differs
between one ERP's export and another's, and writing one by hand means learning
our field names first. It reads the header row, sniffs the delimiter and prints
the draft profile on stdout with its reasoning on stderr. A header that is a
known spelling is taken; one that merely contains a known spelling is taken and
flagged for checking; where several columns could be meant, none is taken and
they are named — a wrong column that looks right is worse than an empty one.
Bundled mappings, for the exports whose published field lists we have read:
| --profile | System | Describes |
|---|---|---|
| sap-bp | SAP Business Partner | sellers |
| datev-partner | DATEV Debitoren/Kreditoren | sellers |
| bc-vendor | Business Central vendors | sellers |
| bc-customer | Business Central customers | buyers |
| bc-api | Business Central API | sellers |
| lexware-contacts | Lexware Office contacts | sellers |
| generic | Generic | sellers |
A vendor / Kreditoren list describes sellers — they invoice you, so they owe
BR-CO-26 identification and a payable account. A customer / Debitoren list
describes buyers, who owe the buyer reference. --role overrides the
profile's own where one export serves both.
Their column names come from each vendor's published field list, not from a
live export, and each profile says so when it is used — versions,
localisations and configured layouts all move column names around. Nothing
guesses silently: a column the file does not have is reported as missing. Which
report to run in which system, and the trap each one sets, is in
doc/erp-export-recipes.md.
| Option | What it does |
|---|---|
| --records | A partner list exported from an ERP (delimited text) |
| --profile | Column mapping for --records: a bundled id or a path to your own JSON |
| --profile-from-header | Draft a column mapping from that file's own header row and print it as JSON — for an export with no bundled profile. Runs on its own |
| --role | Which party to read from the invoices: seller (default) or buyer |
| --out | Write the findings as a semicolon-separated CSV (Pro) |
| --format | Output format — pretty (default) or json |
| --concurrency | Max files read in parallel (default: CPU count, max 8) |
Seal an archive set
beleggo archive rechnungen/ --out bestand-2025
beleggo archive rechnungen/ --out bestand-2025 --journal pruefprotokoll.jsonl --z3 "Muster GmbH"
beleggo archive verify bestand-2025Seals the structured records of a folder into a set whose integrity can be
re-checked — by you, or by someone who does not trust you. Each entry carries a
link computed over the one before it, so the last link (the root) changes if
any document, any entry or the ordering changes. Write the root down somewhere
the archive cannot reach; archive verify re-hashes every file, re-walks the
chain and names every break it finds.
--z3 additionally writes a Datenträgerüberlassung in the
Beschreibungsstandard — index.xml plus its tables — so an auditor's analysis
software can ingest the set directly. The DTD itself is published by the German
tax administration and is not redistributed here; the command says so.
Sealing is Pro; verifying is free and always will be, because a record nobody can check is not a record. This is not an archive and retains nothing for you — it produces the set an archive ingests, plus the proof it was checked. No software can be certified GoBD-compliant (GoBD Rz. 179) and none is claimed.
| Option | What it does |
|---|---|
| --out | Where to write the sealed set (required unless verifying) |
| --journal | Seal a validation journal alongside the documents |
| --z3 | Also write a Beschreibungsstandard export, named to this data supplier |
| --format | Output format — pretty (default) or json |
| --concurrency | Max files read in parallel (default: CPU count, max 8) |
Verfahrensdokumentation — the draft an adviser asks for
beleggo verfahrensdoku --in profile.json --out verfahrensdokumentation.mdEvery adviser now asks a late client for a Verfahrensdokumentation, and what the client gets handed is a template full of blanks they cannot fill, because the answers are technical. A local tool knows them about itself.
Filled in automatically: the accepted formats and the CustomizationID marker each is detected by, the four validation stages, the pinned ruleset versions and their set-date, the intake profile, the BMF error classes with the consequence of each. You supply only what no tool can know — channels, responsible people, archive, approval — and anything you leave out is listed as an open point in the document instead of becoming a silent blank.
{
"company": "Muster GmbH",
"channels": ["[email protected]"],
"responsible": "A. Muster",
"archive": { "system": "ecoDMS", "location": "\\\\server\\belege" },
"recipientProfile": "standard",
"journal": "pruefprotokoll.jsonl"
}Output is German — the Verfahrensdokumentation is an artefact of German tax law, and a translated one is not the document anyone asks for. It is a draft to review with your tax adviser: not legal advice, and it certifies nothing. Pro.
| Option | What it does |
|---|---|
| --in | The JSON above (required) |
| --out | Write the Markdown here instead of to stdout |
| --pdf | Also render a PDF on your own letterhead |
| --brand | Letterhead JSON — name, url, logo, logoPosition |
The per-Mandant bundle
beleggo bundle mandant-a/2025/ --out abgaben/mandant-a-2025 --client "Mandant A"
beleggo bundle rechnungen/ --out paket --profile verfahren.json --journal log.jsonlOne dated folder that answers, on its own, what was checked, what was found and
how the process works — the stock-take and the supplier call list, the journal,
the Verfahrensdokumentation as Markdown and as a PDF on your letterhead, the
sealed document set, and an index.md naming the client, the run date, every
pinned ruleset version and the seal root.
Everything comes from one run, so the stock-take, the journal and the sealed
set cannot describe different documents. beleggo archive verify re-checks the
bundle against its root afterwards.
A stock-take and the record of it — not a repair, not a certification. No software can be certified GoBD-compliant (GoBD Rz. 179) and none is claimed here. Pro.
| Option | What it does |
|---|---|
| --out | Where to write the bundle (required) |
| --client | Who the bundle is for; named on the index |
| --journal | Append this run to an existing journal and include it |
| --profile | Verfahrensdokumentation input JSON — adds the draft and its PDF |
| --report | Also render index.md as bericht.pdf on the letterhead |
| --brand | Letterhead JSON — name, url, logo, logoPosition |
| --lang | Language of the §§ 14/14a content check (default: de) |
| --concurrency | Max files validated in parallel (default: CPU count) |
Readiness report — score another system
beleggo readiness korpus/ --sheet erp-urteile.csv # 1. the blank sheet
# 2. fill in the verdicts
beleggo readiness korpus/ --verdicts erp-urteile.csv --out bericht.pdf \
--subject "Muster ERP 12.4" --brand kanzlei.json # 3. the reportHand a client a folder of invoices, ask what their software says about each one, put those verdicts in a CSV, and this issues the dated sheet that names the defects their software did not catch — on your own letterhead.
This is the answer to "we already have software for that." It turns the
objection from an argument into a measurement, over a corpus the client chooses,
with their own system's verdicts in one column. The same command scores an ERP a
prospect already owns and an integration a vendor is building — and
@beleggo/datakit is the corpus to point
it at when nobody has a better one: samples in every meaningful variation, each
paired with the verdict it is supposed to draw.
--sheet writes the sheet to fill in — one row per document, in the ids this
command matches, with the verdict column empty. It carries no hint of what each
document is: not the group, not the rule it breaks, not this engine's own
verdict. A test whose answers travel with it measures nothing. It is free; the
report itself is Pro, because collecting the evidence should never be the part
that needs a licence.
The reference verdict is this engine's, under the rulesets pinned in this build, and the report prints both: "passed" means nothing without passed what, on what date, under which rules. It also carries a digest over the scored outcome, so a copy can be checked against the original.
The CSV needs two columns, the document and the verdict. Headers are matched by
name (file/datei/id, verdict/urteil/ergebnis) or taken as
the first two columns; verdicts may be valid/invalid, gueltig/fehler,
ok/error, ja/nein, true/false or 1/0. A document with no
row, or an unreadable verdict, counts as no verdict and is excluded from both
rates rather than scored as a pass.
A comparison against this engine, not a certification: no software can be certified conformant and none is claimed here. Pro.
| Option | What it does |
|---|---|
| --sheet | Write the blank verdict sheet for these documents and stop — the input to an evaluation, so it needs no licence |
| --verdicts | The subject's verdicts, as CSV (required) |
| --out | Where to write the report (required) |
| --subject | What was tested, named on the report |
| --json | Also write the scored outcome per document as JSON |
| --issued | Report date, YYYY-MM-DD (default: today) |
| --brand | Letterhead JSON — name, url, logo, logoPosition |
| --delimiter | CSV delimiter (default: auto-detect ; or ,) |
| --concurrency | Max files validated in parallel (default: CPU count) |
Fix
beleggo fix invoices/ # writes <name>.fixed.xml next to each input
beleggo fix invoices/ --write # overwrite the inputs in place
beleggo fix invoices/ --check # CI mode: exit 1 if anything would change
beleggo fix file.xml --validate # print the invalid → valid transitionApplies safe, meaning-preserving structural fixes — reordering misplaced CII
TradeParty elements, for example. Monetary, VAT and IBAN values are never
touched. Only .xml files are processed; ZUGFeRD PDFs are skipped (the
fix_invoice tool in @beleggo/mcp repairs those,
container and all). Exit code is 0 when done or when there was nothing to fix,
1 in --check mode if a file would change, 2 on usage/IO errors.
| Option | What it does |
|---|---|
| --write | Overwrite the input in place instead of writing <name>.fixed.xml (-w) |
| --validate | Re-validate before and after, and print the result |
| --check | Don't write anything; exit 1 if any file would change |
Generate
beleggo generate --in invoice.json --out dist --format ubl
beleggo generate --in invoices.json --out dist --format zugferd --validateThe input is a JSON file holding one invoice object, an array, or
{ "invoices": [...] } — see
examples/invoice.example.json. With
--validate each generated document is checked and a non-zero exit code is
returned if any is invalid.
Every field of the invoice model can be said in that JSON. Beyond the example's
parties, dates, payment account and lines: profile and typeCode (a
381 writes a real UBL credit note), precedingInvoiceId, issueTime,
deliverTo, bic, accountHolder, paymentTerms,
paymentInformation, notes (a string, or {content, subjectCode}),
billingPeriod, attachments (with base64 file.data),
allowancesCharges, prepaidAmount, roundingAmount, watermark and
branding; on a party legalRegistrationId, taxNumber, partyId and
address.countrySubentity; on a line taxCategory, exemptionReason,
itemClassification, allowancesCharges, note and period.
An unknown field is an error, and the message lists the ones that exist. A
misspelled orderRefrence used to produce a valid invoice with no order
reference and no warning.
| Option | What it does |
|---|---|
| --in | Required. The invoice JSON file |
| --out | Output directory (default: the current directory) |
| --format | Output format: ubl, cii, zugferd — zugferd being a PDF with the CII XML embedded (default: ubl) |
| --profile | Declared specification (BT-24): auto, xrechnung, en16931, peppol-bis3. en16931 emits the plain EN 16931 core, peppol-bis3 the Peppol BIS Billing 3.0 CIUS used across the EU. The default auto declares XRechnung when the buyer carries a Leitweg-ID and the EN 16931 core otherwise — XRechnung requires that reference (BR-DE-15), so an invoice without one would be invalid under it |
| --validate | Validate each generated document and report |
Convert
beleggo convert invoices/ --to cii
beleggo convert invoice.xml --to zugferd --out dist --validateConverts between syntaxes — UBL ↔ CII — or wraps an invoice into a ZUGFeRD /
Factur-X PDF, locally. Both .xml and ZUGFeRD .pdf inputs are accepted; output
is written as <name>.<target>.xml (or .pdf for zugferd).
VAT categories S/Z/E/AE (standard, zero, exempt, reverse charge) are preserved.
Intra-community (K), export (G) and document-level allowances are not fully
modelled yet — always --validate output you intend to send.
| Option | What it does |
|---|---|
| --to | Required. Target syntax: ubl, cii, zugferd |
| --out | Output directory (default: next to the input) |
| --validate | Validate each converted file and print the verdict |
Peppol transport package
beleggo peppol-pack rechnungen/ --out versand
beleggo peppol-pack rechnung.xml --out versand --receiver 0088:7300010000001Wraps validated UBL invoices in their Peppol Business Message Envelope (SBDH) and writes a manifest — everything an Access Point needs to accept a submission, and nothing that requires being one.
Every header value is derived from the document: the document-type identifier
from its CustomizationID (so a national CIUS carries its own, not a forced
Peppol one), the process identifier from its ProfileID, the participants from
their electronic addresses and scheme codes. Nothing is guessed — a document
missing any of them is refused and named, because a wrong participant identifier
does not fail, it delivers your invoice to someone else. The payload is embedded
byte-for-byte, so the invoice inside the envelope is the invoice that was
validated.
Beleggo is not an Access Point and this transmits nothing. The output is a package you hand to the service provider you contract with. Whether a receiver is registered on the network is an SMP lookup — the one step that would leave your machine — and it is deliberately not made.
| Option | What it does |
|---|---|
| --out | Where to write the envelopes (required) |
| --sender | Override the sender participant, e.g. 9930:DE123456789 |
| --receiver | Override the receiver participant |
| --skip-invalid | Envelope only the documents that validate (default: refuse the run) |
| --format | Output format — pretty (default) or json |
| --concurrency | Max files read in parallel (default: CPU count, max 8) |
DATEV
# a Belegtransfer package: the ZIP with document.xml as its table of contents
beleggo datev rechnungen/ --format dxso --out belege.zip
# an EXTF Buchungsstapel, which needs four things only you know
beleggo datev rechnungen/ --format extf --out stapel.csv \
--berater 1234567 --mandant 12345 --wj-beginn 2026-01-01 --sachkontenlaenge 4Hands e-invoices to DATEV in the shapes it reads: the DXSO/Belegtransfer package (do not unzip it before uploading), or an EXTF Buchungsstapel with one row per invoice per VAT rate. The batch is written unfestgeschrieben, so a human commits it.
The four identity fields are required and never defaulted: a wrong Beraternummer does not fail, it files the right figures into the wrong books.
Neither writer has been verified against a live DATEV import. Both layouts follow DATEV's published format descriptions, and the only proof that a file imports is importing it — use a test client first. The command repeats this on every run. Pro.
| Option | What it does |
|---|---|
| --format | What to write: dxso (default), extf or csv |
| --out | Output ZIP (dxso) or CSV file (extf/csv). Required |
| --direction | Whether these are incoming (in, default) or outgoing (out) invoices |
| --berater | Required for extf. Beraternummer from your DATEV setup |
| --mandant | Required for extf. Mandantennummer |
| --wj-beginn | Required for extf. Fiscal-year start, ISO yyyy-mm-dd |
| --sachkontenlaenge | Required for extf. Chart-of-accounts digits, 4–8 |
| --konto | Konto (expense account) for every row |
| --gegenkonto | Gegenkonto (creditor/debtor account) for every row |
| --skr | Chart of accounts, e.g. 03 or 04 |
| --concurrency | Max files read in parallel (default: CPU count, max 8) |
Extract from a PDF
beleggo extract rechnungen/ # extraction reports (JSON)
beleggo extract rechnung.pdf --to cii --validate # EN 16931 CII
beleggo extract rechnung.pdf --to zugferd # embed the XML into that same PDF
beleggo extract rechnung.pdf --template muster.json --profile seller.json
beleggo extract lieferant.pdf --to booking # booking data (CSV)For a seller whose own system still only prints PDFs, extract reads the invoice
data back off the page and writes it as an e-invoice. This is a heuristic, not
a conversion: every value carries a confidence and the place on the page it came
from, and the result is checked against the totals the page prints — a document
whose checks fail is reported and skipped unless you pass --force. A scanned or
image-only PDF is refused outright: there is no OCR here, no cloud model, nothing
uploaded.
A hybrid PDF (ZUGFeRD / Factur-X) is not extracted at all — it already carries
the invoice as an XML attachment, so that XML is read out exactly, with no
confidence and nothing to review. --ignore-embedded reads the printed page
instead, which is how you find a hybrid whose page and payload disagree.
A printed page also cannot state the VAT category (BT-151), the electronic
addresses (BT-34 / BT-49) or a Leitweg-ID (BT-10) — supply those with
--profile. --coverage names exactly which mandatory fields are still
unaccounted for, where each has to come from, and the rule that demands it
(exit 1 while any are). Review before you send: the invoice stays the
issuer's responsibility.
Use it for your own outgoing invoices. Self-converting a supplier's PDF gives
you structured booking data, but it does not turn their document into a received
e-invoice — which is what booking / datev are for. They write the figures,
one row per VAT rate, and say as much in the output; datev uses DATEV's
Buchungsstapel field names in a plain CSV, and is not an EXTF file.
| Option | What it does |
|---|---|
| --to | Output: cii, ubl, zugferd, json, booking, datev (default: json, the extraction report) |
| --template | Field mapping for this layout (repeatable) |
| --profile | Seller/buyer master data a page cannot print (JSON) |
| --out | Output directory (default: next to the input) |
| --validate | Validate the produced XML and print the verdict |
| --coverage | Report which mandatory fields this template and profile still cannot supply (exit 1 if any) |
| --force | Write output even when the checks failed |
| --single-line | If no line table can be read, emit one line carrying the printed net total (loses detail) |
| --ignore-embedded | Read the printed page even when the PDF carries an e-invoice as an attachment |
Build a data kit
beleggo kit --dataset kanzlei.json --out kits/mandant-a
beleggo kit --dataset kanzlei.json --out kits/mandant-a --watermark "MUSTER"The EN 16931 data kit is a corpus of sample invoices — valid and deliberately
defective — each paired in manifest.json with the verdict a conformant validator
should reach, so a team can point their own tooling at it and compare. This
command generates it with your content: the trading parties, their addresses,
the line items and the free texts come from a dataset file, while every case,
injected defect and expected verdict stays exactly as it is.
The dataset file is an overlay — it names what it changes and inherits the rest. Write one by hand, or in the wizard, which exports the same file. Every sample is re-validated as it is written and a verdict that moved stops the build, because a kit whose manifest is wrong is worse than no kit.
Handing the result to somebody else is the partner right in the OEM addendum, so that is what this command asks a licence for; the licensee is named in the kit's README.
| Option | What it does |
|---|---|
| --dataset | The content overlay (required) |
| --out | Where to write kit/, manifest.json and dataset.json (required) |
| --watermark | Stamp the visible page of every hybrid PDF with this line. The embedded XML is never marked — it is what a receiving system is tested with |
| --quiet | Only report the summary and any disagreements |
Pinned rulesets
| Ruleset | Pinned version |
|---|---|
| EN 16931 core | 2026-08-31 |
| XRechnung CIUS | 2.6.0 |
| Peppol BIS Billing 3.0 | 3.0.21 |
| NLCIUS (SI-UBL 2.0) | 2.0.3.13 |
| CIUS-RO (RO e-Factura) | 1.0.9 |
| HR-CIUS (eRačun) | 1.0.3 |
| CIUS-PT (eSPap FE-AP) | 2.1.1 |
Pro features
Validating, fixing, generating, converting and the console stock-take are free — the verdict never costs anything. What you keep needs a license:
- Reports & evidence —
validate --reportand--evidence, for a reproducible, tamper-evident record of a validation run. - Inventory outputs —
inventory --outand--archive. - The validation journal —
--journalonvalidateandinventory. Reading a journal back (beleggo journal) needs no license. - Verfahrensdokumentation — the generated GoBD process-documentation draft.
- PDF branding —
branding(logo, accent colour, EPC/GiroCode payment QR) and the free-textnoteon a generatedzugferdinvoice. Without a license those input fields are ignored and a clean, unbranded PDF is written; the embedded EN 16931 XML is identical either way.
The token is verified offline (Ed25519) — nothing leaves the machine:
export BELEGGO_LICENSE="EINV1.…" # your Pro license token
export BELEGGO_LICENSE_PUBKEY="<hex key>" # verification key for your build
beleggo generate --in invoice.json --out dist --format zugferdGet a license at https://beleggo.de.
Cross-platform binaries
A single self-contained executable per platform (no Node at runtime) is produced
by scripts/build-binaries.mjs via Bun:
pnpm --filter @beleggo/cli build # tsc → dist/
pnpm --filter @beleggo/cli compile # bun → dist/binaries/beleggo-<os>-<arch>[.exe]This cross-compiles all targets (linux/macOS/Windows × x64/arm64) from one host. On tagged releases they are attached to the GitHub release automatically.
Related packages
| Package | What it does |
|---|---|
| @beleggo/mcp | Local, on-device Model Context Protocol server for EN 16931 e-invoices — validate, fix, convert, generate and explain XRechnung/ZUGFeRD/Peppol from an AI agent, with nothing uploaded |
Part of Beleggo
Beleggo is a privacy-first e-invoice suite: create, validate, fix and convert EN 16931 e-invoices locally — nothing is uploaded.
- 🌐 Website & guides: https://beleggo.de
- 🧰 Web studio (validate & create in your browser): https://app.beleggo.de
- 📚 1,783-rule error library: https://beleggo.de/fehler
- 🧪 Free test invoices: https://beleggo.de/testrechnungen
- 🖥️ Desktop app (Pro): https://beleggo.de/download
License
PolyForm Shield 1.0.0 ©
Adam Urban (Beleggo) — see LICENSE.
Free to use, including commercially, for any purpose except building a product that competes with Beleggo. Questions or a different arrangement (OEM, white-label, integration): [email protected].
This package ships as a self-contained bundle that includes third-party
validation artefacts (KoSIT, OpenPEPPOL, phive-rules, Stichting Simplerinvoicing)
and open-source libraries, each under its own license — see
THIRD-PARTY-NOTICES.md.
XRechnung, ZUGFeRD / Factur-X and Peppol are trademarks of their
respective owners; Beleggo is not affiliated with or endorsed by them.
