diff --git a/README.md b/README.md index f26ed76..1b357f2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ SDK **3.5** supports Module API **1.4**. First-party module packages live in the - Typed frontend contributions through [`frontend/index.d.ts`](frontend/index.d.ts): full-page routes, settings pages, menus, HTTP access, translations, notifications, and lifecycle events. The host supplies the Vue, router, Axios, and i18n instances—modules do not bundle another framework runtime. - Actions, panels, form sections and save hooks on host records (invoices, estimates, customers, payments and more), plus entries in the global New menu. - Company abilities contributed to the host's authorization catalogue, so a module can ship its own permissions instead of reusing host ones. +- Whole invoices to read and structured XML to embed in invoice PDFs (Module API 1.5.0), for modules that produce e-invoices; see [Documents and invoice PDFs](#documents-and-invoice-pdfs). - Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and read-only company-data queries. `CompanyDataReader` covers the AI assistant's built-in queries plus `companyMembers()` and `existingInvoiceIds()` for modules that assign work to members or stamp their entries against invoices. Module code must not depend on InvoiceShelf Eloquent models. - AI drivers through [`src/Ai`](src/Ai): extend `AiDriver`, return `AiChatResponse`, and throw `AiException` for safe, localizable provider failures. @@ -22,8 +23,8 @@ Declare compatibility in `module.json`, then test it against the host versions y | Concern | Current contract | | --- | --- | -| SDK | `invoiceshelf/modules` `^3.5` | -| Module API | `^1.3.0`, or `^1.4.0` for a module with bundled dependencies | +| SDK | `invoiceshelf/modules` `^3.6` | +| Module API | `^1.3.0`; `^1.4.0` for bundled dependencies and record slots; `^1.5.0` for documents and invoice PDF attachments | | Host application | Your explicit InvoiceShelf 3.x range | | PHP | The range your module actually supports | @@ -241,6 +242,17 @@ extensions.registerCreateAction({ id: 'tasks.new', label: 'tasks.new_task', icon These slots are Module API **1.4.0** additions; declare `"module_api": "^1.4.0"` to use them. +### Documents and invoice PDFs + +A module that turns invoices into other documents, such as an EN 16931 e-invoice, reads them through `InvoiceDocumentReader` and can hand the host an XML to embed in every invoice PDF. + +- **Reading.** `InvoiceDocumentReader::invoiceDocument($companyId, $invoiceId)` returns an `InvoiceDocument` ([`src/Documents/Data`](src/Documents/Data)): seller and buyer with addresses and tax identifiers, lines with their unit codes, document and line taxes with their EN 16931 category codes, the related invoice of a credit note, and the totals. Money is in hundredths exactly as stored, credit notes negative, nothing recalculated. An invoice of another company reads as `null`. `seller($companyId)` reads the company alone, for checks made before any invoice exists. +- **Embedding.** Implement `InvoicePdfAttachmentProvider` and register it once: `Registry::registerInvoicePdfAttachmentProvider('e-invoicing', FacturXProvider::class)`. On every invoice PDF the host renders (download, public link, customer portal, mail attachment, archive), while the module is enabled and `PdfCapabilities::embedsFacturX()` is true, it calls `attachmentFor()` and embeds the returned `FacturXAttachment` as `factur-x.xml` in a PDF/A-3b file. `appliesTo()` must stay cheap: the host also calls it to decide whether an archived PDF is still current. +- **Failures never block billing.** A provider that throws is reported and the invoice gets its ordinary PDF, as does a PDF service that rejects the attachment. +- Only Gotenberg can build the container; with dompdf, `embedsFacturX()` is false and providers are not asked. Show that to your users instead of reading the host's configuration. + +These are Module API **1.5.0** additions; declare `"module_api": "^1.5.0"` to use them. + ### Thin clients The InvoiceShelf mobile clients run the same frontend as the web app, but as a static app package diff --git a/RELEASING.md b/RELEASING.md index ee410fb..680a626 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,7 +18,7 @@ An official module normally calls the tagged SDK workflow from its own tag workf ```yaml jobs: release: - uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.5.0 + uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.6.0 with: channel: stable secrets: inherit diff --git a/src/Contracts/Host/InvoiceDocumentReader.php b/src/Contracts/Host/InvoiceDocumentReader.php new file mode 100644 index 0000000..dbaffdc --- /dev/null +++ b/src/Contracts/Host/InvoiceDocumentReader.php @@ -0,0 +1,23 @@ + $taxes + */ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly ?string $description, + public readonly string $quantity, + public readonly int $price, + public readonly int $discountVal, + public readonly int $total, + public readonly ?string $unitName = null, + public readonly ?string $unitCode = null, + public readonly array $taxes = [], + ) {} + + /** @param array $data */ + public static function fromArray(array $data): self + { + return new self( + Values::int($data, 'id'), + Values::string($data, 'name'), + Values::optionalString($data, 'description'), + Values::string($data, 'quantity'), + Values::int($data, 'price'), + Values::optionalInt($data, 'discount_val') ?? 0, + Values::int($data, 'total'), + Values::optionalString($data, 'unit_name'), + Values::optionalString($data, 'unit_code'), + array_map(DocumentTax::fromArray(...), Values::list($data, 'taxes')), + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'quantity' => $this->quantity, + 'price' => $this->price, + 'discount_val' => $this->discountVal, + 'total' => $this->total, + 'unit_name' => $this->unitName, + 'unit_code' => $this->unitCode, + 'taxes' => array_map(static fn (DocumentTax $tax): array => $tax->toArray(), $this->taxes), + ]; + } +} diff --git a/src/Documents/Data/DocumentParty.php b/src/Documents/Data/DocumentParty.php new file mode 100644 index 0000000..de21a7b --- /dev/null +++ b/src/Documents/Data/DocumentParty.php @@ -0,0 +1,56 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + Values::string($data, 'name'), + Values::optionalString($data, 'company_name'), + Values::optionalString($data, 'vat_id'), + Values::optionalString($data, 'tax_id'), + Values::optionalString($data, 'email'), + Values::optionalString($data, 'phone'), + Values::optionalString($data, 'contact_name'), + is_array($data['address'] ?? null) ? PostalAddress::fromArray($data['address']) : null, + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'company_name' => $this->companyName, + 'vat_id' => $this->vatId, + 'tax_id' => $this->taxId, + 'email' => $this->email, + 'phone' => $this->phone, + 'contact_name' => $this->contactName, + 'address' => $this->address?->toArray(), + ]; + } +} diff --git a/src/Documents/Data/DocumentTax.php b/src/Documents/Data/DocumentTax.php new file mode 100644 index 0000000..453c50a --- /dev/null +++ b/src/Documents/Data/DocumentTax.php @@ -0,0 +1,65 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + Values::optionalInt($data, 'tax_type_id'), + Values::string($data, 'name'), + Values::optionalString($data, 'percent'), + Values::int($data, 'amount'), + Values::optionalString($data, 'calculation_type') ?? self::CALCULATION_PERCENTAGE, + Values::optionalInt($data, 'fixed_amount'), + (bool) ($data['compound'] ?? false), + Values::optionalString($data, 'category_code'), + Values::optionalString($data, 'exemption_reason'), + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'tax_type_id' => $this->taxTypeId, + 'name' => $this->name, + 'percent' => $this->percent, + 'amount' => $this->amount, + 'calculation_type' => $this->calculationType, + 'fixed_amount' => $this->fixedAmount, + 'compound' => $this->compound, + 'category_code' => $this->categoryCode, + 'exemption_reason' => $this->exemptionReason, + ]; + } +} diff --git a/src/Documents/Data/InvoiceDocument.php b/src/Documents/Data/InvoiceDocument.php new file mode 100644 index 0000000..ff824bb --- /dev/null +++ b/src/Documents/Data/InvoiceDocument.php @@ -0,0 +1,145 @@ + $lines + * @param list $taxes + */ + public function __construct( + public readonly int $id, + public readonly int $companyId, + public readonly string $number, + public readonly string $type, + public readonly string $invoiceDate, + public readonly ?string $dueDate, + public readonly string $currencyCode, + public readonly int $currencyPrecision, + public readonly bool $taxPerItem, + public readonly bool $discountPerItem, + public readonly bool $taxIncluded, + public readonly int $subTotal, + public readonly int $discountVal, + public readonly int $tax, + public readonly int $total, + public readonly int $dueAmount, + public readonly DocumentParty $seller, + public readonly ?DocumentParty $buyer, + public readonly array $lines = [], + public readonly array $taxes = [], + public readonly ?string $referenceNumber = null, + public readonly ?string $notes = null, + public readonly ?string $creditReason = null, + public readonly ?RelatedDocument $related = null, + public readonly ?PostalAddress $deliveryAddress = null, + public readonly ?string $exchangeRate = null, + public readonly ?string $companyCurrencyCode = null, + ) {} + + public function isCreditNote(): bool + { + return $this->type === self::TYPE_CREDIT_NOTE; + } + + /** @param array $data */ + public static function fromArray(array $data): self + { + foreach (['seller', 'buyer', 'related', 'delivery_address'] as $key) { + if (($data[$key] ?? null) !== null && ! is_array($data[$key])) { + throw new \InvalidArgumentException("Document data needs '{$key}' to be an object or null."); + } + } + + if (! is_array($data['seller'] ?? null)) { + throw new \InvalidArgumentException("Document data needs a 'seller'."); + } + + return new self( + id: Values::int($data, 'id'), + companyId: Values::int($data, 'company_id'), + number: Values::string($data, 'number'), + type: Values::optionalString($data, 'type') ?? self::TYPE_INVOICE, + invoiceDate: Values::string($data, 'invoice_date'), + dueDate: Values::optionalString($data, 'due_date'), + currencyCode: Values::string($data, 'currency_code'), + currencyPrecision: Values::optionalInt($data, 'currency_precision') ?? 2, + taxPerItem: (bool) ($data['tax_per_item'] ?? false), + discountPerItem: (bool) ($data['discount_per_item'] ?? false), + taxIncluded: (bool) ($data['tax_included'] ?? false), + subTotal: Values::int($data, 'sub_total'), + discountVal: Values::optionalInt($data, 'discount_val') ?? 0, + tax: Values::int($data, 'tax'), + total: Values::int($data, 'total'), + dueAmount: Values::int($data, 'due_amount'), + seller: DocumentParty::fromArray($data['seller']), + buyer: isset($data['buyer']) ? DocumentParty::fromArray($data['buyer']) : null, + lines: array_map(DocumentLine::fromArray(...), Values::list($data, 'lines')), + taxes: array_map(DocumentTax::fromArray(...), Values::list($data, 'taxes')), + referenceNumber: Values::optionalString($data, 'reference_number'), + notes: Values::optionalString($data, 'notes'), + creditReason: Values::optionalString($data, 'credit_reason'), + related: isset($data['related']) ? RelatedDocument::fromArray($data['related']) : null, + deliveryAddress: isset($data['delivery_address']) ? PostalAddress::fromArray($data['delivery_address']) : null, + exchangeRate: Values::optionalString($data, 'exchange_rate'), + companyCurrencyCode: Values::optionalString($data, 'company_currency_code'), + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'company_id' => $this->companyId, + 'number' => $this->number, + 'type' => $this->type, + 'invoice_date' => $this->invoiceDate, + 'due_date' => $this->dueDate, + 'currency_code' => $this->currencyCode, + 'currency_precision' => $this->currencyPrecision, + 'tax_per_item' => $this->taxPerItem, + 'discount_per_item' => $this->discountPerItem, + 'tax_included' => $this->taxIncluded, + 'sub_total' => $this->subTotal, + 'discount_val' => $this->discountVal, + 'tax' => $this->tax, + 'total' => $this->total, + 'due_amount' => $this->dueAmount, + 'seller' => $this->seller->toArray(), + 'buyer' => $this->buyer?->toArray(), + 'lines' => array_map(static fn (DocumentLine $line): array => $line->toArray(), $this->lines), + 'taxes' => array_map(static fn (DocumentTax $tax): array => $tax->toArray(), $this->taxes), + 'reference_number' => $this->referenceNumber, + 'notes' => $this->notes, + 'credit_reason' => $this->creditReason, + 'related' => $this->related?->toArray(), + 'delivery_address' => $this->deliveryAddress?->toArray(), + 'exchange_rate' => $this->exchangeRate, + 'company_currency_code' => $this->companyCurrencyCode, + ]; + } +} diff --git a/src/Documents/Data/PostalAddress.php b/src/Documents/Data/PostalAddress.php new file mode 100644 index 0000000..f7fb183 --- /dev/null +++ b/src/Documents/Data/PostalAddress.php @@ -0,0 +1,50 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + Values::optionalString($data, 'address_street_1'), + Values::optionalString($data, 'address_street_2'), + Values::optionalString($data, 'city'), + Values::optionalString($data, 'state'), + Values::optionalString($data, 'zip'), + Values::optionalString($data, 'country_code'), + Values::optionalString($data, 'phone'), + ); + } + + /** @return array{address_street_1: ?string, address_street_2: ?string, city: ?string, state: ?string, zip: ?string, country_code: ?string, phone: ?string} */ + public function toArray(): array + { + return [ + 'address_street_1' => $this->street1, + 'address_street_2' => $this->street2, + 'city' => $this->city, + 'state' => $this->state, + 'zip' => $this->zip, + 'country_code' => $this->countryCode, + 'phone' => $this->phone, + ]; + } +} diff --git a/src/Documents/Data/RelatedDocument.php b/src/Documents/Data/RelatedDocument.php new file mode 100644 index 0000000..ac46c16 --- /dev/null +++ b/src/Documents/Data/RelatedDocument.php @@ -0,0 +1,30 @@ + $data */ + public static function fromArray(array $data): self + { + return new self(Values::int($data, 'id'), Values::string($data, 'number'), Values::optionalString($data, 'date')); + } + + /** @return array{id: int, number: string, date: ?string} */ + public function toArray(): array + { + return ['id' => $this->id, 'number' => $this->number, 'date' => $this->date]; + } +} diff --git a/src/Documents/Data/Values.php b/src/Documents/Data/Values.php new file mode 100644 index 0000000..524b0c3 --- /dev/null +++ b/src/Documents/Data/Values.php @@ -0,0 +1,73 @@ + $data */ + public static function string(array $data, string $key): string + { + $value = $data[$key] ?? null; + + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException("Document data needs a string '{$key}'."); + } + + return (string) $value; + } + + /** @param array $data */ + public static function optionalString(array $data, string $key): ?string + { + return ($data[$key] ?? null) === null ? null : self::string($data, $key); + } + + /** @param array $data */ + public static function int(array $data, string $key): int + { + $value = $data[$key] ?? null; + + if (! is_int($value) && ! (is_string($value) && preg_match('/^-?\d+$/', $value) === 1)) { + throw new InvalidArgumentException("Document data needs an integer '{$key}'."); + } + + return (int) $value; + } + + /** @param array $data */ + public static function optionalInt(array $data, string $key): ?int + { + return ($data[$key] ?? null) === null ? null : self::int($data, $key); + } + + /** + * @param array $data + * @return list> + */ + public static function list(array $data, string $key): array + { + $value = $data[$key] ?? []; + + if (! is_array($value) || ! array_is_list($value)) { + throw new InvalidArgumentException("Document data needs a list '{$key}'."); + } + + foreach ($value as $entry) { + if (! is_array($entry)) { + throw new InvalidArgumentException("Every entry of '{$key}' must be an object."); + } + } + + return $value; + } +} diff --git a/src/Pdf/Contracts/InvoicePdfAttachmentProvider.php b/src/Pdf/Contracts/InvoicePdfAttachmentProvider.php new file mode 100644 index 0000000..674eb01 --- /dev/null +++ b/src/Pdf/Contracts/InvoicePdfAttachmentProvider.php @@ -0,0 +1,35 @@ + $class + */ + public static function registerInvoicePdfAttachmentProvider(string $slug, string $class): void + { + if (! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) { + throw new InvalidArgumentException("Module slug '{$slug}' must be lower-case kebab-case, matching the module.json slug."); + } + + if (! class_exists($class) || ! is_subclass_of($class, InvoicePdfAttachmentProvider::class) || (new \ReflectionClass($class))->isAbstract()) { + throw new InvalidArgumentException('Invoice PDF attachment provider must be a concrete class implementing '.InvoicePdfAttachmentProvider::class.'.'); + } + + $meta = ['class' => $class]; + + if ((static::$drivers[self::INVOICE_PDF_ATTACHMENT][$slug] ?? null) === $meta) { + return; + } + + if (isset(static::$drivers[self::INVOICE_PDF_ATTACHMENT][$slug])) { + throw new InvalidArgumentException("Module '{$slug}' already registered an invoice PDF attachment provider."); + } + + static::registerDriver(self::INVOICE_PDF_ATTACHMENT, $slug, $meta); + } + /** * Get all registered drivers for a given type. * diff --git a/tests/DocumentContractsTest.php b/tests/DocumentContractsTest.php new file mode 100644 index 0000000..7804ee8 --- /dev/null +++ b/tests/DocumentContractsTest.php @@ -0,0 +1,191 @@ +assertSame(['invoiceDocument', 'seller'], $this->methods(InvoiceDocumentReader::class)); + $this->assertSame(['embedsFacturX'], $this->methods(PdfCapabilities::class)); + $this->assertSame(['appliesTo', 'attachmentFor'], $this->methods(InvoicePdfAttachmentProvider::class)); + + $read = new ReflectionMethod(InvoiceDocumentReader::class, 'invoiceDocument'); + $this->assertSame(['int', 'int'], array_map(static fn ($parameter): string => (string) $parameter->getType(), $read->getParameters())); + $this->assertSame('?'.InvoiceDocument::class, (string) $read->getReturnType()); + $this->assertSame('?'.DocumentParty::class, (string) (new ReflectionMethod(InvoiceDocumentReader::class, 'seller'))->getReturnType()); + $this->assertSame('?'.FacturXAttachment::class, (string) (new ReflectionMethod(InvoicePdfAttachmentProvider::class, 'attachmentFor'))->getReturnType()); + } + + public function test_an_invoice_document_round_trips_through_its_array_shape(): void + { + $data = self::creditNote(); + + $document = InvoiceDocument::fromArray($data); + + $this->assertSame($data, $document->toArray()); + $this->assertTrue($document->isCreditNote()); + $this->assertSame('DE123456789', $document->seller->vatId); + $this->assertSame('DE', $document->buyer?->address?->countryCode); + $this->assertSame('RE-2026-0001', $document->related?->number); + $this->assertSame('-2.00', $document->lines[0]->quantity); + $this->assertSame('S', $document->lines[0]->taxes[0]->categoryCode); + $this->assertSame(DocumentTax::CALCULATION_PERCENTAGE, $document->lines[0]->taxes[0]->calculationType); + } + + public function test_optional_parts_default_when_absent(): void + { + $data = self::creditNote(); + unset($data['type'], $data['buyer'], $data['related'], $data['delivery_address'], $data['lines'], $data['taxes'], $data['currency_precision']); + + $document = InvoiceDocument::fromArray($data); + + $this->assertFalse($document->isCreditNote()); + $this->assertNull($document->buyer); + $this->assertSame([], $document->lines); + $this->assertSame(2, $document->currencyPrecision); + } + + /** @param array $changes */ + #[DataProvider('malformedDocuments')] + public function test_malformed_document_data_is_rejected(array $changes, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + InvoiceDocument::fromArray(array_replace(self::creditNote(), $changes)); + } + + /** @return iterable, string}> */ + public static function malformedDocuments(): iterable + { + yield 'missing number' => [['number' => null], "a string 'number'"]; + yield 'fractional total' => [['total' => '12.50'], "an integer 'total'"]; + yield 'missing seller' => [['seller' => null], "a 'seller'"]; + yield 'scalar buyer' => [['buyer' => 'Muster GmbH'], "'buyer' to be an object or null"]; + yield 'lines as a map' => [['lines' => ['first' => []]], "a list 'lines'"]; + yield 'scalar line' => [['lines' => ['one']], "Every entry of 'lines'"]; + } + + public function test_a_facturx_attachment_needs_xml_and_a_known_profile(): void + { + $attachment = new FacturXAttachment(''); + + $this->assertSame(FacturXAttachment::PROFILE_EN16931, $attachment->profile); + $this->assertSame('factur-x.xml', FacturXAttachment::FILENAME); + $this->assertSame('XRECHNUNG', (new FacturXAttachment('', 'XRECHNUNG'))->profile); + + foreach ([[' ', FacturXAttachment::PROFILE_EN16931, 'needs its XML'], ['', 'EN16931', 'must be one of']] as [$xml, $profile, $message]) { + try { + new FacturXAttachment($xml, $profile); + $this->fail("Expected '{$message}'."); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString($message, $exception->getMessage()); + } + } + } + + /** @return list */ + private function methods(string $interface): array + { + return array_map(static fn (ReflectionMethod $method): string => $method->getName(), (new ReflectionClass($interface))->getMethods()); + } + + /** @return array */ + private static function creditNote(): array + { + $address = static fn (string $street, string $zip, string $city): array => [ + 'address_street_1' => $street, + 'address_street_2' => null, + 'city' => $city, + 'state' => null, + 'zip' => $zip, + 'country_code' => 'DE', + 'phone' => null, + ]; + $tax = [ + 'tax_type_id' => 3, + 'name' => 'VAT', + 'percent' => '19.00', + 'amount' => -1900, + 'calculation_type' => 'percentage', + 'fixed_amount' => null, + 'compound' => false, + 'category_code' => 'S', + 'exemption_reason' => null, + ]; + + return [ + 'id' => 7, + 'company_id' => 1, + 'number' => 'CN-2026-0001', + 'type' => 'CREDIT_NOTE', + 'invoice_date' => '2026-10-24', + 'due_date' => null, + 'currency_code' => 'EUR', + 'currency_precision' => 2, + 'tax_per_item' => true, + 'discount_per_item' => false, + 'tax_included' => false, + 'sub_total' => -10000, + 'discount_val' => 0, + 'tax' => -1900, + 'total' => -11900, + 'due_amount' => 0, + 'seller' => [ + 'name' => 'Acme GmbH', + 'company_name' => null, + 'vat_id' => 'DE123456789', + 'tax_id' => '12/345/67890', + 'email' => null, + 'phone' => '+49 30 1234567', + 'contact_name' => null, + 'address' => $address('Hauptstraße 1', '10115', 'Berlin'), + ], + 'buyer' => [ + 'name' => 'Erika Muster', + 'company_name' => 'Muster GmbH', + 'vat_id' => null, + 'tax_id' => null, + 'email' => 'erika@muster.test', + 'phone' => null, + 'contact_name' => 'Erika Muster', + 'address' => $address('Marktplatz 5', '80331', 'München'), + ], + 'lines' => [[ + 'id' => 11, + 'name' => 'Consulting', + 'description' => 'On site', + 'quantity' => '-2.00', + 'price' => 5000, + 'discount_val' => 0, + 'total' => -10000, + 'unit_name' => 'hour', + 'unit_code' => 'HUR', + 'taxes' => [$tax], + ]], + 'taxes' => [], + 'reference_number' => 'RE-2026-0001', + 'notes' => 'Thank you.', + 'credit_reason' => 'Returned goods', + 'related' => ['id' => 6, 'number' => 'RE-2026-0001', 'date' => '2026-10-01'], + 'delivery_address' => null, + 'exchange_rate' => '1.000000', + 'company_currency_code' => 'EUR', + ]; + } +} diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php index e8d41d5..1dfab86 100644 --- a/tests/RegistryTest.php +++ b/tests/RegistryTest.php @@ -7,6 +7,8 @@ use InvalidArgumentException; use InvoiceShelf\Modules\Ai\Contracts\AiDriver; use InvoiceShelf\Modules\Ai\Data\AiChatResponse; +use InvoiceShelf\Modules\Pdf\Contracts\InvoicePdfAttachmentProvider; +use InvoiceShelf\Modules\Pdf\Data\FacturXAttachment; use InvoiceShelf\Modules\Registry; use InvoiceShelf\Modules\Settings\Schema; use PHPUnit\Framework\Attributes\DataProvider; @@ -417,6 +419,49 @@ private static function validAiMeta(): array ]; } + public function test_invoice_pdf_attachment_provider_is_registered_per_module(): void + { + Registry::flushDrivers(); + Registry::registerInvoicePdfAttachmentProvider('e-invoicing', FakeAttachmentProvider::class); + Registry::registerInvoicePdfAttachmentProvider('e-invoicing', FakeAttachmentProvider::class); + + $this->assertSame( + ['e-invoicing' => ['class' => FakeAttachmentProvider::class]], + Registry::allDrivers(Registry::INVOICE_PDF_ATTACHMENT), + ); + } + + public function test_invoice_pdf_attachment_provider_rejects_a_second_class_for_one_module(): void + { + Registry::flushDrivers(); + Registry::registerInvoicePdfAttachmentProvider('e-invoicing', FakeAttachmentProvider::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('already registered an invoice PDF attachment provider'); + + Registry::registerInvoicePdfAttachmentProvider('e-invoicing', OtherFakeAttachmentProvider::class); + } + + #[DataProvider('invalidAttachmentProviders')] + public function test_invoice_pdf_attachment_provider_rejects_malformed_registrations(string $slug, string $class, string $message): void + { + Registry::flushDrivers(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Registry::registerInvoicePdfAttachmentProvider($slug, $class); + } + + /** @return iterable */ + public static function invalidAttachmentProviders(): iterable + { + yield 'non-kebab slug' => ['EInvoicing', FakeAttachmentProvider::class, 'lower-case kebab-case']; + yield 'missing class' => ['e-invoicing', 'Missing\\Provider', 'concrete class implementing']; + yield 'unrelated class' => ['e-invoicing', self::class, 'concrete class implementing']; + yield 'abstract class' => ['e-invoicing', AbstractFakeAttachmentProvider::class, 'concrete class implementing']; + } + public function test_all_drivers_returns_empty_array_for_unknown_type(): void { Registry::flushDrivers(); @@ -615,3 +660,20 @@ public function validateConnection(): array } abstract class AbstractFakeAiDriver extends AiDriver {} + +class FakeAttachmentProvider implements InvoicePdfAttachmentProvider +{ + public function appliesTo(int $companyId): bool + { + return true; + } + + public function attachmentFor(int $companyId, int $invoiceId): ?FacturXAttachment + { + return new FacturXAttachment(''); + } +} + +class OtherFakeAttachmentProvider extends FakeAttachmentProvider {} + +abstract class AbstractFakeAttachmentProvider implements InvoicePdfAttachmentProvider {}