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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/Contracts/Host/InvoiceDocumentReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace InvoiceShelf\Modules\Contracts\Host;

use InvoiceShelf\Modules\Documents\Data\DocumentParty;
use InvoiceShelf\Modules\Documents\Data\InvoiceDocument;

/**
* Reads whole invoices for modules that turn them into other documents, such
* as structured e-invoices. Module API 1.5.0.
*
* Every read is scoped to the company: an id from another company reads as
* null, exactly like one that does not exist.
*/
interface InvoiceDocumentReader
{
public function invoiceDocument(int $companyId, int $invoiceId): ?InvoiceDocument;

/** The company as the seller of its documents, with its postal address; null when it does not exist. */
public function seller(int $companyId): ?DocumentParty;
}
18 changes: 18 additions & 0 deletions src/Contracts/Host/PdfCapabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace InvoiceShelf\Modules\Contracts\Host;

/**
* What the host's PDF rendering can do right now. Module API 1.5.0.
*/
interface PdfCapabilities
{
/**
* Whether invoice PDFs can carry an embedded Factur-X / ZUGFeRD XML:
* true when they are rendered by a Gotenberg service recent enough to
* build the PDF/A-3 container. dompdf cannot.
*/
public function embedsFacturX(): bool;
}
67 changes: 67 additions & 0 deletions src/Documents/Data/DocumentLine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace InvoiceShelf\Modules\Documents\Data;

/**
* One line of a document, in the order the document lists it.
*
* `quantity` is a decimal string. `unit_name` is the line's own snapshot and
* `unit_code` the UN/ECE Recommendation 20 code of the company unit by that
* name now, null when no unit matches. `description` is plain text. `taxes`
* is empty unless the document taxes each line. Amounts are in hundredths,
* see {@see InvoiceDocument}.
*/
final class DocumentLine
{
/**
* @param list<DocumentTax> $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<string, mixed> $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<string, mixed> */
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),
];
}
}
56 changes: 56 additions & 0 deletions src/Documents/Data/DocumentParty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace InvoiceShelf\Modules\Documents\Data;

/**
* The seller (the company) or the buyer (the customer) of a document.
*
* `vat_id` and `tax_id` are what the host holds: a company has both; a
* customer has only `tax_id`, an untyped field that may hold a VAT identifier
* or a national tax number, so `vat_id` is null for a buyer.
*/
final class DocumentParty
{
public function __construct(
public readonly string $name,
public readonly ?string $companyName = null,
public readonly ?string $vatId = null,
public readonly ?string $taxId = null,
public readonly ?string $email = null,
public readonly ?string $phone = null,
public readonly ?string $contactName = null,
public readonly ?PostalAddress $address = null,
) {}

/** @param array<string, mixed> $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<string, mixed> */
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(),
];
}
}
65 changes: 65 additions & 0 deletions src/Documents/Data/DocumentTax.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace InvoiceShelf\Modules\Documents\Data;

/**
* One tax row of a document or of a line, as stored.
*
* `name` and `percent` are the row's own snapshot; `category_code` (an EN 16931
* tax category such as `S`, `Z` or `E`) and `exemption_reason` come from the
* tax type as it is now. `percent` is a decimal string, null for a fixed tax,
* whose amount per line is `fixed_amount`. Amounts are in hundredths, see
* {@see InvoiceDocument}.
*/
final class DocumentTax
{
public const CALCULATION_PERCENTAGE = 'percentage';

public const CALCULATION_FIXED = 'fixed';

public function __construct(
public readonly ?int $taxTypeId,
public readonly string $name,
public readonly ?string $percent,
public readonly int $amount,
public readonly string $calculationType = self::CALCULATION_PERCENTAGE,
public readonly ?int $fixedAmount = null,
public readonly bool $compound = false,
public readonly ?string $categoryCode = null,
public readonly ?string $exemptionReason = null,
) {}

/** @param array<string, mixed> $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<string, mixed> */
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,
];
}
}
Loading
Loading