diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index bbb239eb..91307943 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -57,6 +57,55 @@ jobs:
- name: Build
run: npm run build
+ # Ticket invoices are ZUGFeRD / Factur-X e-invoices (PDF/A-3b with embedded CII XML, profile
+ # EN 16931). Jest checks the mapping; this job renders a sample of every document type and runs
+ # them through Mustang, which applies veraPDF (PDF/A-3), the CII XSD and the EN 16931 + Factur-X
+ # Schematron rules. A separate job because it needs Java and a ~60 MB validator jar.
+ einvoice-validation:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: directus-cms/extensions/directus-extension-programmierbar-bundle
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc
+ cache: 'npm'
+ cache-dependency-path: 'directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json'
+
+ - name: Use Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+
+ # The validator script pins the Mustang version and its SHA-256; keying the cache on the
+ # script means a version bump downloads the new jar exactly once.
+ - name: Cache Mustang validator
+ uses: actions/cache@v4
+ with:
+ path: directus-cms/extensions/directus-extension-programmierbar-bundle/.cache/mustang
+ key: mustang-${{ hashFiles('directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/cli/validate-einvoices.ts') }}
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate e-invoices
+ run: npm run einvoice:validate
+
+ # The rendered samples and Mustang's reports, for reviewing a change to the invoice layout.
+ - name: Upload e-invoice samples
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: einvoice-samples
+ path: directus-cms/extensions/directus-extension-programmierbar-bundle/einvoice-samples
+ if-no-files-found: ignore
+
nuxt-app-test:
runs-on: ubuntu-latest
defaults:
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/.gitignore b/directus-cms/extensions/directus-extension-programmierbar-bundle/.gitignore
index 8d67a86c..89f89bab 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/.gitignore
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/.gitignore
@@ -1,3 +1,6 @@
.DS_Store
node_modules
dist
+# Rendered by `npm run einvoice:samples`, Mustang jar cached by `npm run einvoice:validate`
+einvoice-samples
+.cache
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md b/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md
index f64ae723..7cbf7fbf 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md
@@ -16,6 +16,23 @@ npm test # Jest
npm run build # directus-extension build
```
+### E-invoice validation
+
+Ticket invoices are ZUGFeRD / Factur-X e-invoices (PDF/A-3b with an embedded CII XML, profile EN
+16931). Jest covers the mapping (`shared/__tests__/einvoice.test.ts`); the official rules run in a
+separate CI job (`einvoice-validation`) because they need Java:
+
+```bash
+npm run einvoice:samples # render every document type to einvoice-samples/ (PDF + extracted XML)
+npm run einvoice:validate # render, then validate each PDF with Mustang
+```
+
+[Mustang](https://www.mustangproject.org/) (Apache-2.0) checks PDF/A-3 conformance with its
+embedded veraPDF, the CII XSD and the EN 16931 + Factur-X Schematron rules. The script downloads the
+pinned Mustang CLI jar from Maven Central into `.cache/mustang/` and verifies its SHA-256; set
+`MUSTANG_JAR` to use a jar you already have. Java 11+ must be on the `PATH`. Each PDF gets a
+`*.mustang.xml` report next to it.
+
There is deliberately no typecheck step yet. `tsc` cannot currently run on this tree at all — see
[the tooling plan](../../../docs/directus-extension-tooling-plan.md), Phase 1.
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json
index 89fbb247..28d1a378 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json
@@ -222,7 +222,9 @@
"algolia-index-rebuild": "tsx src/algolia-index/cli/rebuild-index.ts",
"algolia-index-repair": "tsx src/algolia-index/cli/repair-index.ts",
"test": "jest",
- "test:watch": "jest --watch"
+ "test:watch": "jest --watch",
+ "einvoice:samples": "EINVOICE_SAMPLES_DIR=einvoice-samples jest src/shared/__tests__/einvoice-samples.test.ts",
+ "einvoice:validate": "npm run einvoice:samples && tsx src/shared/cli/validate-einvoices.ts einvoice-samples"
},
"dependencies": {
"mjml": "^5.4.1",
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts
index 59ac98c1..556878a5 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts
@@ -475,6 +475,21 @@ describe('invoice-lifecycle endpoint', () => {
expect(calls.creates).toHaveLength(0)
})
+ test('400 when the order amounts do not add up to a valid e-invoice', async () => {
+ // Hand-edited order: net 190 € + VAT 36 € ≠ the stored gross total of 226,10 €.
+ const { call, calls } = await setup({
+ order: { vat_amount_cents: 3600 },
+ invoices: [issuedOriginalRow()],
+ })
+
+ const res = await call('correction')
+
+ expect(res.statusCode).toBe(400)
+ expect(res.body.error).toContain('e-invoice')
+ expect(calls.creates).toHaveLength(0)
+ expect(calls.uploads).toHaveLength(0)
+ })
+
test('corrects the latest correction, not the first original', async () => {
const original = issuedOriginalRow()
const firstCorrection = issuedOriginalRow({
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts
index 0cb14f8d..75e7f51b 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts
@@ -6,6 +6,7 @@ import type { EmailServiceContext } from '../shared/email-service.js'
import { generateInvoiceNumber } from '../shared/invoice-generator.js'
import {
buildInvoiceSnapshot,
+ EInvoiceDataError,
ensureInvoiceDocuments,
findCancellationFor,
findCurrentInvoiceDocument,
@@ -212,8 +213,10 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) =>
return
}
// Same class of client error as the empty-attendees 400 in
- // loadOrderContext: nothing to invoice, not a server fault.
- if (err instanceof InvalidTicketCountError) {
+ // loadOrderContext: nothing to invoice, not a server fault. Order amounts
+ // that do not reconcile into a valid e-invoice are a data problem the
+ // admin has to fix on the order.
+ if (err instanceof InvalidTicketCountError || err instanceof EInvoiceDataError) {
res.status(400).send({ error: err.message })
return
}
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice-samples.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice-samples.test.ts
new file mode 100644
index 00000000..e86b604c
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice-samples.test.ts
@@ -0,0 +1,53 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, jest, test } from '@jest/globals'
+import { renderEInvoiceSamples } from '../einvoice-samples.ts'
+import { FACTURX_FILENAME } from '../einvoice.ts'
+import { extractEmbeddedFile, pdfMetadata } from './pdf-inspect.ts'
+
+// The real font (Jest cannot parse the loader's `import.meta.url`): PDF/A requires it
+// to be embedded, so the samples handed to the validators must use it.
+jest.mock('../museo-font.ts', () => ({
+ tryLoadMuseoFont: () =>
+ jest
+ .requireActual<{ readFileSync: (file: string) => Buffer }>('node:fs')
+ .readFileSync(`${process.cwd()}/assets/MuseoSans700.otf`),
+}))
+
+/**
+ * Renders every e-invoice sample. With EINVOICE_SAMPLES_DIR set (see
+ * `npm run einvoice:samples`), also writes the PDFs and their embedded XML there for
+ * the official validators (`npm run einvoice:validate`).
+ */
+describe('e-invoice samples', () => {
+ test('render as PDF/A-3b with an embedded factur-x.xml', async () => {
+ const samples = await renderEInvoiceSamples()
+ expect(samples.map((sample) => sample.name)).toEqual([
+ 'rechnung',
+ 'rechnungsberichtigung',
+ 'stornorechnung',
+ 'rechnung-privat',
+ 'rechnung-0-euro',
+ 'rechnung-rundung',
+ ])
+
+ const outDir = process.env.EINVOICE_SAMPLES_DIR
+ if (outDir) {
+ fs.mkdirSync(outDir, { recursive: true })
+ }
+
+ for (const sample of samples) {
+ const metadata = pdfMetadata(sample.pdf)
+ expect(metadata).toContain('3')
+ expect(metadata).toContain('EN 16931')
+
+ const xml = extractEmbeddedFile(sample.pdf)
+ expect(xml).toContain('urn:cen.eu:en16931:2017')
+
+ if (outDir) {
+ fs.writeFileSync(path.join(outDir, `${sample.name}.pdf`), sample.pdf)
+ fs.writeFileSync(path.join(outDir, `${sample.name}.${FACTURX_FILENAME}`), xml)
+ }
+ }
+ })
+})
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice.test.ts
new file mode 100644
index 00000000..e3f416fd
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice.test.ts
@@ -0,0 +1,300 @@
+import { describe, expect, jest, test } from '@jest/globals'
+import {
+ buildFacturXXml,
+ buyerCountryCode,
+ EInvoiceDataError,
+ expectedVatCents,
+ normalizeVatId,
+ unitPriceNetCents,
+} from '../einvoice.ts'
+import { generateInvoicePdf, SELLER, type InvoiceData } from '../invoice-generator.ts'
+import { embeddedFileSpec, extractEmbeddedFile, pdfMetadata } from './pdf-inspect.ts'
+
+// `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform.
+jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null }))
+
+/** 2 tickets at 399 € net, bought with a discount code for 299 € (as the checkout prices it). */
+const INVOICE: InvoiceData = {
+ invoiceNumber: 'PB-CON26-001',
+ invoiceDate: '20.10.2026',
+ serviceDate: '25.–26.11.2026',
+ servicePeriodStart: '2026-11-25',
+ servicePeriodEnd: '2026-11-26',
+ purchaserName: 'Erika Musterfrau',
+ purchaserEmail: 'erika@example.com',
+ companyName: 'Beispiel GmbH',
+ companyVatId: 'DE 123 456 789',
+ billingAddressLine1: 'Musterstraße 1',
+ billingAddressLine2: '2. OG',
+ billingCity: 'Frankfurt am Main',
+ billingPostalCode: '60311',
+ billingCountry: 'DE',
+ conferenceTitle: 'programmier.con 2026',
+ ticketType: 'Discounted',
+ ticketCount: 2,
+ unitPriceGrossCents: 47481, // round(39900 × 1.19)
+ subtotalCents: 79800,
+ discountAmountCents: 20000,
+ vatAmountCents: 11362, // 2 × (round(29900 × 1.19) − 29900)
+ totalGrossCents: 71162,
+ documentKind: 'invoice',
+}
+
+const REFERENCE = { referenceNumber: 'PB-CON26-000', referenceDate: '15.10.2026' }
+
+const CORRECTION: InvoiceData = { ...INVOICE, documentKind: 'correction', ...REFERENCE }
+
+/** What the invoice-lifecycle endpoint renders from a negated snapshot. */
+const CANCELLATION: InvoiceData = {
+ ...INVOICE,
+ unitPriceGrossCents: -47481,
+ subtotalCents: -79800,
+ discountAmountCents: -20000,
+ vatAmountCents: -11362,
+ totalGrossCents: -71162,
+ documentKind: 'cancellation',
+ ...REFERENCE,
+}
+
+const PRIVATE: InvoiceData = {
+ ...INVOICE,
+ companyName: null,
+ companyVatId: null,
+ billingAddressLine1: null,
+ billingAddressLine2: null,
+ billingCity: null,
+ billingPostalCode: null,
+ billingCountry: null,
+ ticketType: 'Regular',
+ ticketCount: 1,
+ unitPriceGrossCents: 47481,
+ subtotalCents: 39900,
+ discountAmountCents: 0,
+ vatAmountCents: 7581,
+ totalGrossCents: 47481,
+}
+
+/** Text of the first element with this tag (optionally below a parent tag). */
+function value(xml: string, tag: string, within?: string): string | undefined {
+ const scope = within ? (new RegExp(`<${within}>([\\s\\S]*?)${within}>`).exec(xml)?.[1] ?? '') : xml
+ return new RegExp(`<${tag}(?: [^>]*)?>([^<]*)${tag}>`).exec(scope)?.[1]
+}
+
+const SUMMATION = 'ram:SpecifiedTradeSettlementHeaderMonetarySummation'
+
+describe('buildFacturXXml', () => {
+ test('declares the EN 16931 profile', () => {
+ const xml = buildFacturXXml(INVOICE, SELLER)
+ expect(value(xml, 'ram:ID', 'ram:GuidelineSpecifiedDocumentContextParameter')).toBe('urn:cen.eu:en16931:2017')
+ expect(value(xml, 'ram:ID', 'rsm:ExchangedDocument')).toBe('PB-CON26-001')
+ expect(xml).toContain('20261020')
+ })
+
+ test.each([
+ ['invoice', INVOICE, '380'],
+ ['correction', CORRECTION, '384'],
+ ['cancellation', CANCELLATION, '381'],
+ ] as const)('a %s has type code %s', (_kind, data, typeCode) => {
+ expect(value(buildFacturXXml(data, SELLER), 'ram:TypeCode', 'rsm:ExchangedDocument')).toBe(typeCode)
+ })
+
+ test('maps the printed amounts of an invoice with a discount', () => {
+ const xml = buildFacturXXml(INVOICE, SELLER)
+
+ // Line: printed Einzelpreis × Anzahl = Gesamtpreis.
+ expect(value(xml, 'ram:ChargeAmount', 'ram:NetPriceProductTradePrice')).toBe('399.00')
+ expect(xml).toContain('2')
+ expect(value(xml, 'ram:LineTotalAmount', 'ram:SpecifiedTradeSettlementLineMonetarySummation')).toBe('798.00')
+
+ // The Rabatt line is a document-level allowance.
+ expect(value(xml, 'ram:ActualAmount', 'ram:SpecifiedTradeAllowanceCharge')).toBe('200.00')
+ expect(value(xml, 'ram:Reason', 'ram:SpecifiedTradeAllowanceCharge')).toBe('Rabatt')
+
+ expect(value(xml, 'ram:LineTotalAmount', SUMMATION)).toBe('798.00')
+ expect(value(xml, 'ram:AllowanceTotalAmount', SUMMATION)).toBe('200.00')
+ expect(value(xml, 'ram:TaxBasisTotalAmount', SUMMATION)).toBe('598.00')
+ expect(value(xml, 'ram:TaxTotalAmount', SUMMATION)).toBe('113.62')
+ expect(value(xml, 'ram:GrandTotalAmount', SUMMATION)).toBe('711.62')
+ // Paid upfront via the online checkout.
+ expect(value(xml, 'ram:TotalPrepaidAmount', SUMMATION)).toBe('711.62')
+ expect(value(xml, 'ram:DuePayableAmount', SUMMATION)).toBe('0.00')
+ expect(value(xml, 'ram:TypeCode', 'ram:SpecifiedTradeSettlementPaymentMeans')).toBe('68')
+
+ // VAT breakdown: category S, 19 %.
+ expect(value(xml, 'ram:CalculatedAmount')).toBe('113.62')
+ expect(value(xml, 'ram:BasisAmount')).toBe('598.00')
+ expect(xml).toContain('S')
+ expect(xml).toContain('19')
+ })
+
+ test('carries seller and buyer identification', () => {
+ const xml = buildFacturXXml(INVOICE, SELLER)
+
+ expect(value(xml, 'ram:Name', 'ram:SellerTradeParty')).toBe('Lotum media GmbH')
+ expect(xml).toContain('DE272856704')
+ expect(xml).toContain('020 238 63371')
+
+ expect(value(xml, 'ram:Name', 'ram:BuyerTradeParty')).toBe('Beispiel GmbH')
+ expect(value(xml, 'ram:PersonName', 'ram:BuyerTradeParty')).toBe('Erika Musterfrau')
+ expect(value(xml, 'ram:CountryID', 'ram:BuyerTradeParty')).toBe('DE')
+ expect(value(xml, 'ram:PostcodeCode', 'ram:BuyerTradeParty')).toBe('60311')
+ expect(value(xml, 'ram:LineTwo', 'ram:BuyerTradeParty')).toBe('2. OG')
+ expect(xml).toContain('erika@example.com')
+ // The free-text USt-IdNr. is normalized for BT-48.
+ expect(xml).toContain('DE123456789')
+ })
+
+ test('carries the conference dates as invoicing period, or as delivery date for a single day', () => {
+ const period = buildFacturXXml(INVOICE, SELLER)
+ expect(period).toContain(
+ '2026112520261126'
+ )
+ expect(period).not.toContain('ActualDeliverySupplyChainEvent')
+
+ const singleDay = buildFacturXXml({ ...INVOICE, servicePeriodEnd: '2026-11-25' }, SELLER)
+ expect(singleDay).toContain(
+ '20261125'
+ )
+ expect(singleDay).not.toContain('BillingSpecifiedPeriod')
+
+ const unknown = buildFacturXXml({ ...INVOICE, servicePeriodStart: null, servicePeriodEnd: null }, SELLER)
+ expect(unknown).not.toContain('BillingSpecifiedPeriod')
+ expect(unknown).not.toContain('ActualDeliverySupplyChainEvent')
+ })
+
+ test('a correction references the corrected invoice (BT-25/BT-26)', () => {
+ const xml = buildFacturXXml(CORRECTION, SELLER)
+ expect(xml).toContain(
+ 'PB-CON26-00020261015'
+ )
+ expect(value(xml, 'ram:Content')).toBe(
+ 'Diese Rechnungsberichtigung berichtigt und ersetzt die Rechnung Nr. PB-CON26-000 vom 15.10.2026.'
+ )
+ expect(value(xml, 'ram:GrandTotalAmount', SUMMATION)).toBe('711.62')
+ expect(value(xml, 'ram:DuePayableAmount', SUMMATION)).toBe('0.00')
+ })
+
+ test('a cancellation is a credit note with the printed negative amounts as positive values', () => {
+ const xml = buildFacturXXml(CANCELLATION, SELLER)
+
+ expect(xml).toContain('PB-CON26-000')
+ expect(value(xml, 'ram:Content')).toBe(
+ 'Diese Stornorechnung storniert die Rechnung Nr. PB-CON26-000 vom 15.10.2026.'
+ )
+ expect(value(xml, 'ram:ChargeAmount', 'ram:NetPriceProductTradePrice')).toBe('399.00')
+ expect(value(xml, 'ram:ActualAmount', 'ram:SpecifiedTradeAllowanceCharge')).toBe('200.00')
+ expect(value(xml, 'ram:TaxTotalAmount', SUMMATION)).toBe('113.62')
+ expect(value(xml, 'ram:GrandTotalAmount', SUMMATION)).toBe('711.62')
+ // Nothing was paid on the credit note; the payment terms say how it is settled.
+ expect(value(xml, 'ram:TotalPrepaidAmount', SUMMATION)).toBe('0.00')
+ expect(value(xml, 'ram:DuePayableAmount', SUMMATION)).toBe('711.62')
+ expect(xml).not.toContain('SpecifiedTradeSettlementPaymentMeans')
+ expect(value(xml, 'ram:Description', 'ram:SpecifiedTradePaymentTerms')).toContain('PB-CON26-000')
+ expect(xml).not.toMatch(/>-\d/)
+ })
+
+ test('a private buyer without address gets the documented country fallback', () => {
+ const xml = buildFacturXXml(PRIVATE, SELLER)
+ expect(value(xml, 'ram:Name', 'ram:BuyerTradeParty')).toBe('Erika Musterfrau')
+ expect(value(xml, 'ram:CountryID', 'ram:BuyerTradeParty')).toBe('DE')
+ expect(xml).not.toContain('')
+ expect(xml).not.toContain('SpecifiedTradeAllowanceCharge')
+ expect(buyerCountryCode(null)).toEqual({ code: 'DE', isFallback: true })
+ expect(buyerCountryCode('Österreich')).toEqual({ code: 'AT', isFallback: false })
+ expect(buyerCountryCode('AT')).toEqual({ code: 'AT', isFallback: false })
+ })
+
+ test('a 0 € invoice (100 % discount) has no payment means and zero totals', () => {
+ const xml = buildFacturXXml(
+ {
+ ...PRIVATE,
+ ticketType: 'Discounted',
+ discountAmountCents: 39900,
+ vatAmountCents: 0,
+ totalGrossCents: 0,
+ },
+ SELLER
+ )
+ expect(value(xml, 'ram:TaxBasisTotalAmount', SUMMATION)).toBe('0.00')
+ expect(value(xml, 'ram:GrandTotalAmount', SUMMATION)).toBe('0.00')
+ expect(value(xml, 'ram:DuePayableAmount', SUMMATION)).toBe('0.00')
+ expect(xml).not.toContain('SpecifiedTradeSettlementPaymentMeans')
+ })
+
+ test('escapes text and leaves a malformed buyer VAT ID out', () => {
+ const xml = buildFacturXXml({ ...INVOICE, companyName: 'Müller & Söhne ', companyVatId: '123' }, SELLER)
+ expect(value(xml, 'ram:Name', 'ram:BuyerTradeParty')).toBe('Müller & Söhne <GmbH>')
+ expect(xml).not.toContain('schemeID="VA">123')
+ })
+
+ test('refuses data whose amounts do not reconcile with the printed totals', () => {
+ expect(() => buildFacturXXml({ ...INVOICE, subtotalCents: 79801 }, SELLER)).toThrow(EInvoiceDataError)
+ expect(() => buildFacturXXml({ ...INVOICE, totalGrossCents: 71163 }, SELLER)).toThrow(EInvoiceDataError)
+ expect(() => buildFacturXXml({ ...INVOICE, invoiceDate: '2026-10-20' }, SELLER)).toThrow(EInvoiceDataError)
+ // A VAT amount far off 19 % (more than BR-CO-17's tolerance of 1.00).
+ expect(() => buildFacturXXml({ ...INVOICE, vatAmountCents: 11000, totalGrossCents: 70800 }, SELLER)).toThrow(
+ EInvoiceDataError
+ )
+ })
+})
+
+describe('amount helpers', () => {
+ test('the net unit price is the one the PDF always printed', () => {
+ for (let gross = -50000; gross <= 200000; gross += 7) {
+ expect(unitPriceNetCents({ unitPriceGrossCents: gross })).toBe(Math.round(gross / 1.19))
+ }
+ })
+
+ test('per-ticket VAT rounding stays within a cent per two tickets of BR-CO-17', () => {
+ // 3 × 99,99 € net: printed VAT 3 × 19,00 € = 57,00 €, exact 19 % of 299,97 € = 56,99 €.
+ expect(3 * (Math.round(9999 * 1.19) - 9999)).toBe(5700)
+ expect(expectedVatCents(29997)).toBe(5699)
+ // Whole-euro prices never deviate.
+ for (let net = 100; net <= 100000; net += 100) {
+ for (let count = 1; count <= 10; count++) {
+ expect(count * (Math.round(net * 1.19) - net)).toBe(expectedVatCents(count * net))
+ }
+ }
+ })
+
+ test('normalizeVatId', () => {
+ expect(normalizeVatId('de 123.456.789')).toBe('DE123456789')
+ expect(normalizeVatId('ATU12345678')).toBe('ATU12345678')
+ expect(normalizeVatId('123456789')).toBeNull()
+ expect(normalizeVatId(null)).toBeNull()
+ })
+})
+
+describe('generateInvoicePdf e-invoice embedding', () => {
+ test.each([
+ ['invoice', INVOICE],
+ ['correction', CORRECTION],
+ ['cancellation', CANCELLATION],
+ ] as const)('a %s embeds exactly the XML built from its data', async (_kind, data) => {
+ const pdf = await generateInvoicePdf(data)
+
+ expect(extractEmbeddedFile(pdf)).toBe(buildFacturXXml(data, SELLER))
+
+ const fileSpec = embeddedFileSpec(pdf)
+ expect(fileSpec).toContain('/AFRelationship /Alternative')
+ expect(fileSpec).toContain('(factur-x.xml)')
+ expect(pdf.toString('latin1')).toMatch(/\/AF \[\d+ 0 R\]/)
+ expect(pdf.toString('latin1')).toContain('/Subtype /text#2Fxml')
+ expect(pdf.toString('latin1')).toMatch(/^%PDF-1\.7/)
+ })
+
+ test('declares PDF/A-3b and the Factur-X EN 16931 profile in the XMP metadata', async () => {
+ const xmp = pdfMetadata(await generateInvoicePdf(INVOICE))
+ expect(xmp).toContain('3')
+ expect(xmp).toContain('B')
+ expect(xmp).toContain('urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#')
+ expect(xmp).toContain('INVOICE')
+ expect(xmp).toContain('factur-x.xml')
+ expect(xmp).toContain('1.0')
+ expect(xmp).toContain('EN 16931')
+ })
+
+ test('rejects instead of rendering a PDF whose amounts the e-invoice would contradict', async () => {
+ await expect(generateInvoicePdf({ ...INVOICE, subtotalCents: 1 })).rejects.toThrow(EInvoiceDataError)
+ })
+})
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts
index 42a72ae7..e023cbe9 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts
@@ -103,7 +103,18 @@ describe('invoice meta block', () => {
test.each([
['invoice', {}],
['correction', { referenceNumber: 'PB-CON26-000', referenceDate: '15.01.2026' }],
- ['cancellation', { referenceNumber: 'PB-CON26-000', referenceDate: '15.01.2026' }],
+ [
+ 'cancellation',
+ {
+ referenceNumber: 'PB-CON26-000',
+ referenceDate: '15.01.2026',
+ // A Stornorechnung carries the referenced invoice's amounts negated.
+ unitPriceGrossCents: -11900,
+ subtotalCents: -10000,
+ vatAmountCents: -1900,
+ totalGrossCents: -11900,
+ },
+ ],
] as const)('a %s shows the Leistungsdatum (§ 14 Abs. 4 Nr. 6 UStG)', async (kind, reference) => {
const texts = await renderedTexts({ ...BASE, documentKind: kind, ...reference } as InvoiceData)
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts
index d95480c7..111caced 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts
@@ -222,6 +222,25 @@ describe('Leistungsdatum (service date)', () => {
})
expect(postSlackMessageMock).not.toHaveBeenCalled()
})
+
+ test('warns when the e-invoice has to fall back to DE as buyer country', async () => {
+ const render = async (billingCountry: string | null) => {
+ const logger = { warn: jest.fn() }
+ await renderAndUploadInvoice({
+ snapshot: buildInvoiceSnapshot({ ...ORDER, billing_country: billingCountry }, 'Conf', 2, CONFERENCE),
+ invoiceNumber: 'PB-CON26-001',
+ invoiceDate: new Date('2026-02-01T10:00:00Z'),
+ filesService: fakeFilesService(),
+ logger,
+ })
+ return logger.warn.mock.calls.map(([message]) => String(message))
+ }
+
+ expect(await render(null)).toEqual([expect.stringMatching(/PB-CON26-001.*states DE/)])
+ expect(await render('Atlantis')).toEqual([expect.stringMatching(/"Atlantis".*states DE/)])
+ expect(await render('AT')).toEqual([])
+ expect(await render('Deutschland')).toEqual([])
+ })
})
describe('negateInvoiceSnapshot', () => {
@@ -287,8 +306,10 @@ describe('renderAndUploadInvoice', () => {
['cancellation', 'Stornorechnung-PB-CON26-001.pdf', 'Stornorechnung PB-CON26-001'],
] as const)('names the %s document after its kind', async (kind, fileName, title) => {
const filesService = fakeFilesService()
+ const snapshot = buildInvoiceSnapshot(ORDER, 'Conf', 2)
const base = {
- snapshot: buildInvoiceSnapshot(ORDER, 'Conf', 2),
+ // A cancellation mirrors the referenced snapshot with negated amounts.
+ snapshot: kind === 'cancellation' ? negateInvoiceSnapshot(snapshot) : snapshot,
invoiceNumber: 'PB-CON26-001',
invoiceDate: new Date('2026-02-01T10:00:00Z'),
filesService,
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/pdf-inspect.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/pdf-inspect.ts
new file mode 100644
index 00000000..32bb42a7
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/pdf-inspect.ts
@@ -0,0 +1,46 @@
+import zlib from 'node:zlib'
+
+/**
+ * Minimal readers for the PDFs pdfkit writes (uncompressed object syntax), enough
+ * for tests to look at the embedded e-invoice and the XMP metadata.
+ */
+
+/** The PDF as latin1, so string offsets equal byte offsets. */
+function raw(pdf: Buffer): string {
+ return pdf.toString('latin1')
+}
+
+/** Content of the (single) embedded file stream, inflated, as UTF-8. */
+export function extractEmbeddedFile(pdf: Buffer): string {
+ const text = raw(pdf)
+ const typeAt = text.indexOf('/Type /EmbeddedFile')
+ if (typeAt < 0) {
+ throw new Error('PDF has no embedded file')
+ }
+ const streamStart = text.indexOf('stream\n', typeAt) + 'stream\n'.length
+ const streamEnd = text.indexOf('\nendstream', streamStart)
+ const dictionary = text.slice(text.lastIndexOf('obj', typeAt), streamStart)
+ const data = pdf.subarray(streamStart, streamEnd)
+ return (dictionary.includes('/FlateDecode') ? zlib.inflateSync(data) : data).toString('utf-8')
+}
+
+/** The XMP metadata packet (pdfkit writes it uncompressed). */
+export function pdfMetadata(pdf: Buffer): string {
+ const text = raw(pdf)
+ const start = text.indexOf('', start) + ''.length
+ if (start < 0 || end < start) {
+ throw new Error('PDF has no XMP metadata')
+ }
+ return pdf.subarray(start, end).toString('utf-8')
+}
+
+/** The file specification dictionary of the embedded file. */
+export function embeddedFileSpec(pdf: Buffer): string {
+ const text = raw(pdf)
+ const typeAt = text.indexOf('/Type /Filespec')
+ if (typeAt < 0) {
+ throw new Error('PDF has no file specification')
+ }
+ return text.slice(text.lastIndexOf('<<', typeAt), text.indexOf('>>', text.indexOf('/EF', typeAt) + 10) + 2)
+}
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/cli/validate-einvoices.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/cli/validate-einvoices.ts
new file mode 100644
index 00000000..b6fb2dc2
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/cli/validate-einvoices.ts
@@ -0,0 +1,138 @@
+/**
+ * Validate the e-invoice sample PDFs with Mustang (ZUGFeRD/Factur-X validator, Apache-2.0).
+ *
+ * Mustang checks, per file: PDF/A-3 conformance (embedded veraPDF), the XSD of the
+ * embedded CII XML, and the EN 16931 + Factur-X Schematron business rules for the
+ * declared profile. Run through `npm run einvoice:validate`, which renders the samples
+ * first (`npm run einvoice:samples`). Needs Java 11+ on the PATH.
+ *
+ * The Mustang CLI jar is downloaded from Maven Central once and verified against the
+ * pinned SHA-256 below. Set MUSTANG_JAR to use an existing jar instead.
+ *
+ * Usage: tsx src/shared/cli/validate-einvoices.ts [samples-dir]
+ */
+import { spawnSync } from 'node:child_process'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+
+const MUSTANG_VERSION = '2.26.0'
+const MUSTANG_SHA256 = '42d7868cb68264874a7b8cab4c3587b03b23ccc7cd72373da917f66758bb9736'
+const MUSTANG_URL = `https://repo1.maven.org/maven2/org/mustangproject/Mustang-CLI/${MUSTANG_VERSION}/Mustang-CLI-${MUSTANG_VERSION}.jar`
+const CACHE_DIR = path.resolve('.cache', 'mustang')
+
+/** Every document type must be among the samples — an empty directory must not pass. */
+const REQUIRED_SAMPLES = ['rechnung', 'rechnungsberichtigung', 'stornorechnung']
+
+function sha256(file: string): string {
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
+}
+
+async function sleep(ms: number) {
+ await new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function ensureMustangJar(): Promise {
+ if (process.env.MUSTANG_JAR) {
+ return process.env.MUSTANG_JAR
+ }
+
+ const jar = path.join(CACHE_DIR, `Mustang-CLI-${MUSTANG_VERSION}.jar`)
+ if (fs.existsSync(jar) && sha256(jar) === MUSTANG_SHA256) {
+ return jar
+ }
+
+ fs.mkdirSync(CACHE_DIR, { recursive: true })
+ // Maven Central rate-limits bursts (HTTP 429): back off and retry.
+ for (const delay of [0, 2000, 4000, 8000, 16000, 32000]) {
+ await sleep(delay)
+ console.log(`Downloading ${MUSTANG_URL}`)
+ const response = await fetch(MUSTANG_URL)
+ if (response.ok) {
+ fs.writeFileSync(jar, Buffer.from(await response.arrayBuffer()))
+ break
+ }
+ console.warn(`Download failed with HTTP ${response.status}`)
+ }
+
+ if (!fs.existsSync(jar) || sha256(jar) !== MUSTANG_SHA256) {
+ fs.rmSync(jar, { force: true })
+ throw new Error(`Could not download Mustang ${MUSTANG_VERSION} with the expected checksum`)
+ }
+ return jar
+}
+
+interface Result {
+ file: string
+ valid: boolean
+ pdf: string
+ xml: string
+ messages: string[]
+}
+
+function sectionStatus(report: string, section: 'pdf' | 'xml'): string {
+ const body = new RegExp(`<${section}>([\\s\\S]*?)${section}>`).exec(report)?.[1] ?? ''
+ return //.exec(body)?.[1] ?? 'missing'
+}
+
+function validate(jar: string, file: string): Result {
+ const run = spawnSync('java', ['-jar', jar, '--action', 'validate', '--source', file, '--no-notices'], {
+ encoding: 'utf-8',
+ maxBuffer: 64 * 1024 * 1024,
+ })
+ if (run.error) {
+ throw run.error
+ }
+ const report = run.stdout
+ fs.writeFileSync(file.replace(/\.pdf$/, '.mustang.xml'), report)
+
+ const overall = /\s*<\/validation>/.exec(report)?.[1]
+ const messages = [...report.matchAll(/<(error|warning)[^>]*>([\s\S]*?)<\/\1>/g)].map(
+ (match) => `${match[1]}: ${match[2]?.trim()}`
+ )
+ return {
+ file: path.basename(file),
+ valid: run.status === 0 && overall === 'valid',
+ pdf: sectionStatus(report, 'pdf'),
+ xml: sectionStatus(report, 'xml'),
+ messages,
+ }
+}
+
+async function main() {
+ const samplesDir = path.resolve(process.argv[2] ?? 'einvoice-samples')
+ const pdfs = fs.existsSync(samplesDir)
+ ? fs
+ .readdirSync(samplesDir)
+ .filter((name) => name.endsWith('.pdf'))
+ .sort()
+ : []
+
+ const missing = REQUIRED_SAMPLES.filter((name) => !pdfs.includes(`${name}.pdf`))
+ if (missing.length > 0) {
+ throw new Error(`Missing samples in ${samplesDir}: ${missing.join(', ')} — run npm run einvoice:samples first`)
+ }
+
+ const jar = await ensureMustangJar()
+ console.log(`Validating ${pdfs.length} e-invoices with Mustang ${MUSTANG_VERSION}\n`)
+
+ const results = pdfs.map((name) => validate(jar, path.join(samplesDir, name)))
+ for (const result of results) {
+ console.log(`${result.valid ? 'VALID ' : 'INVALID'} ${result.file} (PDF/A: ${result.pdf}, XML: ${result.xml})`)
+ for (const message of result.messages) {
+ console.log(` ${message}`)
+ }
+ }
+
+ const invalid = results.filter((result) => !result.valid)
+ if (invalid.length > 0) {
+ console.error(`\n${invalid.length} of ${results.length} e-invoices are invalid — see the *.mustang.xml reports`)
+ process.exit(1)
+ }
+ console.log(`\nAll ${results.length} e-invoices are valid.`)
+}
+
+main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err)
+ process.exit(1)
+})
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice-samples.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice-samples.ts
new file mode 100644
index 00000000..85b8142a
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice-samples.ts
@@ -0,0 +1,156 @@
+import {
+ buildInvoiceSnapshot,
+ negateInvoiceSnapshot,
+ renderAndUploadInvoice,
+ type InvoiceOrder,
+ type RenderInvoiceParams,
+} from './invoice-service.js'
+
+/**
+ * Sample documents covering every e-invoice variant ticket orders produce. Rendered
+ * through the real snapshot → `renderAndUploadInvoice` path (with a capturing files
+ * service instead of Directus), so the validated files are exactly what customers get.
+ * Used by `npm run einvoice:validate` and the e-invoice tests.
+ */
+
+const CONFERENCE = { title: 'programmier.con 2026', start_on: '2026-11-25', end_on: '2026-11-26' }
+
+/** Mirrors the checkout's pricing (nuxt-app create-checkout.post.ts). */
+function pricedOrder(
+ base: Omit<
+ InvoiceOrder,
+ 'subtotal_cents' | 'discount_amount_cents' | 'total_cents' | 'total_gross_cents' | 'vat_amount_cents'
+ >,
+ ticketCount: number,
+ basePriceNetCents: number,
+ discountPriceNetCents: number | null = null
+): InvoiceOrder {
+ const unitNet = discountPriceNetCents ?? basePriceNetCents
+ const subtotal = ticketCount * basePriceNetCents
+ const discount = discountPriceNetCents === null ? 0 : ticketCount * (basePriceNetCents - discountPriceNetCents)
+ const totalNet = subtotal - discount
+ const totalGross = ticketCount * Math.round(unitNet * 1.19)
+ return {
+ ...base,
+ subtotal_cents: subtotal,
+ discount_amount_cents: discount,
+ total_cents: totalNet,
+ total_gross_cents: totalGross,
+ vat_amount_cents: totalGross - totalNet,
+ }
+}
+
+const COMPANY_BUYER = {
+ id: 'order-1',
+ order_number: 'ORD-2026-SAMPLE',
+ purchaser_first_name: 'Erika',
+ purchaser_last_name: 'Musterfrau',
+ purchaser_email: 'erika@example.com',
+ company_name: 'Beispiel GmbH',
+ company_vat_id: 'DE 123 456 789',
+ billing_address_line1: 'Musterstraße 1',
+ billing_address_line2: '2. OG',
+ billing_city: 'Frankfurt am Main',
+ billing_postal_code: '60311',
+ billing_country: 'DE',
+}
+
+const PRIVATE_BUYER = {
+ id: 'order-2',
+ order_number: 'ORD-2026-PRIVAT',
+ purchaser_first_name: 'Max',
+ purchaser_last_name: 'Mustermann',
+ purchaser_email: 'max@example.com',
+ company_name: null,
+ company_vat_id: null,
+ billing_address_line1: null,
+ billing_address_line2: null,
+ billing_city: null,
+ billing_postal_code: null,
+ billing_country: null,
+}
+
+export interface EInvoiceSample {
+ /** File name stem, e.g. "rechnung". */
+ name: string
+ pdf: Buffer
+ fileName: string
+}
+
+type SampleParams = Omit
+
+async function render(name: string, params: SampleParams): Promise {
+ const filesService = { uploadOne: async () => 'sample-file' }
+ const result = await renderAndUploadInvoice({ ...params, filesService } as RenderInvoiceParams)
+ return { name, pdf: result.pdfBuffer, fileName: result.invoiceFileName }
+}
+
+/** All sample documents: the three document types plus the edge cases of the amount mapping. */
+export async function renderEInvoiceSamples(): Promise {
+ const invoiceDate = new Date(2026, 9, 20)
+ const correctionDate = new Date(2026, 9, 27)
+
+ // Two tickets bought with a discount code (Rabatt line), company with USt-IdNr.
+ const discounted = pricedOrder({ ...COMPANY_BUYER, ticket_type: 'discounted' }, 2, 39900, 29900)
+ const original = buildInvoiceSnapshot(discounted, CONFERENCE.title, 2, CONFERENCE)
+ // The correction changes the billing address; amounts stay the same.
+ const corrected = buildInvoiceSnapshot(
+ { ...discounted, billing_address_line2: null, billing_city: 'Offenbach am Main', billing_postal_code: '63065' },
+ CONFERENCE.title,
+ 2,
+ CONFERENCE
+ )
+
+ return [
+ await render('rechnung', { snapshot: original, invoiceNumber: 'PB-CON26-001', invoiceDate }),
+ await render('rechnungsberichtigung', {
+ snapshot: corrected,
+ invoiceNumber: 'PB-CON26-002',
+ invoiceDate: correctionDate,
+ documentKind: 'correction',
+ referenceNumber: 'PB-CON26-001',
+ referenceDate: invoiceDate,
+ }),
+ await render('stornorechnung', {
+ snapshot: negateInvoiceSnapshot(corrected),
+ invoiceNumber: 'PB-CON26-003',
+ invoiceDate: correctionDate,
+ documentKind: 'cancellation',
+ referenceNumber: 'PB-CON26-002',
+ referenceDate: correctionDate,
+ }),
+ // Private buyer without any address: BT-55 falls back to DE.
+ await render('rechnung-privat', {
+ snapshot: buildInvoiceSnapshot(
+ pricedOrder({ ...PRIVATE_BUYER, ticket_type: 'regular' }, 1, 39900),
+ CONFERENCE.title,
+ 1,
+ { start_on: '2026-11-25', end_on: '2026-11-25' }
+ ),
+ invoiceNumber: 'PB-CON26-004',
+ invoiceDate,
+ }),
+ // External order with a 100 % discount code: a 0 € invoice.
+ await render('rechnung-0-euro', {
+ snapshot: buildInvoiceSnapshot(
+ pricedOrder({ ...PRIVATE_BUYER, ticket_type: 'discounted' }, 1, 39900, 0),
+ CONFERENCE.title,
+ 1,
+ CONFERENCE
+ ),
+ invoiceNumber: 'PB-CON26-005',
+ invoiceDate,
+ }),
+ // Non-round price × 3: per-ticket VAT rounding is 1 cent off BR-CO-17's exact value.
+ await render('rechnung-rundung', {
+ snapshot: buildInvoiceSnapshot(
+ pricedOrder({ ...COMPANY_BUYER, ticket_type: 'regular' }, 3, 9999),
+ CONFERENCE.title,
+ 3,
+ CONFERENCE
+ ),
+ invoiceNumber: 'PB-CON26-006',
+ invoiceDate,
+ }),
+ ]
+}
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice.ts
new file mode 100644
index 00000000..ce43943e
--- /dev/null
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice.ts
@@ -0,0 +1,458 @@
+import { normalizeBillingCountry } from '../../../../../shared-code/index.ts'
+import type { InvoiceData, InvoiceDocumentKind } from './invoice-generator.js'
+
+/**
+ * ZUGFeRD 2.x / Factur-X e-invoice (profile EN 16931, "COMFORT") for ticket invoices.
+ *
+ * The CII XML is built from the very same `InvoiceData` the PDF is rendered from, and
+ * every amount in it is one of the printed amounts — nothing is recomputed, so the
+ * XML cannot drift from the PDF. `generateInvoicePdf` embeds it as `factur-x.xml`
+ * into a PDF/A-3b, so one file is both the readable invoice and a valid German
+ * E-Rechnung (§ 14 UStG).
+ */
+
+/** Only VAT rate ticket invoices use (Umsatzsteuer, standard rate). */
+export const VAT_RATE_PERCENT = 19
+
+/** File name of the embedded XML mandated by Factur-X 1.0 / ZUGFeRD 2.1+. */
+export const FACTURX_FILENAME = 'factur-x.xml'
+
+/** EN 16931 profile ("COMFORT" in ZUGFeRD terms). */
+export const FACTURX_GUIDELINE_ID = 'urn:cen.eu:en16931:2017'
+export const FACTURX_CONFORMANCE_LEVEL = 'EN 16931'
+export const FACTURX_VERSION = '1.0'
+
+/**
+ * Buyer country (BT-55, mandatory) used when the order carries no country we can map
+ * to an ISO code — private buyers may skip the address entirely. Every invoice is
+ * issued by a German seller for a conference held in Germany, so DE is the only
+ * defensible default.
+ */
+export const FALLBACK_BUYER_COUNTRY = 'DE'
+
+/**
+ * BT-3 invoice type codes (UNTDID 1001):
+ * - 380 commercial invoice — Rechnung
+ * - 384 corrected invoice — Rechnungsberichtigung (replaces the referenced invoice)
+ * - 381 credit note — Stornorechnung. Carries POSITIVE amounts: a credit note over
+ * +X is the e-invoice equivalent of the printed storno over −X. This is how German
+ * e-invoicing software usually transmits a Storno; 389 (self-billed invoice) would be
+ * the UStG "Gutschrift", which a Storno is not.
+ */
+export const INVOICE_TYPE_CODES: Record = {
+ invoice: '380',
+ correction: '384',
+ cancellation: '381',
+}
+
+/** Seller block printed on the PDF and mapped to BG-4 in the XML. */
+export interface InvoiceSeller {
+ name: string
+ street: string
+ postalCode: string
+ city: string
+ /** ISO 3166-1 alpha-2. */
+ countryCode: string
+ countryName: string
+ email: string
+ phone: string
+ /** Steuernummer (BT-32). */
+ taxNumber: string
+ /** USt-IdNr. (BT-31). */
+ vatId: string
+}
+
+/**
+ * Thrown when the invoice data would yield an e-invoice that contradicts the printed
+ * amounts or violates EN 16931. Orders created by the checkout can never trigger it
+ * (their amounts reconcile by construction); it guards hand-edited orders, where
+ * issuing a self-contradictory invoice would be worse than refusing to.
+ */
+export class EInvoiceDataError extends Error {
+ constructor(invoiceNumber: string, reason: string) {
+ super(`Cannot build the e-invoice for ${invoiceNumber}: ${reason}`)
+ this.name = 'EInvoiceDataError'
+ }
+}
+
+/**
+ * Net unit price printed in the line item table. The snapshot stores the gross price
+ * per ticket; the net price is derived from it exactly like the PDF always did.
+ */
+export function unitPriceNetCents(data: Pick): number {
+ return Math.round((data.unitPriceGrossCents * 100) / (100 + VAT_RATE_PERCENT))
+}
+
+/** The printed amounts of an invoice, in cents and with the printed signs. */
+export interface InvoiceAmounts {
+ unitNetCents: number
+ lineNetCents: number
+ discountCents: number
+ taxBasisCents: number
+ vatCents: number
+ totalGrossCents: number
+}
+
+/** Amounts as printed on the PDF: a Stornorechnung carries them negated. */
+export function printedInvoiceAmounts(data: InvoiceData): InvoiceAmounts {
+ const unitNetCents = unitPriceNetCents(data)
+ const lineNetCents = unitNetCents * data.ticketCount
+ return {
+ unitNetCents,
+ lineNetCents,
+ discountCents: data.discountAmountCents,
+ taxBasisCents: lineNetCents - data.discountAmountCents,
+ vatCents: data.vatAmountCents,
+ totalGrossCents: data.totalGrossCents,
+ }
+}
+
+/**
+ * VAT amount EN 16931 BR-CO-17 expects for a taxable amount: taxable × 19 %, rounded
+ * to the cent. The printed VAT is the sum of per-ticket VAT (gross per ticket is
+ * rounded first, see the checkout), which may differ from this by up to half a cent
+ * per ticket. The official CII schematron accepts a deviation of up to 1.00.
+ */
+export function expectedVatCents(taxBasisCents: number): number {
+ const sign = taxBasisCents < 0 ? -1 : 1
+ return sign * Math.round((Math.abs(taxBasisCents) * VAT_RATE_PERCENT) / 100)
+}
+
+/** Largest deviation from BR-CO-17 the EN 16931 CII schematron tolerates (in cents). */
+const BR_CO_17_TOLERANCE_CENTS = 100
+
+function formatAmount(cents: number): string {
+ const sign = cents < 0 ? '-' : ''
+ const abs = Math.abs(cents)
+ return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`
+}
+
+function escapeXml(value: string): string {
+ return (
+ value
+ // Characters XML 1.0 cannot represent at all.
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ )
+}
+
+/** "DD.MM.YYYY" (as printed) → "YYYYMMDD" (UNTDID 2379 format 102). */
+function toFormat102(invoiceNumber: string, printedDate: string): string {
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(printedDate)
+ if (!match) {
+ throw new EInvoiceDataError(invoiceNumber, `unexpected date format "${printedDate}"`)
+ }
+ return `${match[3]}${match[2]}${match[1]}`
+}
+
+/** "YYYY-MM-DD" → "YYYYMMDD", or null. */
+function isoToFormat102(value: string | null | undefined): string | null {
+ const match = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null
+ return match ? `${match[1]}${match[2]}${match[3]}` : null
+}
+
+function dateElement(tag: string, yyyymmdd: string): string {
+ return `<${tag}>${yyyymmdd}${tag}>`
+}
+
+/**
+ * BT-48 must start with an ISO country prefix (BR-CO-09). The checkout takes the
+ * USt-IdNr. as free text, so an unprefixed or malformed value is left out of the XML
+ * (it is optional for a domestic 19 % invoice) — the PDF still prints it as entered.
+ */
+export function normalizeVatId(value: string | null | undefined): string | null {
+ const compact = (value ?? '').replace(/[\s.\-/]/g, '').toUpperCase()
+ return /^[A-Z]{2}[0-9A-Z+*]{2,12}$/.test(compact) ? compact : null
+}
+
+/** BT-55: ISO code of the buyer's country, or the documented fallback. */
+export function buyerCountryCode(billingCountry: string | null | undefined): {
+ code: string
+ isFallback: boolean
+} {
+ const code = normalizeBillingCountry(billingCountry)
+ return code ? { code, isFallback: false } : { code: FALLBACK_BUYER_COUNTRY, isFallback: true }
+}
+
+/** Number and printed date of the corrected/cancelled invoice (BT-25/BT-26). */
+function precedingInvoiceOf(data: InvoiceData): { number: string; date: string } | null {
+ if (data.documentKind === 'correction' || data.documentKind === 'cancellation') {
+ return data.referenceNumber && data.referenceDate
+ ? { number: data.referenceNumber, date: data.referenceDate }
+ : null
+ }
+ return null
+}
+
+/**
+ * Build the Factur-X / ZUGFeRD CII XML (profile EN 16931) for an invoice.
+ *
+ * Amount mapping (all values are the printed ones):
+ * - one line per ticket type: quantity = ticket count, net price = printed Einzelpreis,
+ * line net amount = printed Gesamtpreis
+ * - the printed "Rabatt" line becomes a document-level allowance (BG-20)
+ * - VAT (BT-110/BT-117) = printed MwSt., grand total (BT-112) = printed Rechnungsbetrag
+ * - invoices and corrections were paid upfront: paid amount (BT-113) = total, due 0
+ * - a Stornorechnung is a credit note (381) carrying the printed amounts with the sign
+ * flipped back to positive.
+ */
+export function buildFacturXXml(data: InvoiceData, seller: InvoiceSeller): string {
+ const kind: InvoiceDocumentKind = data.documentKind || 'invoice'
+ const invoiceNumber = data.invoiceNumber
+
+ // A Stornorechnung prints negated amounts; the credit note carries them positive.
+ const sign = kind === 'cancellation' ? -1 : 1
+ const printed = printedInvoiceAmounts(data)
+ const amounts: InvoiceAmounts = {
+ unitNetCents: sign * printed.unitNetCents,
+ lineNetCents: sign * printed.lineNetCents,
+ discountCents: sign * printed.discountCents,
+ taxBasisCents: sign * printed.taxBasisCents,
+ vatCents: sign * printed.vatCents,
+ totalGrossCents: sign * printed.totalGrossCents,
+ }
+
+ if (!Number.isInteger(data.ticketCount) || data.ticketCount <= 0) {
+ throw new EInvoiceDataError(invoiceNumber, `invalid ticket count ${data.ticketCount}`)
+ }
+ if (amounts.unitNetCents < 0 || amounts.discountCents < 0 || amounts.totalGrossCents < 0) {
+ throw new EInvoiceDataError(invoiceNumber, `unexpected negative amounts for a ${kind} document`)
+ }
+ // The printed line total must be the printed Zwischensumme ...
+ if (printed.lineNetCents !== data.subtotalCents) {
+ throw new EInvoiceDataError(
+ invoiceNumber,
+ `line total ${printed.lineNetCents} (unit net × count) does not match the subtotal ${data.subtotalCents}`
+ )
+ }
+ // ... and Zwischensumme − Rabatt + MwSt. must be the printed Rechnungsbetrag.
+ if (amounts.taxBasisCents + amounts.vatCents !== amounts.totalGrossCents) {
+ throw new EInvoiceDataError(
+ invoiceNumber,
+ `net ${amounts.taxBasisCents} + VAT ${amounts.vatCents} does not add up to the total ${amounts.totalGrossCents}`
+ )
+ }
+ if (Math.abs(amounts.vatCents - expectedVatCents(amounts.taxBasisCents)) > BR_CO_17_TOLERANCE_CENTS) {
+ throw new EInvoiceDataError(
+ invoiceNumber,
+ `VAT ${amounts.vatCents} deviates too far from ${VAT_RATE_PERCENT} % of ${amounts.taxBasisCents} (BR-CO-17)`
+ )
+ }
+ const preceding = precedingInvoiceOf(data)
+ if (kind !== 'invoice' && !preceding) {
+ throw new EInvoiceDataError(invoiceNumber, 'missing reference to the corrected/cancelled invoice')
+ }
+
+ const issueDate = toFormat102(invoiceNumber, data.invoiceDate)
+ const amount = (cents: number) => formatAmount(cents)
+ const text = (value: string) => escapeXml(value)
+ const vatCategory = `VATS${VAT_RATE_PERCENT}`
+
+ // --- Notes (BT-22) ---
+ // Same sentence the PDF prints for a correction/cancellation.
+ const notes: string[] = []
+ if (preceding && kind === 'correction') {
+ notes.push(
+ `Diese Rechnungsberichtigung berichtigt und ersetzt die Rechnung Nr. ${preceding.number} vom ${preceding.date}.`
+ )
+ } else if (preceding && kind === 'cancellation') {
+ notes.push(`Diese Stornorechnung storniert die Rechnung Nr. ${preceding.number} vom ${preceding.date}.`)
+ }
+
+ // --- Seller (BG-4) ---
+ const sellerParty = [
+ `${text(seller.name)}`,
+ '',
+ `${text(seller.phone)}`,
+ `${text(seller.email)}`,
+ '',
+ '',
+ `${text(seller.postalCode)}`,
+ `${text(seller.street)}`,
+ `${text(seller.city)}`,
+ `${seller.countryCode}`,
+ '',
+ `${text(seller.email)}`,
+ `${text(seller.taxNumber)}`,
+ `${text(seller.vatId)}`,
+ ]
+
+ // --- Buyer (BG-7) ---
+ const companyName = data.companyName?.trim() || null
+ const country = buyerCountryCode(data.billingCountry)
+ const buyerVatId = normalizeVatId(data.companyVatId)
+ const buyerParty = [`${text(companyName || data.purchaserName)}`]
+ if (companyName) {
+ buyerParty.push(
+ '',
+ `${text(data.purchaserName)}`,
+ `${text(data.purchaserEmail)}`,
+ ''
+ )
+ }
+ buyerParty.push('')
+ if (data.billingPostalCode?.trim()) {
+ buyerParty.push(`${text(data.billingPostalCode.trim())}`)
+ }
+ if (data.billingAddressLine1?.trim()) {
+ buyerParty.push(`${text(data.billingAddressLine1.trim())}`)
+ }
+ if (data.billingAddressLine2?.trim()) {
+ buyerParty.push(`${text(data.billingAddressLine2.trim())}`)
+ }
+ if (data.billingCity?.trim()) {
+ buyerParty.push(`${text(data.billingCity.trim())}`)
+ }
+ buyerParty.push(`${country.code}`, '')
+ if (data.purchaserEmail?.trim()) {
+ buyerParty.push(
+ `${text(data.purchaserEmail.trim())}`
+ )
+ }
+ if (buyerVatId) {
+ buyerParty.push(
+ `${buyerVatId}`
+ )
+ }
+
+ // --- Leistungsdatum: BT-72 for a single day, BG-14 for a multi-day conference ---
+ const serviceStart = isoToFormat102(data.servicePeriodStart) ?? isoToFormat102(data.servicePeriodEnd)
+ const serviceEnd = isoToFormat102(data.servicePeriodEnd)
+ const isServicePeriod = !!serviceStart && !!serviceEnd && serviceEnd > serviceStart
+ const delivery =
+ serviceStart && !isServicePeriod
+ ? `${dateElement('ram:OccurrenceDateTime', serviceStart)}`
+ : ''
+ const billingPeriod =
+ isServicePeriod && serviceStart && serviceEnd
+ ? `${dateElement('ram:StartDateTime', serviceStart)}${dateElement('ram:EndDateTime', serviceEnd)}`
+ : ''
+
+ // --- Payment (BG-16, BT-20, BT-113, BT-115) ---
+ const isCreditNote = kind === 'cancellation'
+ const paidCents = isCreditNote ? 0 : amounts.totalGrossCents
+ const dueCents = amounts.totalGrossCents - paidCents
+ const paymentMeans =
+ !isCreditNote && amounts.totalGrossCents > 0
+ ? '68Online-Zahlung bei der Bestellung (bereits bezahlt)'
+ : ''
+ const paymentTerms = isCreditNote
+ ? `${text(`Storno der Rechnung Nr. ${preceding?.number} vom ${preceding?.date}. Eine Erstattung bereits gezahlter Beträge erfolgt gesondert.`)}`
+ : `Der Rechnungsbetrag wurde bereits vollständig bezahlt.${kind === 'invoice' ? dateElement('ram:DueDateDateTime', issueDate) : ''}`
+
+ const allowance =
+ amounts.discountCents !== 0
+ ? [
+ '',
+ 'false',
+ `${amount(amounts.discountCents)}`,
+ '95',
+ 'Rabatt',
+ `${vatCategory}`,
+ '',
+ ].join('')
+ : ''
+
+ const precedingInvoice = preceding
+ ? `${text(preceding.number)}${toFormat102(invoiceNumber, preceding.date)}`
+ : ''
+
+ const lines = [
+ '',
+ '',
+ '',
+ `${FACTURX_GUIDELINE_ID}`,
+ '',
+ '',
+ `${text(invoiceNumber)}`,
+ `${INVOICE_TYPE_CODES[kind]}`,
+ dateElement('ram:IssueDateTime', issueDate),
+ ...notes.map((note) => `${text(note)}`),
+ '',
+ '',
+ '',
+ '1',
+ `${text(`${data.conferenceTitle} – Ticket ${data.ticketType}`)}`,
+ `${amount(amounts.unitNetCents)}`,
+ `${data.ticketCount}`,
+ '',
+ `${vatCategory}`,
+ `${amount(amounts.lineNetCents)}`,
+ '',
+ '',
+ '',
+ '',
+ ...sellerParty,
+ '',
+ '',
+ ...buyerParty,
+ '',
+ '',
+ `${delivery}`,
+ '',
+ 'EUR',
+ paymentMeans,
+ '',
+ `${amount(amounts.vatCents)}`,
+ 'VAT',
+ `${amount(amounts.taxBasisCents)}`,
+ 'S',
+ `${VAT_RATE_PERCENT}`,
+ '',
+ billingPeriod,
+ allowance,
+ paymentTerms,
+ '',
+ `${amount(amounts.lineNetCents)}`,
+ amounts.discountCents !== 0
+ ? `${amount(amounts.discountCents)}`
+ : '',
+ `${amount(amounts.taxBasisCents)}`,
+ `${amount(amounts.vatCents)}`,
+ `${amount(amounts.totalGrossCents)}`,
+ `${amount(paidCents)}`,
+ `${amount(dueCents)}`,
+ '',
+ precedingInvoice,
+ '',
+ '',
+ '',
+ ]
+
+ return lines.filter((line) => line !== '').join('\n') + '\n'
+}
+
+/**
+ * XMP packet content PDF/A-3 + Factur-X require: the PDF/A extension schema
+ * describing the `fx` properties, and the properties themselves.
+ */
+export function facturXXmpMetadata(): string {
+ const property = (name: string, description: string) =>
+ `${name}Textexternal${description}`
+ return [
+ '',
+ '',
+ 'Factur-X PDFA Extension Schema',
+ 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#',
+ 'fx',
+ '',
+ property('DocumentFileName', 'name of the embedded XML invoice file'),
+ property('DocumentType', 'INVOICE'),
+ property('Version', 'The actual version of the Factur-X XML schema'),
+ property('ConformanceLevel', 'The conformance level of the embedded Factur-X data'),
+ '',
+ '',
+ '',
+ '',
+ 'INVOICE',
+ `${FACTURX_FILENAME}`,
+ `${FACTURX_VERSION}`,
+ `${FACTURX_CONFORMANCE_LEVEL}`,
+ '',
+ ].join('\n')
+}
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts
index 45439a66..5080648a 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts
@@ -1,17 +1,34 @@
import PDFDocument from 'pdfkit'
import { formatBillingCountry } from '../../../../../shared-code/index.ts'
+import {
+ buildFacturXXml,
+ FACTURX_FILENAME,
+ facturXXmpMetadata,
+ unitPriceNetCents,
+ type InvoiceSeller,
+} from './einvoice.js'
import { tryLoadMuseoFont } from './museo-font.js'
-// Seller info (hardcoded)
-const SELLER = {
+// Seller info (hardcoded). Structured so the e-invoice can carry it as BG-4.
+export const SELLER: InvoiceSeller = {
name: 'Lotum media GmbH',
- address: ['Am Goldstein 1', '61231 Bad Nauheim', 'Deutschland'],
+ street: 'Am Goldstein 1',
+ postalCode: '61231',
+ city: 'Bad Nauheim',
+ countryCode: 'DE',
+ countryName: 'Deutschland',
email: 'podcast@programmier.bar',
phone: '+49 (0) 6032 925507 0',
taxNumber: '020 238 63371',
vatId: 'DE272856704',
}
+/** Seller address lines as printed below the header. */
+const SELLER_ADDRESS_LINES = [SELLER.street, `${SELLER.postalCode} ${SELLER.city}`, SELLER.countryName]
+
+/** Shown as creator/producer in the PDF metadata (Info dictionary and XMP must agree for PDF/A). */
+const PDF_PRODUCER = 'programmier.bar invoice generator'
+
// programmier.bar logo SVG paths (from pblogo.svg, viewBox 0 0 280 33)
const LOGO_PATHS = [
// Black paths
@@ -67,6 +84,12 @@ interface InvoiceDataBase {
* "25.–26.11.2026". The line is omitted when no date is known.
*/
serviceDate?: string | null
+ /**
+ * The same Leistungsdatum as first/last day (YYYY-MM-DD) for the e-invoice
+ * (BT-72 or BG-14). Omitted from the XML when unknown, like the printed line.
+ */
+ servicePeriodStart?: string | null
+ servicePeriodEnd?: string | null
// Customer
purchaserName: string
purchaserEmail: string
@@ -176,6 +199,10 @@ function drawLogo(doc: InstanceType, x: number, y: number, s
/**
* Generate an invoice PDF and return it as a Buffer.
+ *
+ * The PDF is a PDF/A-3b with the ZUGFeRD / Factur-X XML (profile EN 16931) of the
+ * same data embedded as `factur-x.xml` — a hybrid e-invoice: readable for private
+ * buyers, machine-readable E-Rechnung (§ 14 UStG) for business buyers.
*/
export function generateInvoicePdf(data: InvoiceData): Promise {
const kind: InvoiceDocumentKind = data.documentKind || 'invoice'
@@ -189,8 +216,35 @@ export function generateInvoicePdf(data: InvoiceData): Promise {
)
}
+ const heading =
+ kind === 'correction' ? 'Rechnungsberichtigung' : kind === 'cancellation' ? 'Stornorechnung' : 'Rechnung'
+
+ // Built before rendering: data that cannot become a valid e-invoice must not
+ // yield a PDF either.
+ let facturXXml: string
+ try {
+ facturXXml = buildFacturXXml(data, SELLER)
+ } catch (err) {
+ return Promise.reject(err)
+ }
+
return new Promise((resolve, reject) => {
- const doc = new PDFDocument({ size: 'A4', margin: 50 })
+ const createdAt = new Date()
+ const doc = new PDFDocument({
+ size: 'A4',
+ margin: 50,
+ // PDF/A-3 is the only PDF/A part that allows embedding the XML invoice.
+ pdfVersion: '1.7',
+ subset: 'PDF/A-3b',
+ lang: 'de-DE',
+ info: {
+ Title: `${heading} ${data.invoiceNumber}`,
+ Author: SELLER.name,
+ Creator: PDF_PRODUCER,
+ Producer: PDF_PRODUCER,
+ CreationDate: createdAt,
+ },
+ })
const chunks: Buffer[] = []
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
@@ -204,13 +258,22 @@ export function generateInvoicePdf(data: InvoiceData): Promise {
doc.font('MuseoSans')
}
+ // --- Embedded e-invoice (ZUGFeRD / Factur-X) ---
+ doc.appendXML(facturXXmpMetadata())
+ doc.file(Buffer.from(facturXXml, 'utf-8'), {
+ name: FACTURX_FILENAME,
+ type: 'text/xml',
+ description: 'Factur-X/ZUGFeRD invoice (EN 16931)',
+ relationship: 'Alternative',
+ creationDate: createdAt,
+ modifiedDate: createdAt,
+ })
+
const pageWidth = doc.page.width - 100 // margins
// --- Header with logo ---
drawLogo(doc, LOGO_ORIGIN.x, LOGO_ORIGIN.y, LOGO_SCALE)
- const heading =
- kind === 'correction' ? 'Rechnungsberichtigung' : kind === 'cancellation' ? 'Stornorechnung' : 'Rechnung'
const headingText = `${heading} Nr.: ${data.invoiceNumber}`
// The heading shares the top row with the logo, so its right-aligned text
@@ -258,7 +321,7 @@ export function generateInvoicePdf(data: InvoiceData): Promise {
doc.fontSize(9).fillColor('#333')
doc.text(SELLER.name, 50, y)
y += 13
- for (const line of SELLER.address) {
+ for (const line of SELLER_ADDRESS_LINES) {
doc.text(line, 50, y)
y += 12
}
@@ -359,13 +422,13 @@ export function generateInvoicePdf(data: InvoiceData): Promise {
y += 22
// Line item – use netto (net) prices
- const unitPriceNetCents = Math.round(data.unitPriceGrossCents / 1.19)
+ const unitNetCents = unitPriceNetCents(data)
doc.fontSize(9).fillColor('#000')
const ticketLabel = `${data.ticketType}\n${data.conferenceTitle}`
doc.text(ticketLabel, col1, y, { width: 230 })
doc.text(String(data.ticketCount), col2, y, { width: 60, align: 'center' })
- doc.text(formatEur(unitPriceNetCents), col3, y, { width: 80, align: 'right' })
- doc.text(formatEur(unitPriceNetCents * data.ticketCount), col4, y, { width: 75, align: 'right' })
+ doc.text(formatEur(unitNetCents), col3, y, { width: 80, align: 'right' })
+ doc.text(formatEur(unitNetCents * data.ticketCount), col4, y, { width: 75, align: 'right' })
y += 35
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts
index cf21ba82..67e093ca 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts
@@ -1,8 +1,11 @@
import { Readable } from 'node:stream'
+import { buyerCountryCode, FALLBACK_BUYER_COUNTRY } from './einvoice.js'
import { generateInvoicePdf, ticketTypeLabel, type InvoiceData, type InvoiceDocumentKind } from './invoice-generator.js'
import { postSlackMessage } from './postSlackMessage.js'
import { uploadWithInvoiceFolderFallback } from './secure-invoice-folder.js'
+export { EInvoiceDataError } from './einvoice.js'
+
/** Order fields required to assemble an invoice (subset of `ticket_orders`). */
export interface InvoiceOrder {
id: string | number
@@ -290,11 +293,18 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi
params.logger?.warn(`Could not send Slack alert for ${invoiceNumber}: ${error?.message ?? error}`)
})
}
+ if (buyerCountryCode(snapshot.billing_country).isFallback) {
+ params.logger?.warn(
+ `Billing country "${snapshot.billing_country ?? ''}" of ${invoiceNumber} has no ISO code — the e-invoice states ${FALLBACK_BUYER_COUNTRY}`
+ )
+ }
const baseData = {
invoiceNumber,
invoiceDate: formatInvoiceDate(invoiceDate),
serviceDate,
+ servicePeriodStart: toIsoDate(snapshot.service_date_start),
+ servicePeriodEnd: toIsoDate(snapshot.service_date_end),
purchaserName: snapshot.purchaser_name,
purchaserEmail: snapshot.purchaser_email,
companyName: snapshot.company_name,
diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/museo-font.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/museo-font.ts
index 69c3d0b6..2151f77e 100644
--- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/museo-font.ts
+++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/museo-font.ts
@@ -5,16 +5,26 @@ import { fileURLToPath } from 'node:url'
/**
* Load the bundled Museo Sans font for PDF rendering.
*
+ * PDF/A requires every font to be embedded: the invoice is only PDF/A-conformant
+ * when this font is found (the standard-font fallback is not embedded).
+ *
* Lives in its own module because `import.meta.url` cannot be parsed by Jest's
* CommonJS transform — tests mock this module and the rest of the invoice
* generator stays testable.
*/
export function tryLoadMuseoFont(): Buffer | null {
- try {
- const distDir = path.dirname(fileURLToPath(import.meta.url))
- const fontPath = path.resolve(distDir, '..', 'assets', 'MuseoSans700.otf')
- return fs.readFileSync(fontPath)
- } catch {
- return null
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url))
+ // dist/api.js in the built bundle; src/shared/museo-font.ts when run from source
+ // (e.g. the e-invoice sample script).
+ for (const fontPath of [
+ path.resolve(moduleDir, '..', 'assets', 'MuseoSans700.otf'),
+ path.resolve(moduleDir, '..', '..', 'assets', 'MuseoSans700.otf'),
+ ]) {
+ try {
+ return fs.readFileSync(fontPath)
+ } catch {
+ // try the next location
+ }
}
+ return null
}
diff --git a/docs/tickets.md b/docs/tickets.md
index b7530534..76731583 100644
--- a/docs/tickets.md
+++ b/docs/tickets.md
@@ -174,7 +174,8 @@ Key-value settings used by extension hooks.
## Invoice Generation
- **Library**: pdfkit (marked as external in Rollup via `extension.config.js`)
-- **Font**: Museo Sans 700 (loaded from `assets/MuseoSans700.otf`, falls back to Helvetica)
+- **Font**: Museo Sans 700 (loaded from `assets/MuseoSans700.otf`, falls back to Helvetica — which is not embedded, so the PDF is then not PDF/A-conformant)
+- **E-invoice**: every document (Rechnung, Rechnungsberichtigung, Stornorechnung) is a hybrid ZUGFeRD 2.x / Factur-X invoice, profile EN 16931: a PDF/A-3b with the CII XML embedded as `factur-x.xml`, built from the same data as the printed page (`shared/einvoice.ts`). Type codes: 380 Rechnung, 384 Rechnungsberichtigung, 381 Stornorechnung (credit note with positive amounts); corrections and stornos reference the original invoice (BT-25/BT-26). A missing buyer country falls back to `DE`. Validate with `npm run einvoice:validate` in the extension bundle (needs Java, see its `TESTING.md`)
- **Number format**: `PB-CON{YY}-{NNN}` (e.g. `PB-CON26-001`), derived from conference `start_on` year
- **Seller**: Lotum media GmbH, Am Goldstein 1, 61231 Bad Nauheim, USt-ID DE272856704
- **Line items**: Show netto (net) prices; VAT (19%) shown separately in totals