From 99627b0dbf3344f0d2036f7c11c24a4d62bc9ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:34:47 +0000 Subject: [PATCH] Issue ticket invoices as ZUGFeRD / Factur-X e-invoices (EN 16931) Every Rechnung, Rechnungsberichtigung and Stornorechnung is now a hybrid e-invoice: a PDF/A-3b with the CII XML (profile EN 16931) embedded as factur-x.xml (AFRelationship Alternative, Factur-X XMP extension schema). The XML is built from the same InvoiceData as the printed page and only carries printed amounts; data that would contradict them is refused. Type codes: 380 invoice, 384 corrected invoice, 381 credit note with positive amounts for the storno; corrections and stornos reference the original via BT-25/BT-26. The rendered page is pixel-identical. pdfkit already supports PDF/A-3b and file attachments, so no new runtime dependency. Samples of every document type are validated with Mustang (veraPDF, CII XSD, EN 16931 + Factur-X Schematron) in a new CI job. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .github/workflows/run_tests.yml | 49 ++ .../.gitignore | 3 + .../TESTING.md | 17 + .../package.json | 4 +- .../invoice-lifecycle/__tests__/index.test.ts | 15 + .../src/invoice-lifecycle/index.ts | 7 +- .../shared/__tests__/einvoice-samples.test.ts | 53 ++ .../src/shared/__tests__/einvoice.test.ts | 300 ++++++++++++ .../__tests__/invoice-generator.test.ts | 13 +- .../shared/__tests__/invoice-service.test.ts | 23 +- .../src/shared/__tests__/pdf-inspect.ts | 46 ++ .../src/shared/cli/validate-einvoices.ts | 138 ++++++ .../src/shared/einvoice-samples.ts | 156 ++++++ .../src/shared/einvoice.ts | 458 ++++++++++++++++++ .../src/shared/invoice-generator.ts | 83 +++- .../src/shared/invoice-service.ts | 10 + .../src/shared/museo-font.ts | 22 +- docs/tickets.md | 3 +- 18 files changed, 1378 insertions(+), 22 deletions(-) create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice-samples.test.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/einvoice.test.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/pdf-inspect.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/cli/validate-einvoices.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice-samples.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/einvoice.ts 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 1b3a9390..b8f4031b 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json @@ -217,7 +217,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 0622e2b6..dfb71bf9 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 @@ -449,6 +449,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 a4f7439e..97e620dc 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 @@ -4,6 +4,7 @@ import type { SandboxEndpointRouter } from 'directus:api' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { buildInvoiceSnapshot, + EInvoiceDataError, ensureInvoiceDocuments, findCancellationFor, findCurrentInvoiceDocument, @@ -201,8 +202,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]*?)`).exec(xml)?.[1] ?? '') : xml + return new RegExp(`<${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 f554565a..a397dfd6 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 @@ -204,6 +204,25 @@ describe('Leistungsdatum (service date)', () => { expect(result.pdfBuffer.subarray(0, 4).toString()).toBe('%PDF') expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/Leistungsdatum.*PB-CON26-001/)) }) + + 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', () => { @@ -269,8 +288,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]*?)`).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}` +} + +/** + * 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 95f4680f..6860e8c2 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 @@ -173,6 +196,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' @@ -186,8 +213,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)) @@ -201,13 +255,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 @@ -255,7 +318,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 } @@ -356,13 +419,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 3e323213..d748f7f7 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,6 +1,9 @@ import { Readable } from 'node:stream' +import { buyerCountryCode, FALLBACK_BUYER_COUNTRY } from './einvoice.js' import { generateInvoicePdf, ticketTypeLabel, type InvoiceData, type InvoiceDocumentKind } from './invoice-generator.js' +export { EInvoiceDataError } from './einvoice.js' + /** Order fields required to assemble an invoice (subset of `ticket_orders`). */ export interface InvoiceOrder { id: string | number @@ -279,11 +282,18 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi if (!serviceDate) { params.logger?.warn(`No Leistungsdatum (conference date) known for ${invoiceNumber} — omitting it from the PDF`) } + 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 780b8a34..14d318b4 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -170,7 +170,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