Skip to content
Merged
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
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: sodium, dom, fileinfo, gd, iconv, libxml, mbstring, simplexml, zip, zlib
coverage: none
# The Composer the SDK's release workflow pins, so CI resolves as a release does.
tools: composer:2.8.12

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist

- name: Fetch the host's 3.x lock and worked example
run: |
for file in composer.lock tests/Fixtures/modules/invoice-document.standard.json; do
curl --fail --silent --show-error --location \
"https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/3.x/$file" \
--output "$RUNNER_TEMP/$(basename "$file")"
done

- name: The fixture is still the host's worked example
run: diff -u "$RUNNER_TEMP/invoice-document.standard.json" tests/Fixtures/invoice-document.standard.json

- name: Build the bundle from bundle.lock
# Against the host's 3.x branch: a bundle that no longer fits the next
# host release fails here before it fails a release.
run: vendor/bin/invoiceshelf-module bundle . --host-lock="$RUNNER_TEMP/composer.lock"

- name: Verify PHP and package contracts
run: |
composer run lint
composer run test
vendor/bin/invoiceshelf-module validate-module module.json
vendor/bin/invoiceshelf-module validate-package .

- name: Check the EN 16931 business rules
env:
MUSTANG_VERSION: 2.25.0
run: |
curl --fail --silent --show-error --location --output "$RUNNER_TEMP/Mustang-CLI.jar" \
"https://github.com/ZUGFeRD/mustangproject/releases/download/core-${MUSTANG_VERSION}/Mustang-CLI-${MUSTANG_VERSION}.jar"
MUSTANG_JAR="$RUNNER_TEMP/Mustang-CLI.jar" vendor/bin/phpunit --group schematron

- name: Verify frontend
run: |
pnpm install --frozen-lockfile
pnpm run lint
pnpm exec tsc --noEmit
pnpm run build
git diff --exit-code -- dist
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Release module

on:
push:
tags:
- '*'

permissions:
contents: read

jobs:
release:
uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.6.0
with:
channel: stable
# The oldest InvoiceShelf release this version supports; the bundle is built against its composer.lock.
host-version: 3.0.0-alpha.13
secrets: inherit
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# AGENTS.md

Guide for coding agents working on the official E-Invoicing module for InvoiceShelf 3.

- **What it is.** EN 16931 e-invoices: invoice PDFs become ZUGFeRD / Factur-X hybrids and the CII XML downloads on its own. Read the README first.
- **Pipeline.** The host's `InvoiceDocumentReader` hands over an `InvoiceDocument` (SDK typed data, amounts in stored hundredths, credit notes negative). `Document\En16931Mapper` turns it into a syntax-neutral `En16931Invoice` or a list of `Application\Requirement`s; `Document\Writers\CiiWriter` writes CII with horstoeko/zugferd and XSD-validates it. `Application\EInvoiceBuilder` caches written XML by a hash of everything it was built from. A UBL writer would sit next to `CiiWriter` and read the same `En16931Invoice`.
- **Host seams (module API 1.5).** `Pdf\FacturXAttachmentProvider` is registered with `Registry::registerInvoicePdfAttachmentProvider`; the host asks it on every invoice PDF when `PdfCapabilities::embedsFacturX()` is true. Never read host config or models: only SDK contracts.
- **Requirements are stable identifiers.** `Requirement` values are translated in `resources/js/messages.ts` and asserted in tests; never rename one. A new rule gets a new case, a message and a test.
- **Settings** go through `EInvoicingSettings` (keys `module.e-invoicing.*`); `Lifecycle\DataCleanup` must list every key.
- **Frontend.** `resources/js/init.ts` registers the settings page, the invoice panel and the Download XML action. Call the API only through `extensions.client`, ship CSS from `resources/css/module.css` (host utilities are not available), and do not import axios at runtime (it would be bundled). Rebuild and commit `dist/` after any change under `resources/`.
- **Bundle.** `bundle/` is generated from the committed `bundle.lock`; never edit it, and keep tools out of it.
- **Checks.** `composer run lint`, `composer run test`, `MUSTANG_JAR=… vendor/bin/phpunit --group schematron`, `vendor/bin/invoiceshelf-module validate-package .`, `pnpm run lint`, `pnpm exec tsc --noEmit`, `pnpm run build`. CI fails when `dist/` differs from a fresh build or the fixture differs from the host's.
28 changes: 28 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Contributing

## Checks

The same as CI:

```bash
composer install
vendor/bin/invoiceshelf-module bundle . --host-lock=<host composer.lock> # builds bundle/ from bundle.lock
composer run lint
composer run test
MUSTANG_JAR=<Mustang-CLI-2.25.0.jar> vendor/bin/phpunit --group schematron
vendor/bin/invoiceshelf-module validate-module module.json
vendor/bin/invoiceshelf-module validate-package .

pnpm install --frozen-lockfile
pnpm run lint
pnpm exec tsc --noEmit
pnpm run build # commit dist/: the host never runs Node
```

PHP 8.4, Java 21, Node 24, pnpm 11. Mustang CLI is at https://github.com/ZUGFeRD/mustangproject/releases.

A change to what the XML contains needs a test in `tests/Unit/CiiInvoiceBuilderTest.php`, and a new kind of valid invoice belongs in `tests/Support/Scenarios.php`, so the schematron run checks it too. Bump `EInvoiceBuilder::OUTPUT_VERSION` whenever the output changes, so cached XML is rebuilt.

## Releasing

Set `version` in `module.json` and `package.json`, rebuild `dist/`, check `host-version` in `.github/workflows/release.yml` is the oldest InvoiceShelf release the version supports, merge, then push an unprefixed tag equal to the version (`0.1.0`, not `v0.1.0`).
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# InvoiceShelf E-Invoicing module

EN 16931 e-invoices for InvoiceShelf 3. With the module enabled, an invoice PDF becomes a **ZUGFeRD / Factur-X hybrid e-invoice**: a PDF/A-3b file that people read and accounting software reads too, through the EN 16931 XML embedded in it as `factur-x.xml`. The same XML can be downloaded on its own from every invoice.

- **Settings** (company settings, owners only): switch e-invoicing on, the IBAN and BIC the e-invoice asks to be paid to, and payment terms. A checklist shows what the company details still lack.
- **On every invoice**: a panel says whether its PDF carries the XML and, if not, exactly what is missing; a **Download XML** action gets the XML.
- **Every delivery path**: download, public link, customer portal, mail attachment and archive all carry the same XML, because the host embeds it wherever it renders an invoice PDF.
- **Never in the way**: an invoice that does not meet the requirements, a PDF service that cannot embed, or any failure here gives the ordinary PDF. Billing never waits on an e-invoice.

Source and compiled assets are licensed `AGPL-3.0-only`.

## What it covers

| Where | What this version gives you |
| --- | --- |
| Germany (B2B) | A compliant e-invoice: ZUGFeRD in the EN 16931 profile, sent the way you already send invoices. |
| France | Factur-X EN 16931, one of the three formats the reform accepts. Transmission through a certified platform (Plateforme Agréée) is not part of this module. |
| Elsewhere | A valid EN 16931 e-invoice any recipient may accept voluntarily, and the XML for portals that take a file upload. |

Not yet: UBL (Peppol BIS Billing 3.0, XRechnung UBL), sending through Peppol or a platform, and national clearance formats (Italy, Poland, Greece). The module builds an EN 16931 document first and writes CII from it, so a UBL writer is an addition rather than a rewrite.

## Requirements

| Dependency | Constraint |
| --- | --- |
| InvoiceShelf | `>=3.0.0-alpha.13 <4.0.0` |
| Module API | `^1.5.0` (invoice documents and invoice PDF attachments) |
| PHP | `^8.4.0` with `dom`, `fileinfo`, `gd`, `iconv`, `json`, `libxml`, `mbstring`, `simplexml`, `zip`, `zlib` |
| PDF rendering | The **Gotenberg** driver, **8.34 or newer**, as the full image (`gotenberg/gotenberg:8.37.0`, not `-chromium`, which cannot convert to PDF/A). With dompdf the XML download still works; PDFs stay ordinary. |

### What an invoice needs

Company: name, street, post code, city, country and VAT ID (a national tax number alone does not identify the seller under EN 16931), plus an IBAN in the settings. Each invoice: a customer with a billing country, line items with names, net prices, one tax per line, a tax category code on every tax type (set in Tax Types; existing ones start as *Standard rate*, so set zero-rated and exempt ones accordingly, with their exemption reason), and a due date or payment terms. Reverse charge and intra-community supplies need the customer's VAT ID, with its country prefix, in the customer's tax ID. The invoice panel names whatever is missing.

## Local development

You need PHP 8.4, Composer, Node 24, pnpm 11, Java 21 for the conformance checks, and an InvoiceShelf 3.x checkout.

1. **Put the module where the host looks.** Clone it into the host's `Modules/EInvoicing`, or keep it as a sibling checkout and link it: `ln -s ../../module-e-invoicing InvoiceShelf/Modules/EInvoicing` (in the devenv, the sibling directory is also bind-mounted into the PHP container).
2. **Install the module's tools**: `composer install` and `pnpm install`.
3. **Build the bundle** against the host you run: `vendor/bin/invoiceshelf-module bundle . --host-lock=../InvoiceShelf/composer.lock`.
4. **Build the frontend**: `pnpm run build` writes `dist/init.js` and `dist/style.css`. There is no hot reload.
5. **Install it into the host**: `php artisan install:module EInvoicing 0.1.0`, then pick the Gotenberg driver in Administration → PDF Generation (a sidecar on a private address must be named in `GOTENBERG_ALLOWED_PRIVATE_HOST`).
6. **Open** Settings → E-Invoicing, then any invoice.

The tests run without a host, on Orchestra Testbench with the host's contracts faked: `composer run test`. They include the builder tests André Kinne and iebl wrote, ported onto the host's document data. The business rules are checked separately, against Mustang CLI and the CEN EN 16931 schematron:

```bash
MUSTANG_JAR=/path/to/Mustang-CLI-2.25.0.jar vendor/bin/phpunit --group schematron
```

`tests/Fixtures/invoice-document.standard.json` is a copy of the host's worked example (`InvoiceShelf/tests/Fixtures/modules/invoice-document.standard.json`), and CI fails when the two differ.

## Dependencies

The module is `bundled`: [horstoeko/zugferd](https://github.com/horstoeko/zugferd) and the packages it needs that InvoiceShelf does not ship are built into `bundle/` from the committed `bundle.lock`, and signed with each release. Update them with `composer update horstoeko/zugferd` followed by `vendor/bin/invoiceshelf-module bundle . --host-lock=<host composer.lock> --update`, and review the `bundle.lock` diff like code. The SDK README's "Dependencies" section has the rules.

## Data

Settings live in the host's company settings under `module.e-invoicing.*`. The module has no tables. Uninstalling with **Remove module data** deletes those settings for every company.

## Credits

The EN 16931 mapping, its requirements and their tests come from the ZUGFeRD work André Kinne ([0xShinRai](https://github.com/0xShinRai)) and [iebl](https://github.com/iebl) built for InvoiceShelf; this module carries it onto the module system, with credit notes, payment terms, reverse charge and intra-community supplies added.

## Releases

Push a tag equal to `module.json`'s `version` (unprefixed, such as `0.1.0`). The tag workflow uses the SDK's reusable workflow to validate, bundle against the oldest supported InvoiceShelf release, package, sign and register the release with the marketplace. See [CONTRIBUTING.md](CONTRIBUTING.md).
41 changes: 41 additions & 0 deletions app/Application/BuildResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Modules\EInvoicing\Application;

/** Either the e-invoice XML or the requirements standing in its way, never both. */
final class BuildResult
{
/** @param list<Requirement> $missing */
private function __construct(
public readonly ?string $xml,
public readonly array $missing,
) {}

public static function valid(string $xml): self
{
return new self($xml, []);
}

/** @param non-empty-list<Requirement> $missing */
public static function incomplete(array $missing): self
{
if ($missing === []) {
throw new \InvalidArgumentException('An incomplete result names at least one requirement.');
}

return new self(null, $missing);
}

public function isValid(): bool
{
return $this->xml !== null;
}

/** @return list<string> */
public function missingKeys(): array
{
return array_map(static fn (Requirement $requirement): string => $requirement->value, $this->missing);
}
}
75 changes: 75 additions & 0 deletions app/Application/EInvoiceBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Modules\EInvoicing\Application;

use Illuminate\Contracts\Cache\Repository as Cache;
use InvoiceShelf\Modules\Contracts\Host\InvoiceDocumentReader;
use Modules\EInvoicing\Document\En16931Mapper;
use Modules\EInvoicing\Document\Writers\CiiWriter;
use Modules\EInvoicing\Rules\Iban;
use Throwable;

/**
* Builds an invoice's EN 16931 CII XML, or says what it still needs.
*
* The PDF attachment, the XML download and the invoice panel all read this
* one answer. Checking the requirements is cheap; writing and validating the
* XML is not, so a written document is cached under a hash of everything it
* was built from, and any change to the invoice or the settings misses.
*/
final class EInvoiceBuilder
{
/** Bumped whenever the mapping or the writer changes what they produce. */
public const OUTPUT_VERSION = '0.1.0';

public function __construct(
private readonly InvoiceDocumentReader $reader,
private readonly EInvoicingSettings $settings,
private readonly En16931Mapper $mapper,
private readonly CiiWriter $writer,
private readonly Cache $cache,
) {}

/** Null when the invoice does not exist in this company. */
public function build(int $companyId, int $invoiceId): ?BuildResult
{
$document = $this->reader->invoiceDocument($companyId, $invoiceId);

if ($document === null) {
return null;
}

$iban = $this->iban($companyId);
$bic = $this->settings->bic($companyId);
$terms = $this->settings->paymentTerms($companyId);

$mapping = $this->mapper->map($document, $iban, $bic, $terms);

if ($mapping->invoice === null) {
return BuildResult::incomplete($mapping->missing);
}

$key = 'e-invoicing.cii.'.hash('sha256', json_encode([self::OUTPUT_VERSION, $document->toArray(), $iban, $bic, $terms], JSON_THROW_ON_ERROR));

try {
// An empty string records a document that did not validate, so a
// failure is not rebuilt on every view either.
$xml = $this->cache->remember($key, now()->addDay(), fn (): string => $this->writer->write($mapping->invoice) ?? '');
} catch (Throwable $exception) {
report($exception);
$xml = '';
}

return $xml === '' ? BuildResult::incomplete([Requirement::ValidXml]) : BuildResult::valid($xml);
}

/** The settings' IBAN when it is one; an invalid stored value counts as missing. */
public function iban(int $companyId): ?string
{
$iban = $this->settings->iban($companyId);

return $iban !== null && Iban::isValid($iban) ? $iban : null;
}
}
Loading
Loading