From 14b531ff9184962cdd4187edbe2cf84afee67e80 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 24 Oct 2026 04:45:49 +0200 Subject: [PATCH 1/4] EN 16931 e-invoicing: ZUGFeRD / Factur-X hybrid invoice PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds EN 16931 CII XML from the host's invoice document data with a bundled horstoeko/zugferd and hands it to the host to embed in every invoice PDF (module API 1.5). An owners-only settings page holds the switch, IBAN, BIC and payment terms and shows what the company still lacks; each invoice gets a status panel and a Download XML action. The mapping, requirements and builder tests are ported from the work of André Kinne and iebl, with credit notes, payment terms, reverse charge and intra-community supplies added, and checked against Mustang CLI and the CEN EN 16931 schematron. --- .github/workflows/ci.yml | 86 + .github/workflows/release.yml | 18 + AGENTS.md | 12 + CONTRIBUTING.md | 28 + README.md | 68 + app/Application/BuildResult.php | 41 + app/Application/EInvoiceBuilder.php | 75 + app/Application/EInvoicingSettings.php | 100 + app/Application/Readiness.php | 73 + app/Application/Requirement.php | 121 + app/Document/En16931Allowance.php | 16 + app/Document/En16931Invoice.php | 52 + app/Document/En16931Line.php | 22 + app/Document/En16931Mapper.php | 468 ++++ app/Document/En16931Totals.php | 22 + app/Document/En16931VatBreakdown.php | 17 + app/Document/MappingResult.php | 32 + app/Document/Writers/CiiWriter.php | 236 ++ app/Http/CapabilitiesController.php | 21 + app/Http/InvoiceController.php | 61 + app/Http/Requests/SettingsRequest.php | 29 + app/Http/SettingsController.php | 50 + app/Lifecycle/DataCleanup.php | 25 + app/Pdf/FacturXAttachmentProvider.php | 35 + app/Providers/EInvoicingServiceProvider.php | 27 + app/Rules/Bic.php | 23 + app/Rules/Iban.php | 44 + app/Support/Authorizes.php | 62 + app/Support/ModuleRegistration.php | 26 + bundle.lock | 898 +++++++ composer.json | 62 + dist/init.js | 384 +++ dist/style.css | 3 + eslint.config.js | 61 + lang/en/validation.php | 6 + module.json | 49 + package.json | 25 + phpunit.xml.dist | 18 + pint.json | 4 + pnpm-lock.yaml | 2240 +++++++++++++++++ resources/css/module.css | 30 + resources/js/api.ts | 49 + resources/js/components/RequirementList.vue | 11 + resources/js/env.d.ts | 1 + resources/js/extensions/DownloadXmlButton.vue | 63 + resources/js/extensions/InvoicePanel.vue | 55 + resources/js/global.d.ts | 13 + resources/js/init.ts | 76 + resources/js/messages.ts | 62 + resources/js/pages/SettingsPage.vue | 120 + resources/js/shims-css.d.ts | 1 + resources/js/shims-vue.d.ts | 7 + routes/api.php | 18 + tests/Conformance/SchematronTest.php | 129 + tests/Feature/HttpApiTest.php | 110 + tests/Feature/ModuleRegistrationTest.php | 46 + tests/Fixtures/invoice-document.standard.json | 100 + tests/Support/Cii.php | 72 + tests/Support/FakeAuthorization.php | 24 + tests/Support/FakeDocumentReader.php | 36 + tests/Support/FakePdfCapabilities.php | 17 + tests/Support/InvoiceDocuments.php | 81 + tests/Support/MemorySettingsStore.php | 53 + tests/Support/Scenarios.php | 170 ++ tests/TestCase.php | 112 + tests/Unit/BankIdentifierRulesTest.php | 58 + tests/Unit/CiiInvoiceBuilderTest.php | 492 ++++ tests/Unit/EInvoiceBuilderTest.php | 76 + tests/Unit/EInvoicingSettingsTest.php | 54 + tests/Unit/ReadinessAndProviderTest.php | 89 + tsconfig.json | 16 + vite.config.js | 64 + 72 files changed, 7945 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 app/Application/BuildResult.php create mode 100644 app/Application/EInvoiceBuilder.php create mode 100644 app/Application/EInvoicingSettings.php create mode 100644 app/Application/Readiness.php create mode 100644 app/Application/Requirement.php create mode 100644 app/Document/En16931Allowance.php create mode 100644 app/Document/En16931Invoice.php create mode 100644 app/Document/En16931Line.php create mode 100644 app/Document/En16931Mapper.php create mode 100644 app/Document/En16931Totals.php create mode 100644 app/Document/En16931VatBreakdown.php create mode 100644 app/Document/MappingResult.php create mode 100644 app/Document/Writers/CiiWriter.php create mode 100644 app/Http/CapabilitiesController.php create mode 100644 app/Http/InvoiceController.php create mode 100644 app/Http/Requests/SettingsRequest.php create mode 100644 app/Http/SettingsController.php create mode 100644 app/Lifecycle/DataCleanup.php create mode 100644 app/Pdf/FacturXAttachmentProvider.php create mode 100644 app/Providers/EInvoicingServiceProvider.php create mode 100644 app/Rules/Bic.php create mode 100644 app/Rules/Iban.php create mode 100644 app/Support/Authorizes.php create mode 100644 app/Support/ModuleRegistration.php create mode 100644 bundle.lock create mode 100644 composer.json create mode 100644 dist/init.js create mode 100644 dist/style.css create mode 100644 eslint.config.js create mode 100644 lang/en/validation.php create mode 100644 module.json create mode 100644 package.json create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 pnpm-lock.yaml create mode 100644 resources/css/module.css create mode 100644 resources/js/api.ts create mode 100644 resources/js/components/RequirementList.vue create mode 100644 resources/js/env.d.ts create mode 100644 resources/js/extensions/DownloadXmlButton.vue create mode 100644 resources/js/extensions/InvoicePanel.vue create mode 100644 resources/js/global.d.ts create mode 100644 resources/js/init.ts create mode 100644 resources/js/messages.ts create mode 100644 resources/js/pages/SettingsPage.vue create mode 100644 resources/js/shims-css.d.ts create mode 100644 resources/js/shims-vue.d.ts create mode 100644 routes/api.php create mode 100644 tests/Conformance/SchematronTest.php create mode 100644 tests/Feature/HttpApiTest.php create mode 100644 tests/Feature/ModuleRegistrationTest.php create mode 100644 tests/Fixtures/invoice-document.standard.json create mode 100644 tests/Support/Cii.php create mode 100644 tests/Support/FakeAuthorization.php create mode 100644 tests/Support/FakeDocumentReader.php create mode 100644 tests/Support/FakePdfCapabilities.php create mode 100644 tests/Support/InvoiceDocuments.php create mode 100644 tests/Support/MemorySettingsStore.php create mode 100644 tests/Support/Scenarios.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/BankIdentifierRulesTest.php create mode 100644 tests/Unit/CiiInvoiceBuilderTest.php create mode 100644 tests/Unit/EInvoiceBuilderTest.php create mode 100644 tests/Unit/EInvoicingSettingsTest.php create mode 100644 tests/Unit/ReadinessAndProviderTest.php create mode 100644 tsconfig.json create mode 100644 vite.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..46260a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +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 + + - 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 + + # TEMPORARY until SDK 3.6.0 is tagged (InvoiceShelf/modules#15): install + # its branch, then put composer.json back. Replace with a plain + # `composer install` and commit composer.lock once the tag exists. + - name: Install PHP dependencies + run: | + cp composer.json "$RUNNER_TEMP/composer.json" + composer require --no-update "invoiceshelf/modules:dev-feat/invoice-documents as 3.6.0" + # Pin every bundled package to bundle.lock, which a lock-less update would drift from. + composer update --no-interaction --prefer-dist $(php -r '$lock = json_decode(file_get_contents("bundle.lock"), true); foreach ($lock["packages"] as $p) { echo " --with ".$p["name"].":".$p["version"]; }') + cp "$RUNNER_TEMP/composer.json" composer.json + + - name: Fetch the host's 3.x lock and worked example + env: + # TEMPORARY: the worked example lands on 3.x with InvoiceShelf/InvoiceShelf#937; until then read it from that branch. + FIXTURE_REF: feat/module-document-seams + run: | + curl --fail --silent --show-error --location --output "$RUNNER_TEMP/composer.lock" \ + https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/3.x/composer.lock + curl --fail --silent --show-error --location --output "$RUNNER_TEMP/invoice-document.standard.json" \ + "https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/${FIXTURE_REF}/tests/Fixtures/modules/invoice-document.standard.json" + + - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0acd1c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..59dd093 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0c87c92 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +## Checks + +The same as CI: + +```bash +composer install +vendor/bin/invoiceshelf-module bundle . --host-lock= # builds bundle/ from bundle.lock +composer run lint +composer run test +MUSTANG_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`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..570bde8 --- /dev/null +++ b/README.md @@ -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= --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). diff --git a/app/Application/BuildResult.php b/app/Application/BuildResult.php new file mode 100644 index 0000000..e21e56c --- /dev/null +++ b/app/Application/BuildResult.php @@ -0,0 +1,41 @@ + $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 $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 */ + public function missingKeys(): array + { + return array_map(static fn (Requirement $requirement): string => $requirement->value, $this->missing); + } +} diff --git a/app/Application/EInvoiceBuilder.php b/app/Application/EInvoiceBuilder.php new file mode 100644 index 0000000..725c7dd --- /dev/null +++ b/app/Application/EInvoiceBuilder.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/app/Application/EInvoicingSettings.php b/app/Application/EInvoicingSettings.php new file mode 100644 index 0000000..93ee056 --- /dev/null +++ b/app/Application/EInvoicingSettings.php @@ -0,0 +1,100 @@ +store->getCompany($companyId, self::PREFIX.self::ENABLED) === 'YES'; + } + + public function iban(int $companyId): ?string + { + return self::bankIdentifier($this->store->getCompany($companyId, self::PREFIX.self::IBAN)); + } + + public function bic(int $companyId): ?string + { + return self::bankIdentifier($this->store->getCompany($companyId, self::PREFIX.self::BIC)); + } + + public function paymentTerms(int $companyId): ?string + { + $terms = trim((string) $this->store->getCompany($companyId, self::PREFIX.self::PAYMENT_TERMS)); + + return $terms === '' ? null : $terms; + } + + /** @return array{enabled: bool, iban: ?string, bic: ?string, payment_terms: ?string} */ + public function forCompany(int $companyId): array + { + return [ + 'enabled' => $this->isEnabled($companyId), + 'iban' => $this->iban($companyId), + 'bic' => $this->bic($companyId), + 'payment_terms' => $this->paymentTerms($companyId), + ]; + } + + /** @param array{enabled: bool, iban?: ?string, bic?: ?string, payment_terms?: ?string} $values */ + public function save(int $companyId, array $values): void + { + $this->store->putCompany($companyId, self::PREFIX.self::ENABLED, $values['enabled'] ? 'YES' : 'NO'); + + foreach ([self::IBAN, self::BIC] as $key) { + $this->put($companyId, $key, self::bankIdentifier($values[$key] ?? null)); + } + + $terms = trim((string) ($values[self::PAYMENT_TERMS] ?? '')); + $this->put($companyId, self::PAYMENT_TERMS, $terms === '' ? null : $terms); + } + + /** A bank identifier as it is written: no spacing, upper case; null when empty. */ + public static function bankIdentifier(mixed $value): ?string + { + $identifier = strtoupper((string) preg_replace('/\s+/', '', (string) $value)); + + return $identifier === '' ? null : $identifier; + } + + private function put(int $companyId, string $key, ?string $value): void + { + if ($value === null) { + $this->store->deleteCompany($companyId, self::PREFIX.$key); + + return; + } + + $this->store->putCompany($companyId, self::PREFIX.$key, $value); + } +} diff --git a/app/Application/Readiness.php b/app/Application/Readiness.php new file mode 100644 index 0000000..5776709 --- /dev/null +++ b/app/Application/Readiness.php @@ -0,0 +1,73 @@ +} + */ + public function forCompany(int $companyId): array + { + $missing = $this->mapper->missingSellerRequirements($this->reader->seller($companyId), $this->builder->iban($companyId)); + + return [ + 'capable' => $this->capabilities->embedsFacturX(), + 'enabled' => $this->settings->isEnabled($companyId), + 'ready' => $missing === [], + 'missing' => array_map(static fn (Requirement $requirement): string => $requirement->value, $missing), + ]; + } + + /** + * Whether this invoice's PDF carries the XML, and if not, why. Nothing is + * built while e-invoicing is off. `fallback` is true when the company + * issues e-invoices but this invoice goes out as an ordinary PDF. + * + * @return array{capable: bool, enabled: bool, ready: bool, fallback: bool, missing: list}|null + */ + public function forInvoice(int $companyId, int $invoiceId): ?array + { + $capable = $this->capabilities->embedsFacturX(); + + if (! $this->settings->isEnabled($companyId)) { + return ['capable' => $capable, 'enabled' => false, 'ready' => false, 'fallback' => false, 'missing' => []]; + } + + $result = $this->builder->build($companyId, $invoiceId); + + if ($result === null) { + return null; + } + + return [ + 'capable' => $capable, + 'enabled' => true, + 'ready' => $result->isValid(), + 'fallback' => ! $capable || ! $result->isValid(), + 'missing' => $result->missingKeys(), + ]; + } +} diff --git a/app/Application/Requirement.php b/app/Application/Requirement.php new file mode 100644 index 0000000..17859ca --- /dev/null +++ b/app/Application/Requirement.php @@ -0,0 +1,121 @@ + $lines + * @param list $allowances + * @param list $vatBreakdown + */ + public function __construct( + public readonly string $number, + public readonly string $typeCode, + public readonly string $issueDate, + public readonly string $currencyCode, + public readonly DocumentParty $seller, + public readonly DocumentParty $buyer, + public readonly ?string $buyerVatId, + public readonly array $lines, + public readonly array $allowances, + public readonly array $vatBreakdown, + public readonly En16931Totals $totals, + public readonly ?string $buyerReference = null, + public readonly ?string $note = null, + public readonly ?RelatedDocument $precedingInvoice = null, + public readonly ?PostalAddress $deliverTo = null, + public readonly ?string $iban = null, + public readonly ?string $bic = null, + public readonly ?string $dueDate = null, + public readonly ?string $paymentTerms = null, + ) {} +} diff --git a/app/Document/En16931Line.php b/app/Document/En16931Line.php new file mode 100644 index 0000000..a029215 --- /dev/null +++ b/app/Document/En16931Line.php @@ -0,0 +1,22 @@ +missingRequirements($invoice, $iban, $paymentTerms); + + if ($missing !== []) { + return MappingResult::incomplete($missing); + } + + return MappingResult::mapped($this->compose($invoice, $iban, $bic, $paymentTerms)); + } + + /** + * The requirements the company's own data does not meet: the gaps that hold + * for every invoice it will issue, and the only ones it can close in its + * settings. Empty means ready, not that any particular invoice will pass. + * + * @return list + */ + public function missingSellerRequirements(?DocumentParty $seller, ?string $iban): array + { + $missing = []; + $this->collectSellerRequirements($missing, $seller, $iban, true); + + return $missing; + } + + /** @return list */ + private function missingRequirements(InvoiceDocument $invoice, ?string $iban, ?string $paymentTerms): array + { + $missing = []; + $buyer = $invoice->buyer; + + $this->requireValue($missing, Requirement::InvoiceNumber, $invoice->number); + $this->requireValue($missing, Requirement::InvoiceDate, $invoice->invoiceDate); + $this->requireValue($missing, Requirement::InvoiceCurrency, $invoice->currencyCode); + + $this->collectSellerRequirements($missing, $invoice->seller, $iban, ! $invoice->isCreditNote()); + + $this->requireValue($missing, Requirement::BuyerName, $buyer?->name); + $this->requireValue($missing, Requirement::BuyerCountry, $buyer?->address?->countryCode); + + if ($invoice->taxIncluded) { + $this->add($missing, Requirement::NetPrices); + } + + if ($this->positions($invoice) === []) { + $this->add($missing, Requirement::LineItems); + } + + foreach ($invoice->lines as $line) { + if (trim($line->name) === '') { + $this->add($missing, Requirement::LineItemName); + } + + if ($this->signed($invoice, $line->price) < 0 && $this->signed($invoice, $line->total) >= 0) { + $this->add($missing, Requirement::NegativePrice); + } + } + + foreach ($this->taxesPerLine($invoice) as $lineTaxes) { + if ($lineTaxes === []) { + $this->add($missing, Requirement::InvoiceTax); + } elseif (count($lineTaxes) > 1) { + $this->add($missing, Requirement::SingleTaxRatePerLine); + } + } + + foreach ($this->applicableTaxes($invoice) as $tax) { + $category = $tax->categoryCode; + + if ($category === null || $category === '') { + $this->add($missing, Requirement::TaxCategoryCode); + + continue; + } + + if (! in_array($category, self::SUPPORTED_CATEGORIES, true)) { + $this->add($missing, Requirement::UnsupportedTaxCategory); + + continue; + } + + if ($tax->calculationType === DocumentTax::CALCULATION_FIXED || $tax->percent === null) { + $this->add($missing, Requirement::TaxRatePercentage); + } elseif (! $this->rateFitsCategory($category, (float) $tax->percent)) { + $this->add($missing, Requirement::TaxRateForCategory); + } + + if (in_array($category, self::EXEMPT_CATEGORIES, true) && trim((string) $tax->exemptionReason) === '') { + $this->add($missing, Requirement::TaxExemptionReason); + } + + if (in_array($category, self::BUYER_VAT_CATEGORIES, true) && $this->buyerVatId($invoice) === null) { + $this->add($missing, Requirement::BuyerVatId); + } + } + + if ($this->signed($invoice, $invoice->dueAmount) > 0 && $invoice->dueDate === null && $paymentTerms === null) { + $this->add($missing, Requirement::PaymentTerms); + } + + return $missing; + } + + /** + * The seller's name, postal address, VAT identifier and, for documents + * that ask for payment, the IBAN. + * + * @param list $missing + */ + private function collectSellerRequirements(array &$missing, ?DocumentParty $seller, ?string $iban, bool $needsIban): void + { + $address = $seller?->address; + + $this->requireValue($missing, Requirement::SellerName, $seller?->name); + $this->requireValue($missing, Requirement::SellerStreet, $address?->street1); + $this->requireValue($missing, Requirement::SellerPostcode, $address?->zip); + $this->requireValue($missing, Requirement::SellerCity, $address?->city); + $this->requireValue($missing, Requirement::SellerCountry, $address?->countryCode); + + // BR-CO-26 accepts only BT-29, BT-30 or BT-31 as the seller's + // identification, and of those the host holds the VAT identifier + // (BT-31). A national tax number is BT-32, which the rule does not + // count, so a tax number alone reads as not ready. + $this->requireValue($missing, Requirement::SellerTaxRegistration, $seller?->vatId); + + if ($needsIban && $iban === null) { + $this->add($missing, Requirement::SellerIban); + } + } + + /** @param list $missing */ + private function requireValue(array &$missing, Requirement $requirement, ?string $value): void + { + if ($value === null || trim($value) === '') { + $this->add($missing, $requirement); + } + } + + /** @param list $missing */ + private function add(array &$missing, Requirement $requirement): void + { + if (! in_array($requirement, $missing, true)) { + $missing[] = $requirement; + } + } + + /** + * The taxes that apply to each line, whichever way the invoice taxes: a + * document tax applies to every line, which is what EN 16931 asks a line + * to state (BG-30). + * + * @return list> + */ + private function taxesPerLine(InvoiceDocument $invoice): array + { + return array_map( + static fn (DocumentLine $line): array => $invoice->taxPerItem ? $line->taxes : $invoice->taxes, + $invoice->lines, + ); + } + + /** @return list */ + private function applicableTaxes(InvoiceDocument $invoice): array + { + if (! $invoice->taxPerItem) { + return $invoice->taxes; + } + + return array_merge([], ...array_map(static fn (DocumentLine $line): array => $line->taxes, $invoice->lines)); + } + + /** + * The lines that become invoice lines (BG-25): all but the deductions. + * + * @return list + */ + private function positions(InvoiceDocument $invoice): array + { + return array_values(array_filter($invoice->lines, fn (DocumentLine $line): bool => ! $this->isDeduction($invoice, $line))); + } + + /** + * A line whose amount is negative, such as a down payment already + * settled. EN 16931 allows no negative net price (BR-27), so it travels as + * a document allowance (BG-20) in the VAT group it was taxed in. + */ + private function isDeduction(InvoiceDocument $invoice, DocumentLine $line): bool + { + return $this->signed($invoice, $line->total) < 0; + } + + /** + * Standard-rated is above 0 %; zero-rated, exempt, reverse charge, + * intra-community and export are exactly 0 %. + */ + private function rateFitsCategory(string $category, float $percent): bool + { + return $category === 'S' ? $percent > 0 : abs($percent) < 0.0001; + } + + /** An amount as the document states it: a credit note's stored amounts turned positive. */ + private function signed(InvoiceDocument $invoice, int $amount): int + { + return $invoice->isCreditNote() ? -$amount : $amount; + } + + /** + * The buyer's VAT identifier (BT-48). The customer's tax ID is an untyped + * field that may hold a VAT identifier or a national tax number; only a + * value with a VAT country prefix is taken as BT-48, so a national number + * is never misstated as one. + */ + private function buyerVatId(InvoiceDocument $invoice): ?string + { + $candidate = strtoupper((string) preg_replace('/[\s.\-]+/', '', (string) $invoice->buyer?->taxId)); + + return preg_match(self::VAT_ID_PATTERN, $candidate) === 1 ? $candidate : null; + } + + /** Only called once every requirement is met, so the data it reads is there. */ + private function compose(InvoiceDocument $invoice, ?string $iban, ?string $bic, ?string $paymentTerms): En16931Invoice + { + $groups = $this->vatGroups($invoice); + $lineTotal = array_sum(array_column($groups, 'lineTotal')); + $discount = max(0, $this->signed($invoice, $invoice->discountVal)); + $shares = $this->allocate($discount, array_column($groups, 'lineTotal')); + + $breakdown = []; + $allowances = []; + $deducted = 0; + $taxTotal = 0; + $taxBasis = 0; + + foreach ($groups as $index => $group) { + $deduction = array_sum(array_column($group['deductions'], 'amount')); + $basis = $group['lineTotal'] - $deduction - $shares[$index]; + $deducted += $deduction; + $taxBasis += $basis; + $taxTotal += $group['tax']; + + $breakdown[] = new En16931VatBreakdown($group['category'], $group['rate'], $basis, $group['tax'], $group['exemptionReason']); + + foreach ($group['deductions'] as $entry) { + $allowances[] = new En16931Allowance($entry['amount'], $entry['reason'] ?: self::DEDUCTION_REASON, $group['category'], $group['rate']); + } + + if ($shares[$index] > 0) { + $allowances[] = new En16931Allowance($shares[$index], self::DISCOUNT_REASON, $group['category'], $group['rate']); + } + } + + $grandTotal = $taxBasis + $taxTotal; + $prepaid = max(0, $this->signed($invoice, $invoice->total) - $this->signed($invoice, $invoice->dueAmount)); + $buyer = $invoice->buyer ?? new DocumentParty(''); + $categories = array_column($groups, 'category'); + + return new En16931Invoice( + number: $invoice->number, + typeCode: $invoice->isCreditNote() ? En16931Invoice::TYPE_CREDIT_NOTE : En16931Invoice::TYPE_INVOICE, + issueDate: $invoice->invoiceDate, + currencyCode: strtoupper($invoice->currencyCode), + seller: $invoice->seller, + buyer: $buyer, + buyerVatId: $this->buyerVatId($invoice), + lines: $this->lines($invoice), + allowances: $allowances, + vatBreakdown: $breakdown, + totals: new En16931Totals($lineTotal, $discount + $deducted, $taxBasis, $taxTotal, $grandTotal, $prepaid, $grandTotal - $prepaid), + // On a credit note the reference number holds the corrected + // invoice's number, which is BG-3, not the buyer's reference. + buyerReference: $invoice->isCreditNote() ? null : $this->filled($invoice->referenceNumber), + note: $this->filled(self::plainText($invoice->notes)), + precedingInvoice: $invoice->isCreditNote() ? $invoice->related : null, + // An intra-community supply names the country goods went to (BR-IC-12). + deliverTo: in_array('K', $categories, true) ? ($invoice->deliveryAddress?->countryCode ? $invoice->deliveryAddress : $buyer->address) : null, + iban: $invoice->isCreditNote() ? null : $iban, + bic: $invoice->isCreditNote() ? null : $bic, + dueDate: $invoice->dueDate, + paymentTerms: $paymentTerms, + ); + } + + /** @return list */ + private function lines(InvoiceDocument $invoice): array + { + $lines = []; + + foreach ($this->positions($invoice) as $index => $line) { + $tax = $this->lineTax($invoice, $line); + + $lines[] = new En16931Line( + id: (string) ($index + 1), + name: self::plainText($line->name), + description: $this->filled(self::plainText($line->description)), + quantity: (float) $line->quantity, + unitCode: $line->unitCode ?: 'C62', + netPrice: abs($line->price), + allowance: max(0, $this->signed($invoice, $line->discountVal)), + netAmount: $this->signed($invoice, $line->total), + categoryCode: (string) $tax->categoryCode, + rate: round((float) $tax->percent, 2), + ); + } + + return $lines; + } + + /** + * The VAT groups, one per category and rate, in the order the lines + * introduce them. `lineTotal` sums the net amounts of the invoice lines in + * the group; a deduction line is a `deductions` entry of the group it was + * taxed in, and `tax` counts it with its negative sign. + * + * @return list, tax: int}> + */ + private function vatGroups(InvoiceDocument $invoice): array + { + $groups = []; + + foreach ($invoice->lines as $line) { + $tax = $this->lineTax($invoice, $line); + $category = (string) $tax->categoryCode; + $rate = round((float) $tax->percent, 2); + $key = $category.'@'.$rate; + + $groups[$key] ??= [ + 'category' => $category, + 'rate' => $rate, + 'exemptionReason' => in_array($category, self::EXEMPT_CATEGORIES, true) ? $this->filled(self::plainText($tax->exemptionReason)) : null, + 'lineTotal' => 0, + 'deductions' => [], + 'tax' => 0, + ]; + + if ($this->isDeduction($invoice, $line)) { + $groups[$key]['deductions'][] = ['reason' => self::plainText($line->name), 'amount' => abs($line->total)]; + } else { + $groups[$key]['lineTotal'] += $this->signed($invoice, $line->total); + } + + if ($invoice->taxPerItem) { + $groups[$key]['tax'] += $this->signed($invoice, $tax->amount); + } + } + + if (! $invoice->taxPerItem) { + // One document tax on the whole invoice: its stored amount belongs + // to the single group every line shares. + foreach (array_keys($groups) as $key) { + $groups[$key]['tax'] = $this->signed($invoice, $invoice->taxes[0]->amount); + } + } + + return array_values($groups); + } + + /** + * Split an amount across weights in whole hundredths, losing none. + * + * @param list $weights + * @return list + */ + private function allocate(int $amount, array $weights): array + { + $total = array_sum($weights); + $remaining = $amount; + $shares = []; + $last = count($weights) - 1; + + foreach ($weights as $index => $weight) { + if ($index === $last) { + $shares[] = $remaining; + + break; + } + + $share = $total === 0 ? 0 : (int) round($amount * $weight / $total); + $shares[] = $share; + $remaining -= $share; + } + + return $shares; + } + + /** The single tax that applies to a line. */ + private function lineTax(InvoiceDocument $invoice, DocumentLine $line): DocumentTax + { + return $invoice->taxPerItem ? $line->taxes[0] : $invoice->taxes[0]; + } + + private function filled(?string $value): ?string + { + return $value === null || trim($value) === '' ? null : $value; + } + + /** + * Rich text as the plain text an XML element can carry: markup resolved to + * line breaks, and the control characters no XML document may hold dropped. + */ + public static function plainText(?string $value): string + { + if ($value === null) { + return ''; + } + + $text = preg_replace('//i', "\n", $value); + $text = html_entity_decode(strip_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', (string) $text); + + return trim((string) $text); + } +} diff --git a/app/Document/En16931Totals.php b/app/Document/En16931Totals.php new file mode 100644 index 0000000..3d63f17 --- /dev/null +++ b/app/Document/En16931Totals.php @@ -0,0 +1,22 @@ + $missing */ + private function __construct( + public readonly ?En16931Invoice $invoice, + public readonly array $missing, + ) {} + + public static function mapped(En16931Invoice $invoice): self + { + return new self($invoice, []); + } + + /** @param non-empty-list $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); + } +} diff --git a/app/Document/Writers/CiiWriter.php b/app/Document/Writers/CiiWriter.php new file mode 100644 index 0000000..7157484 --- /dev/null +++ b/app/Document/Writers/CiiWriter.php @@ -0,0 +1,236 @@ +compose($invoice); + + if ((new ZugferdXsdValidator($document))->validate()->hasValidationErrors()) { + return null; + } + + $xml = $document->getContent(); + + return $this->isWellFormed($xml) ? $xml : null; + } + + private function compose(En16931Invoice $invoice): ZugferdDocumentBuilder + { + $document = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931); + + $document->setDocumentInformation( + $invoice->number, + $invoice->typeCode === En16931Invoice::TYPE_CREDIT_NOTE ? ZugferdInvoiceType::CREDITNOTE : ZugferdInvoiceType::INVOICE, + $this->date($invoice->issueDate), + $invoice->currencyCode, + ); + + // The invoice carries no delivery date of its own, but the delivery + // block must not stay empty: Peppol rejects empty elements + // (PEPPOL-EN16931-R008) and German UStG § 14 expects a date of supply. + // Until the data can state one, the issue date is the delivery date + // (BT-72), the reading recipients apply to an invoice that names none. + $document->setDocumentSupplyChainEvent($this->date($invoice->issueDate)); + + if ($invoice->buyerReference !== null) { + $document->setDocumentBuyerReference($invoice->buyerReference); + } + + if ($invoice->note !== null) { + $document->addDocumentNote($invoice->note); + } + + if ($invoice->precedingInvoice !== null) { + $document->setDocumentInvoiceReferencedDocument( + $invoice->precedingInvoice->number, + null, + $invoice->precedingInvoice->date ? $this->date($invoice->precedingInvoice->date) : null, + ); + } + + $this->writeParties($document, $invoice); + $this->writePayment($document, $invoice); + $this->writeLines($document, $invoice); + $this->writeTotals($document, $invoice); + + return $document; + } + + private function writeParties(ZugferdDocumentBuilder $document, En16931Invoice $invoice): void + { + $seller = $invoice->seller; + $document->setDocumentSeller($seller->name); + $document->setDocumentSellerAddress(...$this->postalAddress($seller->address)); + + if ($seller->vatId !== null) { + $document->addDocumentSellerVATRegistrationNumber($seller->vatId); + } + + if ($seller->taxId !== null) { + $document->addDocumentSellerTaxNumber($seller->taxId); + } + + $buyer = $invoice->buyer; + $document->setDocumentBuyer($buyer->name); + + if ($buyer->companyName !== null && $buyer->companyName !== '') { + $document->setDocumentBuyerLegalOrganisation(null, null, $buyer->companyName); + } + + $document->setDocumentBuyerAddress(...$this->postalAddress($buyer->address)); + + if ($invoice->buyerVatId !== null) { + $document->addDocumentBuyerVATRegistrationNumber($invoice->buyerVatId); + } + + if ($invoice->deliverTo !== null) { + $document->setDocumentShipTo($buyer->name); + $document->setDocumentShipToAddress(...$this->postalAddress($invoice->deliverTo)); + } + } + + /** + * The credit transfer (BG-16) and the payment terms. BG-17 identifies the + * bank by its BIC alone; no bank name belongs in the XML. + */ + private function writePayment(ZugferdDocumentBuilder $document, En16931Invoice $invoice): void + { + if ($invoice->iban !== null) { + $document->addDocumentPaymentMeanToCreditTransfer($invoice->iban, $invoice->seller->name, null, $invoice->bic); + } + + if ($invoice->dueDate !== null || $invoice->paymentTerms !== null) { + $document->addDocumentPaymentTerm( + $invoice->paymentTerms, + $invoice->dueDate !== null ? $this->date($invoice->dueDate) : null, + ); + } + } + + private function writeLines(ZugferdDocumentBuilder $document, En16931Invoice $invoice): void + { + foreach ($invoice->lines as $line) { + $document->addNewPosition($line->id); + $document->setDocumentPositionProductDetails($line->name, $line->description); + $document->setDocumentPositionNetPrice($this->amount($line->netPrice)); + $document->setDocumentPositionQuantity($line->quantity, $line->unitCode); + $document->addDocumentPositionTax($line->categoryCode, ZugferdVatTypeCodes::VALUE_ADDED_TAX, $line->rate); + + if ($line->allowance > 0) { + $document->addDocumentPositionAllowanceCharge( + $this->amount($line->allowance), false, null, null, ZugferdAllowanceCodes::DISCOUNT, 'Discount', + ); + } + + $document->setDocumentPositionLineSummation($this->amount($line->netAmount)); + } + } + + private function writeTotals(ZugferdDocumentBuilder $document, En16931Invoice $invoice): void + { + foreach ($invoice->vatBreakdown as $group) { + $document->addDocumentTax( + $group->categoryCode, + ZugferdVatTypeCodes::VALUE_ADDED_TAX, + $this->amount($group->basis), + $this->amount($group->tax), + $group->rate, + $group->exemptionReason, + ); + } + + foreach ($invoice->allowances as $allowance) { + $document->addDocumentAllowanceCharge( + $this->amount($allowance->amount), + false, + $allowance->categoryCode, + ZugferdVatTypeCodes::VALUE_ADDED_TAX, + $allowance->rate, + null, null, null, null, null, + ZugferdAllowanceCodes::DISCOUNT, + $allowance->reason, + ); + } + + $totals = $invoice->totals; + $document->setDocumentSummation( + $this->amount($totals->grandTotal), + $this->amount($totals->duePayable), + $this->amount($totals->lineTotal), + $this->amount(0), + $this->amount($totals->allowanceTotal), + $this->amount($totals->taxBasis), + $this->amount($totals->taxTotal), + null, + $this->amount($totals->prepaid), + ); + } + + /** + * An address as a CII postal address takes it: street, second line, third + * line, post code, city, country code, subdivision. + * + * @return array{?string, ?string, null, ?string, ?string, ?string, ?string} + */ + private function postalAddress(?PostalAddress $address): array + { + return [ + $address?->street1, + $address?->street2, + null, + $address?->zip, + $address?->city, + $address?->countryCode, + $address?->state, + ]; + } + + private function date(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value); + } + + /** Hundredths as the decimal amount EN 16931 states. */ + private function amount(int $hundredths): float + { + return round($hundredths / 100, 2); + } + + private function isWellFormed(string $xml): bool + { + if ($xml === '') { + return false; + } + + $previous = libxml_use_internal_errors(true); + $wellFormed = (new DOMDocument)->loadXML($xml) !== false; + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + return $wellFormed; + } +} diff --git a/app/Http/CapabilitiesController.php b/app/Http/CapabilitiesController.php new file mode 100644 index 0000000..68c21a7 --- /dev/null +++ b/app/Http/CapabilitiesController.php @@ -0,0 +1,21 @@ +json([ + 'manage_settings' => $authorizes->canManageSettings($request), + 'view_invoices' => $authorizes->canViewInvoices($request), + ]); + } +} diff --git a/app/Http/InvoiceController.php b/app/Http/InvoiceController.php new file mode 100644 index 0000000..13f3765 --- /dev/null +++ b/app/Http/InvoiceController.php @@ -0,0 +1,61 @@ +authorizes->requireInvoices($request); + + $report = $this->readiness->forInvoice(Authorizes::companyId($request), $invoice); + + abort_if($report === null, 404); + + return response()->json($report); + } + + public function xml(Request $request, int $invoice): Response|JsonResponse + { + $this->authorizes->requireInvoices($request); + $companyId = Authorizes::companyId($request); + + $result = $this->builder->build($companyId, $invoice); + + abort_if($result === null, 404); + + if (! $result->isValid()) { + return response()->json(['missing' => $result->missingKeys()], 422); + } + + $number = (string) $this->reader->invoiceDocument($companyId, $invoice)?->number; + $filename = preg_replace('/[^A-Za-z0-9._-]+/', '-', $number ?: 'invoice').'.xml'; + + return response((string) $result->xml, 200, [ + 'Content-Type' => 'application/xml', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } +} diff --git a/app/Http/Requests/SettingsRequest.php b/app/Http/Requests/SettingsRequest.php new file mode 100644 index 0000000..3eaa1ff --- /dev/null +++ b/app/Http/Requests/SettingsRequest.php @@ -0,0 +1,29 @@ +canManageSettings($this); + } + + /** @return array> */ + public function rules(): array + { + return [ + 'enabled' => ['required', 'boolean'], + 'iban' => ['nullable', 'string', 'max:42', new Iban], + 'bic' => ['nullable', 'string', 'max:15', new Bic], + 'payment_terms' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/SettingsController.php b/app/Http/SettingsController.php new file mode 100644 index 0000000..0db19fb --- /dev/null +++ b/app/Http/SettingsController.php @@ -0,0 +1,50 @@ +authorizes->requireSettings($request); + + return $this->respond(Authorizes::companyId($request)); + } + + public function update(SettingsRequest $request): JsonResponse + { + $companyId = Authorizes::companyId($request); + + $this->settings->save($companyId, [ + 'enabled' => $request->boolean('enabled'), + 'iban' => $request->validated('iban'), + 'bic' => $request->validated('bic'), + 'payment_terms' => $request->validated('payment_terms'), + ]); + + return $this->respond($companyId); + } + + private function respond(int $companyId): JsonResponse + { + return response()->json([ + 'settings' => $this->settings->forCompany($companyId), + 'readiness' => $this->readiness->forCompany($companyId), + ]); + } +} diff --git a/app/Lifecycle/DataCleanup.php b/app/Lifecycle/DataCleanup.php new file mode 100644 index 0000000..574028e --- /dev/null +++ b/app/Lifecycle/DataCleanup.php @@ -0,0 +1,25 @@ +settings->deleteCompanyForAll(EInvoicingSettings::PREFIX.$key); + } + } +} diff --git a/app/Pdf/FacturXAttachmentProvider.php b/app/Pdf/FacturXAttachmentProvider.php new file mode 100644 index 0000000..75c881a --- /dev/null +++ b/app/Pdf/FacturXAttachmentProvider.php @@ -0,0 +1,35 @@ +settings->isEnabled($companyId); + } + + public function attachmentFor(int $companyId, int $invoiceId): ?FacturXAttachment + { + $result = $this->builder->build($companyId, $invoiceId); + + return $result?->isValid() ? new FacturXAttachment((string) $result->xml) : null; + } +} diff --git a/app/Providers/EInvoicingServiceProvider.php b/app/Providers/EInvoicingServiceProvider.php new file mode 100644 index 0000000..a2448ee --- /dev/null +++ b/app/Providers/EInvoicingServiceProvider.php @@ -0,0 +1,27 @@ +name)); + $this->loadRoutesFrom(module_path($this->name, 'routes/api.php')); + } +} diff --git a/app/Rules/Bic.php b/app/Rules/Bic.php new file mode 100644 index 0000000..7bc1d3f --- /dev/null +++ b/app/Rules/Bic.php @@ -0,0 +1,23 @@ +translate(); + } + } +} diff --git a/app/Rules/Iban.php b/app/Rules/Iban.php new file mode 100644 index 0000000..37400cd --- /dev/null +++ b/app/Rules/Iban.php @@ -0,0 +1,44 @@ +translate(); + } + } + + public static function isValid(string $iban): bool + { + if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $iban) !== 1) { + return false; + } + + // Move the first four characters to the end, letters to 10..35, and + // take the remainder piecewise so no number outgrows an integer. + $digits = ''; + foreach (str_split(substr($iban, 4).substr($iban, 0, 4)) as $character) { + $digits .= ctype_alpha($character) ? (string) (ord($character) - 55) : $character; + } + + $remainder = 0; + foreach (str_split($digits, 7) as $chunk) { + $remainder = (int) ($remainder.$chunk) % 97; + } + + return $remainder === 1; + } +} diff --git a/app/Support/Authorizes.php b/app/Support/Authorizes.php new file mode 100644 index 0000000..56ffaa7 --- /dev/null +++ b/app/Support/Authorizes.php @@ -0,0 +1,62 @@ +header('company'); + } + + public function canManageSettings(Request $request): bool + { + return $this->allows($request, Registry::abilityId(self::SLUG, self::MANAGE_SETTINGS)); + } + + public function canViewInvoices(Request $request): bool + { + return $this->allows($request, 'view-invoice', 'invoice'); + } + + /** @throws AuthorizationException */ + public function requireSettings(Request $request): void + { + if (! $this->canManageSettings($request)) { + throw new AuthorizationException('Managing e-invoicing settings requires the '.Registry::abilityId(self::SLUG, self::MANAGE_SETTINGS).' ability.'); + } + } + + /** @throws AuthorizationException */ + public function requireInvoices(Request $request): void + { + if (! $this->canViewInvoices($request)) { + throw new AuthorizationException('This requires the view-invoice ability.'); + } + } + + private function allows(Request $request, string $ability, ?string $resource = null): bool + { + $userId = $request->user()?->getAuthIdentifier(); + + return $userId !== null && $this->authorization->allows((int) $userId, self::companyId($request), $ability, $resource); + } +} diff --git a/app/Support/ModuleRegistration.php b/app/Support/ModuleRegistration.php new file mode 100644 index 0000000..fc493e2 --- /dev/null +++ b/app/Support/ModuleRegistration.php @@ -0,0 +1,26 @@ + Authorizes::MANAGE_SETTINGS, + 'name' => 'Manage e-invoicing settings', + 'owner_only' => true, + ]); + + Registry::registerInvoicePdfAttachmentProvider(Authorizes::SLUG, FacturXAttachmentProvider::class); + } +} diff --git a/bundle.lock b/bundle.lock new file mode 100644 index 0000000..a29ebb5 --- /dev/null +++ b/bundle.lock @@ -0,0 +1,898 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6b166faa2f1dc89fafe4d95cf560cb4b", + "packages": [ + { + "name": "doctrine/instantiator", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2026-01-05T06:47:08+00:00" + }, + { + "name": "goetas-webservices/xsd2php-runtime", + "version": "0.2.18", + "source": { + "type": "git", + "url": "https://github.com/goetas-webservices/xsd2php-runtime.git", + "reference": "1215ce8504f2726b1a0c5025478da86a2791a0ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/goetas-webservices/xsd2php-runtime/zipball/1215ce8504f2726b1a0c5025478da86a2791a0ba", + "reference": "1215ce8504f2726b1a0c5025478da86a2791a0ba", + "shasum": "" + }, + "require": { + "jms/serializer": "^1.2|^2.0|^3.0", + "php": ">=7.1", + "symfony/yaml": "^2.2|^3.0|^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "conflict": { + "jms/serializer": "1.4.1|1.6.1|1.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.2-dev" + } + }, + "autoload": { + "psr-4": { + "GoetasWebservices\\Xsd\\XsdToPhpRuntime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Asmir Mustafic" + } + ], + "description": "Convert XSD (XML Schema) definitions into PHP classes", + "keywords": [ + "converter", + "jms", + "php", + "serializer", + "xml", + "xsd" + ], + "support": { + "issues": "https://github.com/goetas-webservices/xsd2php-runtime/issues", + "source": "https://github.com/goetas-webservices/xsd2php-runtime/tree/0.2.18" + }, + "time": "2026-01-16T19:24:00+00:00" + }, + { + "name": "horstoeko/mimedb", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/horstoeko/mimedb.git", + "reference": "2d50f2b6bf63f14741514682869b53fc97232308" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/horstoeko/mimedb/zipball/2d50f2b6bf63f14741514682869b53fc97232308", + "reference": "2d50f2b6bf63f14741514682869b53fc97232308", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "pdepend/pdepend": "^2", + "phploc/phploc": "^7", + "phpmd/phpmd": "^2", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9", + "sebastian/phpcpd": "^6", + "squizlabs/php_codesniffer": "^3" + }, + "type": "package", + "autoload": { + "psr-4": { + "horstoeko\\mimedb\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Erling", + "email": "daniel@erling.com.de", + "role": "lead" + } + ], + "description": "Get mimetypes by fileextensions and visa versa", + "homepage": "https://github.com/horstoeko/mimedb", + "keywords": [ + "file extension", + "mimetype" + ], + "support": { + "issues": "https://github.com/horstoeko/mimedb/issues", + "source": "https://github.com/horstoeko/mimedb/tree/v1.0.8" + }, + "time": "2024-12-03T10:57:28+00:00" + }, + { + "name": "horstoeko/stringmanagement", + "version": "v1.0.12", + "source": { + "type": "git", + "url": "https://github.com/horstoeko/stringmanagement.git", + "reference": "d304a094e34c39d35f275bccf4944176dd8f1722" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/horstoeko/stringmanagement/zipball/d304a094e34c39d35f275bccf4944176dd8f1722", + "reference": "d304a094e34c39d35f275bccf4944176dd8f1722", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "nette/php-generator": "*", + "pdepend/pdepend": "^2", + "phpdocumentor/reflection-docblock": "^5", + "phploc/phploc": "^7", + "phpmd/phpmd": "^2", + "phpstan/phpstan": "^1|^2", + "phpunit/phpunit": "^9", + "rector/rector": "*", + "sebastian/phpcpd": "^6", + "squizlabs/php_codesniffer": "^3" + }, + "type": "package", + "autoload": { + "psr-4": { + "horstoeko\\stringmanagement\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Erling", + "email": "daniel@erling.com.de", + "role": "lead" + } + ], + "description": "A library for string manipulation utilities", + "homepage": "https://github.com/horstoeko/stringmanagement", + "keywords": [ + "stringmanagement" + ], + "support": { + "issues": "https://github.com/horstoeko/stringmanagement/issues", + "source": "https://github.com/horstoeko/stringmanagement/tree/v1.0.12" + }, + "time": "2025-03-15T09:04:46+00:00" + }, + { + "name": "horstoeko/zugferd", + "version": "v1.0.133", + "source": { + "type": "git", + "url": "https://github.com/horstoeko/zugferd.git", + "reference": "7ae81641e2ca1992b5b0afbce00309ed4d989e4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/horstoeko/zugferd/zipball/7ae81641e2ca1992b5b0afbce00309ed4d989e4c", + "reference": "7ae81641e2ca1992b5b0afbce00309ed4d989e4c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "ext-zip": "*", + "goetas-webservices/xsd2php-runtime": "^0.2.13", + "horstoeko/mimedb": "^1", + "horstoeko/stringmanagement": "^1", + "jms/serializer": "^3", + "php": ">=7.3", + "setasign/fpdf": "^1", + "setasign/fpdi": "^2", + "smalot/pdfparser": "^0|^2", + "symfony/finder": "^5|^6|^7|^8", + "symfony/process": "^5|^6|^7|^8", + "symfony/validator": "^5|^6|^7|^8", + "symfony/yaml": "^5|^6|^7|^8" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "goetas-webservices/xsd2php": "^0", + "nette/php-generator": "*", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpdocumentor/reflection-docblock": "^6", + "phpstan/phpstan": "^1|^2.1", + "phpstan/phpstan-deprecation-rules": "^1|^2", + "phpstan/phpstan-strict-rules": "^1|^2", + "phpunit/phpunit": "^9.6", + "rector/rector": "^2" + }, + "type": "package", + "autoload": { + "psr-4": { + "horstoeko\\zugferd\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Erling", + "email": "daniel@erling.com.de", + "role": "lead" + } + ], + "description": "A library for creating and reading european electronic invoices", + "homepage": "https://github.com/horstoeko/zugferd", + "keywords": [ + "ZUGFeRD", + "electronic", + "factur-x", + "invoice", + "xrechnung" + ], + "support": { + "issues": "https://github.com/horstoeko/zugferd/issues", + "source": "https://github.com/horstoeko/zugferd/tree/v1.0.133" + }, + "funding": [ + { + "url": "https://paypal.me/horstoeko", + "type": "custom" + } + ], + "time": "2026-10-20T12:44:04+00:00" + }, + { + "name": "jms/metadata", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/metadata.git", + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330", + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "require-dev": { + "doctrine/cache": "^1.0|^2.0", + "doctrine/coding-standard": "^8.0", + "mikey179/vfsstream": "^1.6.7", + "phpunit/phpunit": "^8.5.42|^9.6.23", + "psr/container": "^1.0|^2.0", + "symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Metadata\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "Class/method/property metadata management in PHP", + "keywords": [ + "annotations", + "metadata", + "xml", + "yaml" + ], + "support": { + "issues": "https://github.com/schmittjoh/metadata/issues", + "source": "https://github.com/schmittjoh/metadata/tree/2.9.0" + }, + "time": "2025-11-30T20:12:26+00:00" + }, + { + "name": "jms/serializer", + "version": "3.32.9", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/serializer.git", + "reference": "25cf987fa31d5f412feaae29745a5421cb803773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/25cf987fa31d5f412feaae29745a5421cb803773", + "reference": "25cf987fa31d5f412feaae29745a5421cb803773", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.3.1 || ^2.0", + "doctrine/lexer": "^2.0 || ^3.0", + "jms/metadata": "^2.6", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^1.20 || ^2.0" + }, + "require-dev": { + "doctrine/annotations": "^1.14 || ^2.0", + "doctrine/coding-standard": "^12.0", + "doctrine/orm": "^2.14 || ^3.0", + "doctrine/persistence": "^2.5.2 || ^3.0", + "doctrine/phpcr-odm": "^1.5.2 || ^2.0", + "ext-pdo_sqlite": "*", + "jackalope/jackalope-doctrine-dbal": "^1.3", + "ocramius/proxy-manager": "^1.0 || ^2.0", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.0 || ^10.0 || ^11.0", + "psr/container": "^1.0 || ^2.0", + "rector/rector": "^1.0.0 || ^2.0", + "symfony/clock": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/form": "^5.4.45 || ^6.4.27 || ^7.0 || ^8.0", + "symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/uid": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "twig/twig": "^1.34 || ^2.4 || ^3.0" + }, + "suggest": { + "doctrine/collections": "Required if you like to use doctrine collection types as ArrayCollection.", + "symfony/cache": "Required if you like to use cache functionality.", + "symfony/uid": "Required if you'd like to serialize UID objects.", + "symfony/yaml": "Required if you'd like to use the YAML metadata format." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "JMS\\Serializer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "Library for (de-)serializing data of any complexity; supports XML, and JSON.", + "homepage": "http://jmsyst.com/libs/serializer", + "keywords": [ + "deserialization", + "jaxb", + "json", + "serialization", + "xml" + ], + "support": { + "issues": "https://github.com/schmittjoh/serializer/issues", + "source": "https://github.com/schmittjoh/serializer/tree/3.32.9" + }, + "funding": [ + { + "url": "https://github.com/goetas", + "type": "github" + }, + { + "url": "https://github.com/scyzoryck", + "type": "github" + } + ], + "time": "2026-09-04T14:41:03+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.6", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + }, + "time": "2026-09-27T20:07:45+00:00" + }, + { + "name": "setasign/fpdf", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDF.git", + "reference": "051b70e4c57dedc88df41b1eff1c62894e5f9ed0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDF/zipball/051b70e4c57dedc88df41b1eff1c62894e5f9ed0", + "reference": "051b70e4c57dedc88df41b1eff1c62894e5f9ed0", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "ext-zlib": "*" + }, + "type": "library", + "autoload": { + "classmap": [ + "fpdf.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Olivier Plathey", + "email": "oliver@fpdf.org", + "homepage": "http://fpdf.org/" + } + ], + "description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.", + "homepage": "http://www.fpdf.org", + "keywords": [ + "fpdf", + "pdf" + ], + "support": { + "source": "https://github.com/Setasign/FPDF/tree/1.9.0" + }, + "time": "2026-05-31T14:25:29+00:00" + }, + { + "name": "setasign/fpdi", + "version": "v2.6.9", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDI.git", + "reference": "d97ade6368aa0b51b6316d5dc16a567e2bf60167" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/d97ade6368aa0b51b6316d5dc16a567e2bf60167", + "reference": "d97ade6368aa0b51b6316d5dc16a567e2bf60167", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "php": ">=7.2 <=8.6.99999" + }, + "conflict": { + "setasign/tfpdf": "<1.31" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52", + "setasign/fpdf": "^1.9.0", + "setasign/tfpdf": "~1.33", + "tecnickcom/tcpdf": "^6.8" + }, + "suggest": { + "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + }, + "type": "library", + "autoload": { + "psr-4": { + "setasign\\Fpdi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + }, + { + "name": "Maximilian Kresse", + "email": "maximilian.kresse@setasign.com", + "homepage": "https://www.setasign.com" + } + ], + "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", + "homepage": "https://www.setasign.com/fpdi", + "keywords": [ + "fpdf", + "fpdi", + "pdf" + ], + "support": { + "issues": "https://github.com/Setasign/FPDI/issues", + "source": "https://github.com/Setasign/FPDI/tree/v2.6.9" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "type": "tidelift" + } + ], + "time": "2026-10-13T07:36:28+00:00" + }, + { + "name": "smalot/pdfparser", + "version": "v2.13.1", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "a71c6dd38e60a995fdb4d36e4433b8b3557c65dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/a71c6dd38e60a995fdb4d36e4433b8b3557c65dc", + "reference": "a71c6dd38e60a995fdb4d36e4433b8b3557c65dc", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.13.1" + }, + "time": "2026-10-13T08:49:45+00:00" + }, + { + "name": "symfony/validator", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "fc0dd260b03d572ce1a2aaa9da2c6b40e3ccf8a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/fc0dd260b03d572ce1a2aaa9da2c6b40e3ccf8a1", + "reference": "fc0dd260b03d572ce1a2aaa9da2c6b40e3ccf8a1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/doctrine-bridge": "<7.4", + "symfony/expression-language": "<7.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T21:44:42+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.0.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "38e4b36d74a20dd9124a5b34c3e83e270b9649b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/38e4b36d74a20dd9124a5b34c3e83e270b9649b8", + "reference": "38e4b36d74a20dd9124a5b34c3e83e270b9649b8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.0.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:20:43+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.4.0" + }, + "plugin-api-version": "2.6.0" +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ef53c07 --- /dev/null +++ b/composer.json @@ -0,0 +1,62 @@ +{ + "name": "invoiceshelf/module-e-invoicing", + "description": "Official EN 16931 e-invoicing module for InvoiceShelf: ZUGFeRD / Factur-X hybrid invoice PDFs", + "license": "AGPL-3.0-only", + "authors": [ + { + "name": "InvoiceShelf", + "email": "hello@invoiceshelf.com" + } + ], + "require": { + "php": "^8.4", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-zip": "*", + "ext-zlib": "*", + "horstoeko/zugferd": "^1.0.133", + "invoiceshelf/modules": "^3.6" + }, + "require-dev": { + "laravel/pint": "^1.26", + "orchestra/testbench": "^11.0", + "phpunit/phpunit": "^12.0" + }, + "autoload": { + "psr-4": { + "Modules\\EInvoicing\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\EInvoicing\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "lint": "vendor/bin/pint --test" + }, + "config": { + "allow-plugins": { + "wikimedia/composer-merge-plugin": false + }, + "platform": { + "php": "8.4.0" + }, + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true, + "repositories": { + "invoiceshelf-modules": { + "type": "vcs", + "url": "https://github.com/InvoiceShelf/modules.git" + } + } +} diff --git a/dist/init.js b/dist/init.js new file mode 100644 index 0000000..16826e5 --- /dev/null +++ b/dist/init.js @@ -0,0 +1,384 @@ +const { Fragment: e, computed: t, createBlock: n, createCommentVNode: r, createElementBlock: i, createElementVNode: a, createTextVNode: o, createVNode: s, defineComponent: c, h: l, normalizeClass: u, onMounted: d, openBlock: f, reactive: p, ref: m, renderList: h, resolveComponent: g, toDisplayString: _, watch: v, withCtx: y, withModifiers: b } = window.__invoiceshelf_vue; +// resources/js/api.ts +var x = { + capabilities: "/api/v1/e-invoicing/capabilities", + settings: "/api/v1/e-invoicing/settings", + readiness: (e) => `/api/v1/e-invoicing/invoices/${e}/readiness`, + xml: (e) => `/api/v1/e-invoicing/invoices/${e}/xml` +}, S = [ + "seller_name", + "seller_street", + "seller_postcode", + "seller_city", + "seller_country", + "seller_tax_registration", + "seller_iban" +]; +function C(e) { + return typeof e == "object" && e && "response" in e ? e.response ?? null : null; +} +// resources/js/messages.ts +var w = { en: { e_invoicing: { + title: "E-Invoicing", + description: "Issue invoices as ZUGFeRD / Factur-X e-invoices: the PDF carries the EN 16931 XML that accounting software reads.", + enable: "Embed the e-invoice in invoice PDFs", + enable_desc: "Every invoice PDF of this company that meets the requirements becomes a hybrid e-invoice. The others stay ordinary PDFs.", + bank_details: "Payment details", + iban: "IBAN", + iban_help: "The account the e-invoice asks to be paid to. Required on invoices.", + bic: "BIC", + payment_terms: "Payment terms", + payment_terms_help: "Written on every e-invoice, and required on one without a due date.", + save: "Save", + saved: "E-invoicing settings saved.", + load_failed: "Could not load the e-invoicing settings.", + save_failed: "Could not save the e-invoicing settings.", + not_capable: "Invoice PDFs cannot carry the XML yet: e-invoices need the Gotenberg PDF driver, version 8.34 or newer, as the full image (not -chromium). Your settings are kept, and the XML can still be downloaded from each invoice.", + ready: "Your company details are complete for e-invoices.", + not_ready: "Complete these before invoices can become e-invoices:", + panel: { + title: "E-invoice", + ready: "This invoice's PDF carries its EN 16931 XML (ZUGFeRD / Factur-X).", + not_capable: "This invoice meets the requirements, but the PDF service cannot embed the XML, so its PDF is an ordinary one.", + fallback: "This invoice goes out as an ordinary PDF, without the e-invoice XML. Missing:", + settings_link: "E-invoicing settings" + }, + download_xml: "Download XML", + xml_unavailable: "No e-invoice XML for this invoice yet. Missing: {missing}", + xml_failed: "Could not download the e-invoice XML.", + requirements: { + invoice_number: "An invoice number", + invoice_date: "An invoice date", + invoice_currency: "An invoice currency", + seller_name: "Your company name", + seller_street: "Your company street address", + seller_postcode: "Your company post code", + seller_city: "Your company city", + seller_country: "Your company country", + seller_tax_registration: "Your company VAT ID", + seller_iban: "An IBAN in the e-invoicing settings", + buyer_name: "A customer name", + buyer_country: "The customer's billing country", + buyer_vat_id: "The customer's VAT ID, with its country prefix, in the customer's tax ID (reverse charge and intra-community supplies need it)", + line_items: "At least one line item", + line_item_name: "A name on every line item", + net_prices: "Net prices: tax-inclusive invoices cannot become e-invoices", + tax_category_code: "A tax type with a tax category code on every tax", + tax_rate_percentage: "A percentage tax: fixed-amount taxes cannot become e-invoices", + invoice_tax: "A tax on every line item", + single_tax_rate_per_line: "A single tax per line item", + tax_exemption_reason: "An exemption reason on the exempt tax type", + negative_price: "No negative price on a line with a positive amount (deductions such as a down payment are fine)", + tax_rate_for_category: "A tax rate that fits its category: above 0 % for standard rate, exactly 0 % for zero-rated, exempt and the like", + unsupported_tax_category: "A supported tax category: outside the scope of tax (O), Canary Islands (L) and Ceuta and Melilla (M) are not supported yet", + payment_terms: "A due date on the invoice, or payment terms in the e-invoicing settings", + valid_xml: "Valid e-invoice XML could not be produced from this invoice" + } +} } }, T = /* @__PURE__ */ c({ + __name: "DownloadXmlButton", + props: { + context: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + t: { type: Function } + }, + setup(e) { + let t = e, r = m(!1); + async function i() { + let e = t.context.record(); + if (e) { + r.value = !0; + try { + let n = await t.client.get(x.xml(e.id), { responseType: "blob" }), r = /filename="([^"]+)"/.exec(String(n.headers["content-disposition"] ?? ""))?.[1] ?? `${e.number ?? e.id}.xml`, i = URL.createObjectURL(n.data), a = document.createElement("a"); + a.href = i, a.download = r, a.click(), URL.revokeObjectURL(i); + } catch (e) { + t.notify("error", await a(e)); + } finally { + r.value = !1; + } + } + } + async function a(e) { + let n = C(e); + if (n?.status === 422 && n.data instanceof Blob) try { + let e = (JSON.parse(await n.data.text()).missing ?? []).map((e) => t.t(`e_invoicing.requirements.${e}`)).join("; "); + return t.t("e_invoicing.xml_unavailable", { missing: e }); + } catch {} + return t.t("e_invoicing.xml_failed"); + } + return (e, t) => { + let a = g("BaseIcon"), c = g("BaseButton"); + return f(), n(c, { + variant: "primary-outline", + loading: r.value, + disabled: r.value, + onClick: i + }, { + left: y((e) => [s(a, { + name: "CodeBracketIcon", + class: u(e.class) + }, null, 8, ["class"])]), + default: y(() => [o(" " + _(e.$t("e_invoicing.download_xml")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]); + }; + } +}), E = { class: "mt-2 list-disc space-y-1 ps-5 text-sm" }, D = /* @__PURE__ */ c({ + __name: "RequirementList", + props: { missing: {} }, + setup(t) { + return (n, r) => (f(), i("ul", E, [(f(!0), i(e, null, h(t.missing, (e) => (f(), i("li", { key: e }, _(n.$t(`e_invoicing.requirements.${e}`)), 1))), 128))])); + } +}), O = { class: "flex items-start gap-3" }, k = { class: "min-w-0 text-sm text-body" }, A = { class: "font-medium text-heading" }, j = { + key: 0, + class: "mt-1" +}, M = { + key: 1, + class: "mt-1" +}, N = { class: "mt-1" }, P = { + key: 0, + class: "mt-2" +}, F = /* @__PURE__ */ c({ + __name: "InvoicePanel", + props: { + context: {}, + client: { type: [Function, Object] } + }, + setup(c) { + let l = c, d = m(null), p = t(() => l.context.record()?.id ?? null), h = t(() => !!(d.value?.ready && d.value.capable)), b = t(() => d.value?.missing.some((e) => S.includes(e)) ?? !1); + return v(p, async (e) => { + if (d.value = null, e !== null) try { + let { data: t } = await l.client.get(x.readiness(e)); + p.value === e && (d.value = t); + } catch { + d.value = null; + } + }, { immediate: !0 }), (t, c) => { + let l = g("BaseIcon"), p = g("router-link"), m = g("BaseCard"); + return d.value && d.value.enabled ? (f(), n(m, { + key: 0, + "container-class": "px-4 py-3" + }, { + default: y(() => [a("div", O, [s(l, { + name: h.value ? "CheckBadgeIcon" : "ExclamationTriangleIcon", + class: u(["mt-0.5 h-5 w-5 shrink-0", h.value ? "text-status-green" : "text-status-yellow"]) + }, null, 8, ["name", "class"]), a("div", k, [a("p", A, _(t.$t("e_invoicing.panel.title")), 1), h.value ? (f(), i("p", j, _(t.$t("e_invoicing.panel.ready")), 1)) : d.value.ready ? (f(), i("p", M, _(t.$t("e_invoicing.panel.not_capable")), 1)) : (f(), i(e, { key: 2 }, [ + a("p", N, _(t.$t("e_invoicing.panel.fallback")), 1), + s(D, { missing: d.value.missing }, null, 8, ["missing"]), + b.value ? (f(), i("p", P, [s(p, { + class: "text-primary-500 underline", + to: "/admin/settings/e-invoicing" + }, { + default: y(() => [o(_(t.$t("e_invoicing.panel.settings_link")), 1)]), + _: 1 + })])) : r("", !0) + ], 64))])])]), + _: 1 + })) : r("", !0); + }; + } +}), I = { + key: 0, + class: "py-6 text-sm text-muted" +}, L = { + key: 0, + class: "rounded-lg bg-alert-warning-bg p-4 text-sm text-alert-warning-text", + role: "status" +}, R = { class: "grid gap-6 md:grid-cols-2" }, z = /* @__PURE__ */ c({ + __name: "SettingsPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + t: { type: Function } + }, + setup(e) { + let c = e, l = m(!0), h = m(!1), v = m(null), S = m(null), w = p({ + iban: null, + bic: null, + payment_terms: null + }), T = t({ + get: () => v.value?.iban ?? "", + set: (e) => { + v.value && (v.value.iban = e); + } + }), E = t({ + get: () => v.value?.bic ?? "", + set: (e) => { + v.value && (v.value.bic = e); + } + }), O = t({ + get: () => v.value?.payment_terms ?? "", + set: (e) => { + v.value && (v.value.payment_terms = e); + } + }); + d(() => { + A(); + }); + function k(e) { + v.value = { ...e.settings }, S.value = e.readiness; + } + async function A() { + l.value = !0; + try { + let { data: e } = await c.client.get(x.settings); + k(e); + } catch { + c.notify("error", c.t("e_invoicing.load_failed")); + } finally { + l.value = !1; + } + } + async function j() { + if (v.value) { + h.value = !0, Object.keys(w).forEach((e) => { + w[e] = null; + }); + try { + let { data: e } = await c.client.put(x.settings, v.value); + k(e), c.notify("success", c.t("e_invoicing.saved")); + } catch (e) { + let t = C(e), n = t?.status === 422 ? t.data?.errors : null; + if (n) for (let e of Object.keys(w)) w[e] = n[e]?.[0] ?? null; + else c.notify("error", c.t("e_invoicing.save_failed")); + } finally { + h.value = !1; + } + } + } + return (e, t) => { + let c = g("BaseSwitchSection"), d = g("BaseInput"), p = g("BaseInputGroup"), m = g("BaseTextarea"), x = g("BaseButton"), C = g("BaseSettingCard"); + return f(), n(C, { + title: e.$t("e_invoicing.title"), + description: e.$t("e_invoicing.description") + }, { + default: y(() => [l.value ? (f(), i("div", I, "…")) : v.value ? (f(), i("form", { + key: 1, + class: "space-y-6", + onSubmit: b(j, ["prevent"]) + }, [ + S.value && !S.value.capable ? (f(), i("div", L, _(e.$t("e_invoicing.not_capable")), 1)) : r("", !0), + S.value ? (f(), i("div", { + key: 1, + class: u(["rounded-lg p-4 text-sm", S.value.ready ? "bg-alert-success-bg text-alert-success-text" : "bg-alert-warning-bg text-alert-warning-text"]), + role: "status" + }, [a("p", null, _(S.value.ready ? e.$t("e_invoicing.ready") : e.$t("e_invoicing.not_ready")), 1), S.value.ready ? r("", !0) : (f(), n(D, { + key: 0, + missing: S.value.missing + }, null, 8, ["missing"]))], 2)) : r("", !0), + s(c, { + modelValue: v.value.enabled, + "onUpdate:modelValue": t[0] ||= (e) => v.value.enabled = e, + title: e.$t("e_invoicing.enable"), + description: e.$t("e_invoicing.enable_desc") + }, null, 8, [ + "modelValue", + "title", + "description" + ]), + a("div", R, [s(p, { + label: e.$t("e_invoicing.iban"), + "help-text": e.$t("e_invoicing.iban_help"), + error: w.iban + }, { + default: y(() => [s(d, { + modelValue: T.value, + "onUpdate:modelValue": t[1] ||= (e) => T.value = e, + autocomplete: "off", + invalid: !!w.iban + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "help-text", + "error" + ]), s(p, { + label: e.$t("e_invoicing.bic"), + error: w.bic + }, { + default: y(() => [s(d, { + modelValue: E.value, + "onUpdate:modelValue": t[2] ||= (e) => E.value = e, + autocomplete: "off", + invalid: !!w.bic + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"])]), + s(p, { + label: e.$t("e_invoicing.payment_terms"), + "help-text": e.$t("e_invoicing.payment_terms_help"), + error: w.payment_terms + }, { + default: y(() => [s(m, { + modelValue: O.value, + "onUpdate:modelValue": t[3] ||= (e) => O.value = e, + rows: "2", + invalid: !!w.payment_terms + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "help-text", + "error" + ]), + a("div", null, [s(x, { + type: "submit", + variant: "primary", + loading: h.value, + disabled: h.value + }, { + default: y(() => [o(_(e.$t("e_invoicing.save")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])]) + ], 32)) : r("", !0)]), + _: 1 + }, 8, ["title", "description"]); + }; + } +}), B = "e-invoicing"; +window.InvoiceShelf.booting((e, t, n) => { + let r = m(!1), i = async () => { + try { + let { data: e } = await n.client.get(x.capabilities); + r.value = !!e.manage_settings; + } catch { + r.value = !1; + } + }; + n.addMessages(w), n.on("bootstrap:completed", ({ adminMode: e }) => { + e || i(); + }), n.on("company:changing", () => { + r.value = !1; + }), n.on("company:changed", ({ companyId: e }) => { + e !== null && i(); + }); + let a = (t, n) => String(e.config.globalProperties.$t(t, n ?? {})), o = n.notify.bind(n), s = { + client: n.client, + notify: o, + t: a + }; + n.registerCompanySettingsPage({ + id: `${B}.settings`, + title: "e_invoicing.title", + icon: "DocumentCheckIcon", + path: "e-invoicing", + priority: 70, + visible: () => r.value, + meta: { ability: `${B}:manage-settings` }, + component: V(z, s) + }), n.registerEntityPanel("invoice", { + id: `${B}.status`, + component: F, + props: { client: n.client } + }), n.registerEntityAction("invoice", "detail", { + id: `${B}.download-xml`, + component: T, + props: s + }); +}); +function V(e, t) { + return c({ setup: (n, { attrs: r }) => () => l(e, { + ...r, + ...t + }) }); +} diff --git a/dist/style.css b/dist/style.css new file mode 100644 index 0000000..55c48ab --- /dev/null +++ b/dist/style.css @@ -0,0 +1,3 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-font-weight:initial}}}@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.mt-0\.5{margin-top:calc(var(--spacing,.25rem) * .5)}.mt-1{margin-top:var(--spacing,.25rem)}.mt-2{margin-top:calc(var(--spacing,.25rem) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.h-5{height:calc(var(--spacing,.25rem) * 5)}.w-5{width:calc(var(--spacing,.25rem) * 5)}.min-w-0{min-width:0}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.items-start{align-items:flex-start}.gap-3{gap:calc(var(--spacing,.25rem) * 3)}.gap-6{gap:calc(var(--spacing,.25rem) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing,.25rem) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing,.25rem) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 6) * calc(1 - var(--tw-space-y-reverse)))}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg,.5rem)}.bg-alert-success-bg{background-color:var(--color-alert-success-bg)}.bg-alert-warning-bg{background-color:var(--color-alert-warning-bg)}.p-4{padding:calc(var(--spacing,.25rem) * 4)}.px-4{padding-inline:calc(var(--spacing,.25rem) * 4)}.py-3{padding-block:calc(var(--spacing,.25rem) * 3)}.py-6{padding-block:calc(var(--spacing,.25rem) * 6)}.ps-5{padding-inline-start:calc(var(--spacing,.25rem) * 5)}.text-sm{font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)))}.font-medium{--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500)}.text-alert-success-text{color:var(--color-alert-success-text)}.text-alert-warning-text{color:var(--color-alert-warning-text)}.text-body{color:var(--color-body)}.text-heading{color:var(--color-heading)}.text-muted{color:var(--color-muted)}.text-primary-500{color:var(--color-primary-500)}.text-status-green{color:var(--color-status-green)}.text-status-yellow{color:var(--color-status-yellow)}.underline{text-decoration-line:underline}@media (width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-font-weight{syntax:"*";inherits:false} +/*$vite$:1*/ \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..6cf9c07 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,61 @@ +import pluginVue from 'eslint-plugin-vue' +import pluginVueA11y from 'eslint-plugin-vuejs-accessibility' +import tseslint from 'typescript-eslint' + +// The host's form controls, so a