Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<pdfaid:part>3</pdfaid:part>')
expect(metadata).toContain('<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>')

const xml = extractEmbeddedFile(sample.pdf)
expect(xml).toContain('<ram:ID>urn:cen.eu:en16931:2017</ram:ID>')

if (outDir) {
fs.writeFileSync(path.join(outDir, `${sample.name}.pdf`), sample.pdf)
fs.writeFileSync(path.join(outDir, `${sample.name}.${FACTURX_FILENAME}`), xml)
}
}
})
})
Loading
Loading