From 31571c24a3b1dd6ed8b6a7d0c0f958fcd858657f Mon Sep 17 00:00:00 2001 From: jbagsik Date: Mon, 28 Sep 2026 17:36:41 +0200 Subject: [PATCH 01/12] feat(invoice): persist supplier_number as BT-29 seller identifier --- packages/invoice/CHANGELOG.md | 1 + packages/invoice/CONTEXT.md | 3 ++ packages/invoice/README.md | 1 + .../database/factories/InvoiceFactory.php | 1 + ...supplier_number_to_invoices_table.php.stub | 28 +++++++++++++++++++ .../migrations/create_invoices_table.php.stub | 1 + .../invoice/src/InvoiceServiceProvider.php | 1 + packages/invoice/src/Models/Invoice.php | 1 + .../invoice/src/Support/InvoiceBuilder.php | 1 + packages/invoice/src/Support/InvoiceDraft.php | 1 + 10 files changed, 39 insertions(+) create mode 100644 packages/invoice/database/migrations/add_supplier_number_to_invoices_table.php.stub diff --git a/packages/invoice/CHANGELOG.md b/packages/invoice/CHANGELOG.md index b2913fb76..e20ae4d4d 100644 --- a/packages/invoice/CHANGELOG.md +++ b/packages/invoice/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- Nullable indexed `supplier_number` on invoices (EN 16931 BT-29 seller identifier). Flows through `InvoiceDraft` / `InvoiceBuilder` / model / create-table stub; migration stub `add_supplier_number_to_invoices_table` (hosts must publish/run). - Nullable `vat_category` on invoices (`InvoiceDraft` / `InvoiceBuilder` / model / create-table stub) for the EN 16931 VAT category stamp (BT-118). ### Changed diff --git a/packages/invoice/CONTEXT.md b/packages/invoice/CONTEXT.md index 2a77f5985..99e26c770 100644 --- a/packages/invoice/CONTEXT.md +++ b/packages/invoice/CONTEXT.md @@ -55,6 +55,9 @@ document wording, no host model names. Keep lean. in that language. - **Buyer identifier** — BT-46. The debtor identity of the document. Distinct from **buyer reference** (BT-10); the two are separate terms and must not be conflated. +- **Seller identifier** (**supplier number**) — BT-29. The seller's identifier as printed on the + document (e.g. Lieferanten-Nr.). Distinct from **buyer identifier** and from purchasing master-data + supplier numbers. Persisted on `invoices.supplier_number`. *Avoid:* conflating with VAT ID (BT-31). - **Payment terms** — BT-20, free text as printed on the document. A term, not a structured discount: no early-payment arithmetic is modelled here. - **Shipping method** — how the goods travelled. No BT number in EN 16931; ZUGFeRD `EXTENDED` diff --git a/packages/invoice/README.md b/packages/invoice/README.md index a94ea456c..bf30ba87a 100644 --- a/packages/invoice/README.md +++ b/packages/invoice/README.md @@ -184,6 +184,7 @@ The `Invoice` model (`Moox\Invoice\Models\Invoice`) stores the invoice header. I - `due_date` (string, nullable) - Payment due date - `currency` (string, default: `EUR`) - ISO 4217 currency code - `customer_number` (string, nullable, indexed) - Buyer identifier assigned by the seller (EN 16931 **BT-46**); stored verbatim from the document +- `supplier_number` (string, nullable, indexed) - Seller identifier (EN 16931 **BT-29**, printed Lieferanten-Nr.); stored verbatim from the document - `customer_reference` (string, nullable) - Reference the buyer asked to see on the document (EN 16931 **BT-10**); distinct from `customer_number` - `order_number` (string, nullable) - Associated order number - `order_date` (string, nullable) - Associated order date diff --git a/packages/invoice/database/factories/InvoiceFactory.php b/packages/invoice/database/factories/InvoiceFactory.php index 41a3dc970..025f33370 100644 --- a/packages/invoice/database/factories/InvoiceFactory.php +++ b/packages/invoice/database/factories/InvoiceFactory.php @@ -29,6 +29,7 @@ public function definition(): array 'due_date' => fake()->optional()->date('Y-m-d'), 'currency' => 'EUR', 'customer_number' => fake()->optional()->bothify('000####'), + 'supplier_number' => fake()->optional()->bothify('5#####'), 'customer_reference' => fake()->optional()->bothify('REF-####'), 'order_number' => fake()->optional()->bothify('PO-####'), 'order_date' => fake()->optional()->date('Y-m-d'), diff --git a/packages/invoice/database/migrations/add_supplier_number_to_invoices_table.php.stub b/packages/invoice/database/migrations/add_supplier_number_to_invoices_table.php.stub new file mode 100644 index 000000000..1839b566d --- /dev/null +++ b/packages/invoice/database/migrations/add_supplier_number_to_invoices_table.php.stub @@ -0,0 +1,28 @@ +string('supplier_number')->nullable()->index()->after('customer_number'); + } + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + if (Schema::hasColumn('invoices', 'supplier_number')) { + $table->dropColumn('supplier_number'); + } + }); + } +}; diff --git a/packages/invoice/database/migrations/create_invoices_table.php.stub b/packages/invoice/database/migrations/create_invoices_table.php.stub index 65a6e709e..9bdf4da18 100644 --- a/packages/invoice/database/migrations/create_invoices_table.php.stub +++ b/packages/invoice/database/migrations/create_invoices_table.php.stub @@ -20,6 +20,7 @@ return new class extends Migration $table->string('due_date')->nullable(); $table->string('currency')->default('EUR'); $table->string('customer_number')->nullable()->index(); + $table->string('supplier_number')->nullable()->index(); $table->string('customer_reference')->nullable(); $table->string('order_number')->nullable(); $table->string('order_date')->nullable(); diff --git a/packages/invoice/src/InvoiceServiceProvider.php b/packages/invoice/src/InvoiceServiceProvider.php index 94cba4fba..e09510671 100644 --- a/packages/invoice/src/InvoiceServiceProvider.php +++ b/packages/invoice/src/InvoiceServiceProvider.php @@ -21,6 +21,7 @@ public function configureMoox(Package $package): void 'convert_delivery_json_to_party_shape', 'add_document_versioning_to_invoices_table', 'add_vat_category_to_invoices_table', + 'add_supplier_number_to_invoices_table', ]) ->hasCommands(); diff --git a/packages/invoice/src/Models/Invoice.php b/packages/invoice/src/Models/Invoice.php index 9688ea425..bb2ef5ce6 100644 --- a/packages/invoice/src/Models/Invoice.php +++ b/packages/invoice/src/Models/Invoice.php @@ -51,6 +51,7 @@ class Invoice extends BaseItemModel 'due_date', 'currency', 'customer_number', + 'supplier_number', 'customer_reference', 'order_number', 'order_date', diff --git a/packages/invoice/src/Support/InvoiceBuilder.php b/packages/invoice/src/Support/InvoiceBuilder.php index c1c5773d8..c723a9289 100644 --- a/packages/invoice/src/Support/InvoiceBuilder.php +++ b/packages/invoice/src/Support/InvoiceBuilder.php @@ -37,6 +37,7 @@ private function persist(InvoiceDraft $draft): Invoice $invoice->due_date = $draft->due_date; $invoice->currency = $draft->currency; $invoice->customer_number = $draft->customer_number; + $invoice->supplier_number = $draft->supplier_number; $invoice->customer_reference = $draft->customer_reference; $invoice->order_number = $draft->order_number; $invoice->order_date = $draft->order_date; diff --git a/packages/invoice/src/Support/InvoiceDraft.php b/packages/invoice/src/Support/InvoiceDraft.php index 2bee5bd48..1f336f488 100644 --- a/packages/invoice/src/Support/InvoiceDraft.php +++ b/packages/invoice/src/Support/InvoiceDraft.php @@ -20,6 +20,7 @@ public function __construct( public ?string $due_date, public string $currency, public ?string $customer_number, + public ?string $supplier_number, public ?string $customer_reference, public ?string $order_number, public ?string $order_date, From 655cab34f425582ed88d265bea64dbf3ced5421c Mon Sep 17 00:00:00 2001 From: jbagsik Date: Mon, 28 Sep 2026 17:43:00 +0200 Subject: [PATCH 02/12] feat(zugferd): emit BT-29 seller id when supplierNumber is set --- packages/zugferd/CHANGELOG.md | 1 + packages/zugferd/README.md | 2 ++ packages/zugferd/src/Contracts/ZugferdInvoice.php | 3 +++ packages/zugferd/src/ZugferdConverter.php | 5 +++++ 4 files changed, 11 insertions(+) diff --git a/packages/zugferd/CHANGELOG.md b/packages/zugferd/CHANGELOG.md index d7940b01b..23fa21273 100644 --- a/packages/zugferd/CHANGELOG.md +++ b/packages/zugferd/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `ZugferdInvoice` gains `?string $supplierNumber` (BT-29). **Breaking for custom implementations.** `ZugferdConverter` emits `addDocumentSellerId` when non-empty (trimmed); omits when null/empty; no scheme ID. - `ShipToPartyEquality`: ship-to equals buyer when case-insensitive trimmed names match and postal fingerprints match (street, `addressLine2`/street2, postal code, country). City is **not** in the fingerprint. - `ZugferdConverter` emission (ADR 0020): omit BT-70 / BG-15 when ship-to equals buyer; keep BT-72; VAT category **K** still emits full BG-15 (buyer postal acceptable when empty) for BR-IC-12 / BR-IC-11. Promote one shared distinct line ship-to to document BG-13 when the header is empty; otherwise divergent line parties, different PO document numbers, and non-common despatch refs → BT-127 notes. - Line contracts: `itemAttributes` / `itemClassifications` → BG-32 / BT-158; trade refs BT-13 (`purchaseOrderReference`), BT-16 (`despatchAdviceReference`, with common line DN fallback), BT-132 via `purchaseOrderLineReference`; line allowance/charges. diff --git a/packages/zugferd/README.md b/packages/zugferd/README.md index a5ca0481f..3242e07a1 100644 --- a/packages/zugferd/README.md +++ b/packages/zugferd/README.md @@ -17,6 +17,7 @@ Moox Zugferd converts invoice data implementing `ZugferdInvoice` into valid ZUGF - Required profile key on convert: MINIMUM, BASIC, EN16931, EXTENDED, XRECHNUNG (unknown keys throw) - Optional `deliveryDate` on invoice (BT-72 via `setDocumentSupplyChainEvent`) and on lines (line billing period with start=end for non-EXTENDED profiles, line actual delivery for EXTENDED); profile key selects the line-date carrier; no header invoicing period (BG-14) is derived from delivery dates - Optional `shipToName` / `shipToAddress` on invoice and lines (BG-13 via `setDocumentShipTo` / `setDocumentShipToAddress`); address is omitted when country is empty; ship-to tax registration and contact are never emitted +- Optional `supplierNumber` (BT-29; emit via `addDocumentSellerId` when non-empty, omit when empty/whitespace), `shipToName` / `shipToAddress` on invoice and lines (BG-13 via `setDocumentShipTo` / `setDocumentShipToAddress`); address is omitted when country is empty; ship-to tax registration and contact are never emitted - Omit duplicate ShipTo when name + postal fingerprint equals buyer (street, street2, postal code, country; city excluded; keep BT-72); VAT **K** still emits BG-15 (buyer postal OK). Promote shared distinct line ship-to when header empty; else BT-127 notes. Line `itemAttributes` / `itemClassifications` → BG-32 / BT-158; trade refs BT-13 / BT-16 (common line DN fallback) / BT-132 (`purchaseOrderLineReference`); line allowance/charges. `purchaseOrderDate` is never OrderReference IssueDate (UBL-CR-018) @@ -116,6 +117,7 @@ Header, parties, totals, `lines`, `bankAccounts`, and `allowanceCharges`. - Non-empty `bankAccounts` with non-empty IBAN on each account **Other notable fields:** `documentType` (credit note when value contains `gutschrift` → type code `381`, else `380`), `paymentMeansCode` (default `58`), `dueDate` / `paymentTerms`, `deliveryDate` (BT-72), `documentNotes` (BT-22), `purchaseOrderReference` (BT-13), `despatchAdviceReference` (BT-16; converter may fill from a common line `deliveryNoteNumber`), `purchaseOrderDate` (unstructured notes only — never OrderReference IssueDate), `shipToName` / `shipToAddress` (BG-13; address requires a country; omitted when equal to buyer unless VAT **K**), `vatCategoryCode`, `vatRate`, `netTotal`, `vatAmount`, `grossTotal`. +**Other notable fields:** `documentType` (credit note when value contains `gutschrift` → type code `381`, else `380`), `paymentMeansCode` (default `58`), `dueDate` / `paymentTerms`, `supplierNumber` (BT-29; emit via `addDocumentSellerId` when non-empty), `deliveryDate` (BT-72), `documentNotes` (BT-22), `purchaseOrderReference` (BT-13), `despatchAdviceReference` (BT-16; converter may fill from a common line `deliveryNoteNumber`), `purchaseOrderDate` (unstructured notes only — never OrderReference IssueDate), `shipToName` / `shipToAddress` (BG-13; address requires a country; omitted when equal to buyer unless VAT **K**), `precedingInvoices` (BG-3: `list`, BT-25/BT-26 — one `InvoiceReferencedDocument` per entry), `vatCategoryCode`, `vatRate`, `netTotal`, `vatAmount`, `grossTotal`. ### `ZugferdInvoiceLine` diff --git a/packages/zugferd/src/Contracts/ZugferdInvoice.php b/packages/zugferd/src/Contracts/ZugferdInvoice.php index a656beec5..48a35efbe 100644 --- a/packages/zugferd/src/Contracts/ZugferdInvoice.php +++ b/packages/zugferd/src/Contracts/ZugferdInvoice.php @@ -42,6 +42,9 @@ interface ZugferdInvoice public ?string $supplierTaxNumber { get; } + /** BT-29 seller identifier; null/empty → do not emit. */ + public ?string $supplierNumber { get; } + public ?string $paymentTerms { get; } public ?string $deliveryDate { get; } diff --git a/packages/zugferd/src/ZugferdConverter.php b/packages/zugferd/src/ZugferdConverter.php index d49d2e4fb..7a7cb4365 100644 --- a/packages/zugferd/src/ZugferdConverter.php +++ b/packages/zugferd/src/ZugferdConverter.php @@ -259,6 +259,11 @@ private function setSeller(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice) { $doc->setDocumentSeller($invoice->supplierName); + $sellerId = $invoice->supplierNumber !== null ? trim($invoice->supplierNumber) : ''; + if ($sellerId !== '') { + $doc->addDocumentSellerId($sellerId); + } + if ($invoice->supplierAddress === null) { throw new IncompleteInvoiceException('Missing required field: supplierAddress (BG-5 seller postal address).'); } From 134e9de219d7c3adfaebfd0db4f7c78eb5921a03 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Mon, 28 Sep 2026 17:48:42 +0200 Subject: [PATCH 03/12] feat(e-billing): persist show and MoSCoW supplier number as BT-29 --- packages/e-billing/CHANGELOG.md | 1 + packages/e-billing/CONTEXT.md | 2 + packages/e-billing/config/e-billing.php | 1 + .../0012-supplier-number-bt-29-on-invoices.md | 35 ++++++++++++ .../src/Adapters/ZugferdInvoiceAdapter.php | 3 ++ .../src/Adapters/ZugferdInvoiceDtoAdapter.php | 3 ++ .../e-billing/src/Services/InvoiceFactory.php | 3 ++ .../src/Services/ParsedInvoiceMapper.php | 3 ++ .../src/Support/InvoiceFieldLabels.php | 1 + .../src/ViewModels/InvoiceViewModel.php | 2 +- packages/zugferd/CONTEXT.md | 54 +++++++++++++++++++ 11 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/e-billing/docs/adr/0012-supplier-number-bt-29-on-invoices.md create mode 100644 packages/zugferd/CONTEXT.md diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 31cfcd4e5..0e682943c 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Supplier number (EN 16931 BT-29, ADR 0012): persist DTO `supplierNumber` onto `invoices.supplier_number` via `ParsedInvoiceMapper` / `InvoiceFactory`; MoSCoW `could` on invoice / credit-note / corrected-invoice field maps; ViewInvoice supplier (BG-4) group shows `supplier_number`; both Zugferd adapters expose `supplierNumber`. No SQL backfill — next `GenerateArtifactJob` / leave-edit fills the column from `bill_data`. - `e-billing.intake.scopes`: optional allowlist of mail-inbox Scope keys for `ProcessInboxAttachmentListener` (null/`[]` = all; non-listed PDFs marked Skipped, no `EbillingDocument`). - Optional `e-billing.delivery.from_name` / `EBILLING_DELIVERY_FROM_NAME` (display name only; From address is host-owned). diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 2c0b141f3..19e6fcbf3 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -18,6 +18,7 @@ Glossary for the generic e-billing conversion pipeline (`packages/e-billing`). K - **Finding (Befund)** — a field whose status is `missing` or `needs_review`, at invoice or line level. Counted by `attentionFieldCount()`, listed with a link to the field it belongs to — the list is the navigable form of the score, and the reason a number alone does not suffice once a document has many lines. *Avoid:* calling a `not_applicable` field a finding; that status means "empty and rightly so". - **`not_applicable` vs `parsed`** — `not_applicable` means the field is empty and that is correct, and it counts as clean. `parsed` means a value is present, is transmitted, and has not been corroborated against master data. A value that goes into the artifact must never be `not_applicable`. *Avoid:* using `not_applicable` to suppress noise on a field that carries data. - **ZUGFeRD mapping seam** — `moox/zugferd` consumes `ZugferdInvoice` via property hooks; **do not** put those hooks on `Data\Invoice` (PHPCS cannot parse PHP 8.4 hooks there). Use adapters instead: **`$dto->forZugferd()`** → `ZugferdInvoiceDtoAdapter` for parsed bill_data (tests, `EBilling::convertToXml`, any `ZugferdConverter::convert` on a DTO) — snapshots contract fields as public properties in its constructor (PHPCS-safe; field count mirrors `ZugferdInvoice`); **`new ZugferdInvoiceAdapter($model)`** for the persisted Eloquent invoice (`GenerateArtifactJob`, property hooks). Ship-to on both paths: party name + address → `shipToName` / `shipToAddress` (from `deliveryAddress` / `delivery` party). Persisted delivery uses `Address::toEn16931DeliveryParty()` and `Party::deliveryConsignee()`. Header computed fields on the DTO are **methods** — `bankAccounts()`, `allowanceCharges()` — not property hooks. Line DTO `allowanceCharges` remains a property hook; mappers read `$line->allowanceCharges`, never `allowanceCharges()`. *Avoid:* passing `Data\Invoice` directly to `ZugferdConverter` or `generateXml`. +- **Supplier number** — the seller identifier printed on the commercial document (German label: Lieferanten-Nr. / Lieferantennr.). EN 16931 **BT-29** (optional; no scheme when buyer and seller already share the meaning). Distinct from **customer number** (buyer identifier at the seller, BT-46) and **customer reference** (BT-10). Distinct from the purchasing master-data field on `Supplier` (Kreditorennummer). *Avoid:* "supplier ID" for the Moox Supplier record; conflating with VAT ID (BT-31) or tax number (BT-32). ### Review and correction @@ -54,6 +55,7 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Consignee is a party.** ✅ Invoice and line `delivery` is a name + address party. A missing country does **not** drop the party on persist; `ZugferdConverter` decides whether BG-15 is emitted (name-only ShipTo when the country is absent; omit when equal to buyer — see Duplicate consignee emission). Header/line `delivery_address` (label Consignee, hint BG-13) renders via `PartyAddressFormatter`. `ZugferdInvoiceAdapter` (persisted) and `ZugferdInvoiceDtoAdapter` (parsed DTO via `forZugferd()`) map the party to `shipToName` / `shipToAddress`; tax registration and contact are never written ([mooxphp/invoice#8](https://github.com/mooxphp/invoice/issues/8)). - **Duplicate consignee emission.** ✅ When ship-to name and postal fingerprint (street, street2, postal code, country; city excluded) match the buyer, `ZugferdConverter` omits BT-70/BG-15 (XRechnung §11.7); BT-72 may still emit. VAT category **K** still emits full BG-15 for BR-IC-12. Shared distinct line ship-to promotes to document BG-13 when the header is empty; otherwise divergent line parties / PO docs / despatch → BT-127. Adapters expose `shipTo*` without pre-nulling. Storage of a derived buyer-as-consignee remains forbidden (root ADR 0014 / 0020). +- **Supplier number (BT-29) persistence.** ✅ Parsed `supplierNumber` persists on `invoices.supplier_number`, shows in ViewInvoice supplier group, MoSCoW `could`, emitted via `addDocumentSellerId` when non-empty (ADR `docs/adr/0012-supplier-number-bt-29-on-invoices.md`). No backfill; no master-data corroboration. - **PDF product facts → EN carriers.** ✅ Adapters expose trade refs and line `itemAttributes` / `itemClassifications`. `LineItemAttributeMapper` maps material, net/gross weight as text with unit, and unpriced certificate designation → BG-32; BT-160 names + CAE reason_text fallbacks come from `e-billing::emission` via `document_locale` (package default `en`, not Filament/UI locale). Customs tariff → BT-158 scheme `HS`. Certificate **charges** default UNCL 7161 `CAE` (`BillDataAllowanceChargeMapper`, `InvoiceFactory`, model adapter fallback). Feature coverage: `ZugferdOmitDuplicateConsigneeXmlTest`. - **Format = strategy seam.** ✅ Three formats registered: `xrechnung` (pure CII XML, `XRECHNUNG` profile), `zugferd` (hybrid PDF), `factur-x` (hybrid PDF). All share one `ZugferdGeneratorStrategy`; hybrids take `FormatDefinition.profile` from `e-billing.default.profile` (default `EN16931`). Adding UBL as a syntax/strategy is a new registry entry; Peppol is transport (delivery channel), not a format — see ADR 0003. - **Scope now:** the three **CII-based** formats are live. UBL generation and Peppol delivery are deferred (separate layers). diff --git a/packages/e-billing/config/e-billing.php b/packages/e-billing/config/e-billing.php index 46c269d2d..c35a169be 100644 --- a/packages/e-billing/config/e-billing.php +++ b/packages/e-billing/config/e-billing.php @@ -613,6 +613,7 @@ // Seller — MUST (own company data, from system settings later) 'supplier_name' => 'must', // BT-27 + 'supplier_number' => 'could', // BT-29 'supplier_vat_id' => 'must', // BT-31 'supplier_tax_number' => 'should', // BT-32 'supplier_address' => 'must', // BG-5 diff --git a/packages/e-billing/docs/adr/0012-supplier-number-bt-29-on-invoices.md b/packages/e-billing/docs/adr/0012-supplier-number-bt-29-on-invoices.md new file mode 100644 index 000000000..23c50d85f --- /dev/null +++ b/packages/e-billing/docs/adr/0012-supplier-number-bt-29-on-invoices.md @@ -0,0 +1,35 @@ +--- +status: accepted +date: 2026-09-28 +--- + +# Persist and emit supplier number as EN 16931 BT-29 on `invoices.supplier_number` + +## Context + +Parsers already put the printed Lieferanten-Nr. on the e-billing DTO as `supplierNumber` / `bill_data.supplier_number` (letterhead also keys off it). That value never reached the persisted Invoice, ViewInvoice, or CII emission — `ParsedInvoiceMapper` / `InvoiceFactory` dropped it, and `ZugferdConverter` never called `addDocumentSellerId`. + +We needed one clear meaning and one persistence shape so review and XRechnung/ZUGFeRD/Factur-X stay aligned. + +## Decision + +1. **Meaning.** **Supplier number** is EN 16931 **BT-29** (seller identifier). Distinct from customer number (BT-46), customer reference (BT-10), and purchasing `Supplier.supplier_number` (Kreditorennummer). +2. **Persist.** Nullable indexed `invoices.supplier_number`, filled from the DTO through `InvoiceDraft` / `InvoiceBuilder` (same path as `customer_number`: empty string → null, otherwise **verbatim**, including interior whitespace). `bill_data` remains the parse snapshot via `Data\Invoice::toArray()`. Emission trims only to decide whether to omit BT-29. +3. **Emit.** When non-empty (after trim), `ZugferdConverter` calls `addDocumentSellerId` (no scheme). Empty/null omits BT-29. Shared converter → XRechnung, ZUGFeRD, and Factur-X. +4. **MoSCoW.** Priority **`could`** on invoice, credit-note, and corrected-invoice field maps — missing never blocks. +5. **UI.** Show in the ViewInvoice supplier (BG-4) group for invoices and credit notes; not denylisted. +6. **No corroboration** against master data. **No SQL backfill** — the next `GenerateArtifactJob` / leave-edit `createFromDto` fills the column from `bill_data`. ViewInvoice reads the column only until then. + +## Considered options + +- **bill_data only** — rejected: model adapter / ViewInvoice would stay blind; dual source of truth. +- **Seller `Party` identifier list** — rejected for v1: one optional string; YAGNI for multi-ID cardinality. +- **MoSCoW must/should** — rejected for package default: BT-29 is optional in EN 16931. +- **ISO 6523 scheme on BT-29** — rejected: the PDF has no scheme; buyer and seller already share the meaning. + +## Consequences + +- `ZugferdInvoice` gains `?string $supplierNumber` (**breaking** for custom contract implementors). +- Hosts that override `field_validation` maps must add `'supplier_number' => 'could'` themselves. +- Hosts that redeclare Invoice `$fillable` must list `supplier_number` for mass-assign/factory paths; `InvoiceBuilder` assigns the property directly either way. +- Value correction may later target this field key; this ADR does not ship correction UI. diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php index 0a631990c..f79cd036d 100644 --- a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php @@ -114,6 +114,9 @@ public function __construct( public ?string $paymentTerms { get => $this->model->payment_terms !== null && $this->model->payment_terms !== '' ? (string) $this->model->payment_terms + public ?string $supplierNumber { + get => $this->model->supplier_number !== null + ? (string) $this->model->supplier_number : null; } diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php index 271b71a52..da0c41c66 100644 --- a/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php @@ -56,6 +56,8 @@ final class ZugferdInvoiceDtoAdapter implements ZugferdInvoice public ?string $supplierTaxNumber; + public ?string $supplierNumber; + public ?string $paymentTerms; public ?string $deliveryDate; @@ -115,6 +117,7 @@ public function __construct(Invoice $invoice) $this->supplierVatId = $invoice->supplierVatId; $this->supplierTaxNumber = $invoice->supplierTaxNumber; $this->paymentTerms = $invoice->paymentTerms; + $this->supplierNumber = $invoice->supplierNumber; $this->deliveryDate = DeliveryDateTransmission::documentActualDeliveryDate( $invoice->deliveryDate, $invoice->lines, diff --git a/packages/e-billing/src/Services/InvoiceFactory.php b/packages/e-billing/src/Services/InvoiceFactory.php index bec7c23b6..f1b43a149 100644 --- a/packages/e-billing/src/Services/InvoiceFactory.php +++ b/packages/e-billing/src/Services/InvoiceFactory.php @@ -115,6 +115,9 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft due_date: $dto->dueDate, currency: $dto->currency, customer_number: $dto->customerNumber !== '' ? $dto->customerNumber : null, + supplier_number: $dto->supplierNumber !== null && $dto->supplierNumber !== '' + ? $dto->supplierNumber + : null, customer_reference: $dto->customerReference, order_number: $dto->orderNumber, order_date: $dto->orderDate, diff --git a/packages/e-billing/src/Services/ParsedInvoiceMapper.php b/packages/e-billing/src/Services/ParsedInvoiceMapper.php index d4a22a144..5953e1ee2 100644 --- a/packages/e-billing/src/Services/ParsedInvoiceMapper.php +++ b/packages/e-billing/src/Services/ParsedInvoiceMapper.php @@ -113,6 +113,9 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft due_date: $dto->dueDate, currency: $dto->currency, customer_number: $dto->customerNumber !== '' ? $dto->customerNumber : null, + supplier_number: $dto->supplierNumber !== null && $dto->supplierNumber !== '' + ? $dto->supplierNumber + : null, customer_reference: $dto->customerReference, order_number: $dto->orderNumber, order_date: $dto->orderDate, diff --git a/packages/e-billing/src/Support/InvoiceFieldLabels.php b/packages/e-billing/src/Support/InvoiceFieldLabels.php index 9a1493214..81d094b4a 100644 --- a/packages/e-billing/src/Support/InvoiceFieldLabels.php +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -170,6 +170,7 @@ public static function btNumber(string $field, ?string $context = null): ?string 'payment_means' => 'BT-81', 'vat_category' => 'BT-118', 'supplier_name' => 'BT-27', + 'supplier_number' => 'BT-29', 'supplier_vat_id' => 'BT-31', 'supplier_tax_number' => 'BT-32', 'supplier_address' => 'BG-5', diff --git a/packages/e-billing/src/ViewModels/InvoiceViewModel.php b/packages/e-billing/src/ViewModels/InvoiceViewModel.php index ffaea1117..5713a6341 100644 --- a/packages/e-billing/src/ViewModels/InvoiceViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceViewModel.php @@ -46,7 +46,7 @@ public function groupedFields(): array 'title' => __('e-billing::fields.section_seller_supplier'), 'subtitle' => 'BG-4', 'fields' => $this->buildFields([ - 'supplier_name', 'supplier_vat_id', 'supplier_tax_number', + 'supplier_name', 'supplier_number', 'supplier_vat_id', 'supplier_tax_number', 'supplier_address', 'supplier_bank_accounts', 'supplier_email', 'supplier_phone', 'payment_means', 'agent', ]), ], diff --git a/packages/zugferd/CONTEXT.md b/packages/zugferd/CONTEXT.md new file mode 100644 index 000000000..34a3a7093 --- /dev/null +++ b/packages/zugferd/CONTEXT.md @@ -0,0 +1,54 @@ +# ZUGFeRD — Context + +Glossary for structured e-invoice **emission** (`packages/zugferd`, `moox/zugferd`): turning an emission view into CII XML and, for hybrids, embedding that XML in a PDF/A-3. This package is **not** the persisted invoice model (see `moox/invoice`) and **not** the conversion pipeline or format menu (see `moox/e-billing`). Keep lean; curated truth lives in the vault. + +## Glossary + +### Formats and profiles + +- **Profile** — the EN 16931 / CIUS conformance level passed into the document builder for one convert (`MINIMUM`, `BASIC`, `EN16931`, `EXTENDED`, `XRECHNUNG`). It selects which carriers exist (e.g. how a line delivery date is written). Unknown keys are refused. *Avoid:* treating profile as the customer-facing format id; parking the pipeline default inside this package (defaults belong to the host / e-billing). + +- **Format** — the customer-facing deliverable shape (XRechnung / ZUGFeRD / Factur-X): pure XML vs hybrid PDF container. Owned by the e-billing format registry, not by this package. ZUGFeRD 2.x and Factur-X share one hybrid generator; they differ by label/container preference, not by a third XML dialect. *Avoid:* “three generators”; conflating format with profile. + +- **Hybrid PDF** — a PDF/A-3 whose human-readable pages carry an **embedded** CII XML (ZUGFeRD / Factur-X). Distinct from a loose XRechnung `.xml`. *Avoid:* “ZUGFeRD file” for pure XML; “PDF invoice” without saying whether XML is embedded. + +- **Pure XML artifact** — CII XML alone (XRechnung path). No PDF wrapper. *Avoid:* calling it ZUGFeRD. + +- **Unencrypted deliverable** — PDF/A-3 forbids encryption on the shipped hybrid. Input PDFs may be decrypted for merge; the merged artifact must not be re-encrypted. *Avoid:* owner-password “protection” on the outbound hybrid. + +- **Business process (BT-23)** — the process identifier some profiles stamp on the document. Left to the builder’s profile defaults: Peppol URI on XRechnung 3; omitted on EN16931 hybrids. *Avoid:* forcing Peppol BT-23 onto every profile. + +### Emission view + +- **Emission view** — the flat, convert-ready shape of a document (`ZugferdInvoice` / line / address / bank / allowance contracts). Adapters in other packages project persisted or parsed data into it. *Avoid:* passing the e-billing DTO or Eloquent invoice straight into the converter; calling the emission view the “stored invoice”. + +- **Incomplete invoice** — an emission view missing a field the converter requires for a valid EN 16931 document (e.g. seller/buyer postal address, seller electronic address, bank account with IBAN). Convert fails closed rather than inventing values. *Avoid:* silent defaults for missing fiscal parties or accounts. + +- **Seller identifier** — BT-29 on the emission view. When non-empty (after trim) it is written as an unschemed seller id; when empty it is omitted. Meaning and persistence live with the invoice / e-billing glossaries. *Avoid:* inventing an ISO 6523 scheme the document never carried; conflating with VAT id (BT-31). + +- **Preceding invoice reference** — BG-3 on the emission view (BT-25 number, optional BT-26 date). One referenced document per entry. Document-type rules for when it applies live in e-billing / invoice. *Avoid:* treating it as this document’s own number. + +- **Document note** — unstructured text carried as BT-22 (and line BT-127 where used). Includes facts that have no IssueDate carrier (e.g. purchase order date must not become OrderReference IssueDate — UBL-CR-018). *Avoid:* stuffing structured trade refs into notes when a core BT exists. + +- **Item attribute / classification** — BG-32 (BT-160/161) and BT-158 on a line. Free-text attributes need both name and value (BR-54); classifications need a scheme (BR-65), e.g. HS for customs. *Avoid:* dumping the same weight both as an attribute and as a duplicate BT-127 unless they differ. + +### Ship-to on emission + +- **Ship-to equality** — ship-to matches the buyer (or another baseline party) when names match case-insensitively after trim **and** the postal fingerprint matches: street, address line 2, postal code, country. **City is not** in the fingerprint. *Avoid:* name-token overlap as equality; treating layout labels such as `Firma` as address lines. + +- **Duplicate consignee (emission)** — a ship-to that equals the buyer under ship-to equality. At convert time BT-70 / BG-15 are **omitted** (XRechnung §11.7); BT-72 may still emit. VAT category **K** still emits a full BG-15 so BR-IC-12 / BR-IC-11 can pass. Storage must not invent a buyer-as-consignee (host / invoice ADR 0014); this package only suppresses duplicates on new artifacts. *Avoid:* “always emit whatever is stored”; omitting BT-72 because the address was dropped. + +- **Line ship-to promotion** — when the header has no consignee and every line shares one party that is **not** equal to the buyer, that party is emitted once as document BG-13 (emission only; the invoice column is not written). Otherwise divergent line parties become BT-127 notes; lines equal to the baseline get no note. *Avoid:* writing the promoted party back into storage from the converter. + +### Delivery dates + +- **Actual delivery date (document)** — BT-72 on the emission view, written as the document supply-chain event when present. *Avoid:* folding several different line dates into a header invoicing period (BG-14). + +- **Line delivery date** — a per-line date on the emission view. Non-EXTENDED profiles carry it as a line billing period with start and end equal to that day; EXTENDED carries line actual delivery. The **profile** selects the carrier. *Avoid:* deriving BG-14 from line dates. + +## Boundary + +- **In:** CII XML build from an emission view; PDF/A-3 merge with embedded XML; emission rules that must be identical for every adapter (duplicate ship-to, profile-keyed line dates, BT-23 defaults, required-field refusals). +- **Out:** Persisted invoice schema; parse/ MoSCoW / ViewInvoice; format menu and artifact jobs; KOSIT / veraPDF validation; host PDF layout chrome (`Firma`, letterhead); Peppol network transport. + +Cross-links: host ADR `docs/adr/0020-omit-duplicate-consignee-on-emission.md`, host ADR `docs/adr/0014-consignee-is-a-party-line-consignees-ride-as-line-notes.md`, e-billing ADR `packages/e-billing/docs/adr/0001-generate-then-validate-per-format-artifacts.md`, e-billing ADR `packages/e-billing/docs/adr/0012-supplier-number-bt-29-on-invoices.md`. From cf46cd210ec0442d86e3c10949fec3b82a08ad67 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 09:42:21 +0200 Subject: [PATCH 04/12] feat(invoice,zugferd,e-billing): carry BG-3 preceding invoice reference into the XML --- .../src/Adapters/ZugferdInvoiceAdapter.php | 18 +++++- .../src/Adapters/ZugferdInvoiceDtoAdapter.php | 11 +++- packages/e-billing/src/Data/Invoice.php | 6 ++ .../src/Services/ParsedInvoiceMapper.php | 38 +----------- .../Support/PrecedingInvoiceReferences.php | 58 +++++++++++++++++++ packages/invoice/CHANGELOG.md | 1 + packages/invoice/CONTEXT.md | 12 ++++ packages/invoice/README.md | 1 + ...ceding_invoices_to_invoices_table.php.stub | 28 +++++++++ .../invoice/src/InvoiceServiceProvider.php | 1 + packages/invoice/src/Models/Invoice.php | 3 + .../invoice/src/Support/InvoiceBuilder.php | 1 + packages/invoice/src/Support/InvoiceDraft.php | 3 + packages/zugferd/CHANGELOG.md | 1 + packages/zugferd/README.md | 3 +- .../zugferd/src/Contracts/ZugferdInvoice.php | 7 +++ packages/zugferd/src/ZugferdConverter.php | 20 +++++++ 17 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 packages/e-billing/src/Support/PrecedingInvoiceReferences.php create mode 100644 packages/invoice/database/migrations/add_preceding_invoices_to_invoices_table.php.stub diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php index f79cd036d..3e747cbbe 100644 --- a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php @@ -4,9 +4,11 @@ namespace Moox\EBilling\Adapters; +use Moox\EBilling\Support\CreditNotePaymentTerms; use Moox\EBilling\Support\DeliveryDateTransmission; use Moox\EBilling\Support\DocumentTypeCodeResolver; use Moox\EBilling\Support\InvoiceDocumentNotes; +use Moox\EBilling\Support\PrecedingInvoiceReferences; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Models\InvoiceAllowanceCharge; use Moox\Zugferd\Contracts\ZugferdAddress; @@ -111,15 +113,20 @@ public function __construct( get => $this->model->seller?->tax_number; } - public ?string $paymentTerms { - get => $this->model->payment_terms !== null && $this->model->payment_terms !== '' - ? (string) $this->model->payment_terms public ?string $supplierNumber { get => $this->model->supplier_number !== null ? (string) $this->model->supplier_number : null; } + public ?string $paymentTerms { + get => CreditNotePaymentTerms::forEmission( + (string) $this->model->document_type, + $this->model->due_date !== null ? (string) $this->model->due_date : null, + $this->model->payment_terms !== null ? (string) $this->model->payment_terms : null, + ); + } + public ?string $deliveryDate { get { $this->model->loadMissing('lines'); @@ -253,6 +260,11 @@ public function __construct( get => InvoiceDocumentNotes::fromInvoice($this->model); } + /** @var list */ + public array $precedingInvoices { + get => PrecedingInvoiceReferences::normalize($this->model->preceding_invoices); + } + private static function mapAllowanceCharge(InvoiceAllowanceCharge $charge): AllowanceCharge { return new AllowanceCharge( diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php index da0c41c66..6cdeedd7e 100644 --- a/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceDtoAdapter.php @@ -7,6 +7,7 @@ use Moox\EBilling\Data\Invoice; use Moox\EBilling\Data\InvoiceLine; use Moox\EBilling\Support\ConfiguredEn16931CodeResolver; +use Moox\EBilling\Support\CreditNotePaymentTerms; use Moox\EBilling\Support\DeliveryDateTransmission; use Moox\EBilling\Support\InvoiceDocumentNotes; use Moox\Zugferd\Contracts\ZugferdAddress; @@ -96,6 +97,9 @@ final class ZugferdInvoiceDtoAdapter implements ZugferdInvoice /** @var list */ public array $documentNotes; + /** @var list */ + public array $precedingInvoices; + public function __construct(Invoice $invoice) { $this->invoiceNumber = $invoice->invoiceNumber; @@ -116,8 +120,12 @@ public function __construct(Invoice $invoice) $this->agent = $invoice->agent; $this->supplierVatId = $invoice->supplierVatId; $this->supplierTaxNumber = $invoice->supplierTaxNumber; - $this->paymentTerms = $invoice->paymentTerms; $this->supplierNumber = $invoice->supplierNumber; + $this->paymentTerms = CreditNotePaymentTerms::forEmission( + $invoice->documentTypeCode, + $invoice->dueDate, + $invoice->paymentTerms, + ); $this->deliveryDate = DeliveryDateTransmission::documentActualDeliveryDate( $invoice->deliveryDate, $invoice->lines, @@ -156,5 +164,6 @@ static function (InvoiceLine $line): InvoiceLine { ); $this->bankAccounts = $invoice->bankAccounts(); $this->documentNotes = InvoiceDocumentNotes::fromDto($invoice); + $this->precedingInvoices = $invoice->precedingInvoices; } } diff --git a/packages/e-billing/src/Data/Invoice.php b/packages/e-billing/src/Data/Invoice.php index 7458bae36..048d5dec8 100644 --- a/packages/e-billing/src/Data/Invoice.php +++ b/packages/e-billing/src/Data/Invoice.php @@ -6,6 +6,7 @@ use Moox\EBilling\Adapters\ZugferdInvoiceDtoAdapter; use Moox\EBilling\Support\BillDataAllowanceChargeMapper; +use Moox\EBilling\Support\PrecedingInvoiceReferences; use Moox\EBilling\Support\VatIdNormalizer; use Moox\Zugferd\Contracts\ZugferdAllowanceCharge; use Moox\Zugferd\Contracts\ZugferdBankAccount; @@ -70,6 +71,9 @@ public function __construct( /** @var array */ public array $notes = [], + /** @var list BG-3: BT-25 number, BT-26 date (Y-m-d) */ + public array $precedingInvoices = [], + // Currency public string $currency = 'EUR', ) { @@ -277,6 +281,7 @@ public static function fromArray(array $data): self shippingMethod: isset($data['shipping_method']) && is_string($data['shipping_method']) ? $data['shipping_method'] : null, lines: $lines, notes: $notes, + precedingInvoices: PrecedingInvoiceReferences::normalize($data['preceding_invoices'] ?? null), currency: is_string($data['currency'] ?? null) && $data['currency'] !== '' ? $data['currency'] : 'EUR', ); @@ -380,6 +385,7 @@ public function toArray(): array 'lines' => array_map(fn (InvoiceLine $line) => $line->toArray(), $this->lines), 'notes' => $this->notes, + 'preceding_invoices' => $this->precedingInvoices, ]; } diff --git a/packages/e-billing/src/Services/ParsedInvoiceMapper.php b/packages/e-billing/src/Services/ParsedInvoiceMapper.php index 5953e1ee2..2fe1ee763 100644 --- a/packages/e-billing/src/Services/ParsedInvoiceMapper.php +++ b/packages/e-billing/src/Services/ParsedInvoiceMapper.php @@ -16,7 +16,6 @@ use Moox\Invoice\Models\Invoice; use Moox\Invoice\Models\InvoiceLine; use Moox\Invoice\Support\ChargeDraft; -use Moox\Invoice\Support\En16931\Address as En16931Address; use Moox\Invoice\Support\En16931\BankAccount as En16931BankAccount; use Moox\Invoice\Support\En16931\Contact; use Moox\Invoice\Support\En16931\Party; @@ -142,6 +141,7 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft ), headerCharges: $this->buildHeaderChargeDraftsFromDto($dto), notes: $dto->notes, + preceding_invoices: $dto->precedingInvoices, ); } @@ -283,7 +283,7 @@ private function mapEn16931Party( return null; } - $en16931Address = $this->mapEn16931Address($address); + $en16931Address = $address?->toEn16931Address(); if ($en16931Address === null) { return null; } @@ -297,40 +297,6 @@ private function mapEn16931Party( ); } - private function mapEn16931Address(?Address $address): ?En16931Address - { - if ($address === null) { - return null; - } - - $countryCode = $address->country !== null ? strtoupper(trim($address->country)) : ''; - if ($countryCode === '') { - return null; - } - - $line1 = trim((string) ($address->street ?? '')); - if ($line1 === '' && $address->company !== null) { - $line1 = trim($address->company); - } - - $line2 = $address->addressLine2; - if ($address->addressLine3 !== null && trim($address->addressLine3) !== '') { - $line3 = trim($address->addressLine3); - $line2 = $line2 !== null && trim($line2) !== '' - ? trim($line2)."\n".$line3 - : $line3; - } - - return new En16931Address( - line1: $line1, - line2: $line2 !== null && trim($line2) !== '' ? trim($line2) : null, - city: trim((string) ($address->city ?? '')), - postal_code: trim((string) ($address->zip ?? '')), - subdivision: null, - country_code: $countryCode, - ); - } - private function isNonZeroAmount(?float $amount): bool { return $amount !== null && (float) $amount !== 0.0; diff --git a/packages/e-billing/src/Support/PrecedingInvoiceReferences.php b/packages/e-billing/src/Support/PrecedingInvoiceReferences.php new file mode 100644 index 000000000..6b5335ff8 --- /dev/null +++ b/packages/e-billing/src/Support/PrecedingInvoiceReferences.php @@ -0,0 +1,58 @@ + + */ + public static function normalize(mixed $raw): array + { + if (! is_array($raw)) { + return []; + } + + $references = []; + + foreach ($raw as $row) { + if (! is_array($row) || ! is_string($row['number'] ?? null) || trim($row['number']) === '') { + continue; + } + + $date = $row['date'] ?? null; + + $references[] = [ + 'number' => trim($row['number']), + 'date' => is_string($date) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : null, + ]; + } + + return $references; + } + + /** + * The first reference, which the MoSCoW fields `preceding_invoice_number` / `_date` describe. + * + * @return array{number: string, date: ?string}|null + */ + public static function first(mixed $raw): ?array + { + return self::normalize($raw)[0] ?? null; + } + + /** + * Number form used to find a referenced invoice: separators ignored, so `30641.25` matches `3064125`. + */ + public static function comparableNumber(string $number): string + { + return strtoupper((string) preg_replace('/[^A-Za-z0-9]/', '', $number)); + } +} diff --git a/packages/invoice/CHANGELOG.md b/packages/invoice/CHANGELOG.md index e20ae4d4d..59a67dc93 100644 --- a/packages/invoice/CHANGELOG.md +++ b/packages/invoice/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Nullable indexed `supplier_number` on invoices (EN 16931 BT-29 seller identifier). Flows through `InvoiceDraft` / `InvoiceBuilder` / model / create-table stub; migration stub `add_supplier_number_to_invoices_table` (hosts must publish/run). +- Nullable `preceding_invoices` (json) on invoices for the EN 16931 preceding invoice reference (BG-3: list of `{number, date}`, BT-25/BT-26). `InvoiceDraft::$preceding_invoices` (default `[]`), persisted by `InvoiceBuilder`, cast as array on the model. Migration stub `add_preceding_invoices_to_invoices_table`; hosts must publish/run it. - Nullable `vat_category` on invoices (`InvoiceDraft` / `InvoiceBuilder` / model / create-table stub) for the EN 16931 VAT category stamp (BT-118). ### Changed diff --git a/packages/invoice/CONTEXT.md b/packages/invoice/CONTEXT.md index 99e26c770..59dd00c71 100644 --- a/packages/invoice/CONTEXT.md +++ b/packages/invoice/CONTEXT.md @@ -53,6 +53,18 @@ document wording, no host model names. Keep lean. cannot be tolerated at generation time — it is a blocking finding before it. Where several codes share a label in one language, the preferred code is configuration, not a constant keyed on a word in that language. +- **Credit note** — a document with type code 381 (BT-3). The type code carries the direction (money back + to the buyer); its quantities and amounts are therefore **positive**. *Avoid:* negative amounts on a 381: + they cancel the direction and read as a debit, and schema validation does not catch it. +- **Corrected invoice** — a document with type code 384 (BT-3) that corrects or cancels an earlier invoice, + which it references (BG-3), because that invoice was wrong or is cancelled. It is either a full restatement + of the corrected invoice (positive amounts) or a correction issued as a credit, a delta (negative amounts, + so a cancellation is negative). *Avoid:* treating it as a credit note: a credit note credits a later change + of the amount owed without correcting a specific invoice, and stays positive. +- **Preceding invoice reference** — BG-3: an earlier invoice that this document corrects or credits, + as its number (BT-25) and optional issue date (BT-26). A document may carry several. The buyer matches + it against the referenced invoice's BT-1, so any difference in spelling between the two is a matching risk. + Optional for credit notes (381). *Avoid:* treating it as the document's own number; parking it in a note. - **Buyer identifier** — BT-46. The debtor identity of the document. Distinct from **buyer reference** (BT-10); the two are separate terms and must not be conflated. - **Seller identifier** (**supplier number**) — BT-29. The seller's identifier as printed on the diff --git a/packages/invoice/README.md b/packages/invoice/README.md index bf30ba87a..ed649e813 100644 --- a/packages/invoice/README.md +++ b/packages/invoice/README.md @@ -192,6 +192,7 @@ The `Invoice` model (`Moox\Invoice\Models\Invoice`) stores the invoice header. I - `payment_terms` (text, nullable) - Payment terms free text (EN 16931 **BT-20**) - `shipping_method` (string, nullable) - Shipping / delivery method as shown on the document - `delivery_terms` (string, nullable) - Delivery terms / freight bearer (e.g. ex works; free text; serialized as note in e-billing / ZUGFeRD layer; not payment terms) +- `preceding_invoices` (json, nullable) - Preceding invoice references (EN 16931 **BG-3**: list of `{number, date}`, BT-25/BT-26) - `seller` (json, nullable) - Seller party snapshot; cast to `Party` via `PartyCast` - `buyer` (json, nullable) - Buyer party snapshot; cast to `Party` via `PartyCast` - `delivery` (json, nullable) - Consignee party (name + address); cast to `Party` via `DeliveryPartyCast`. VAT identifier, tax number, and contact are not stored. A stored consignee may lack `country_code`; BR-57 is enforced at emission. diff --git a/packages/invoice/database/migrations/add_preceding_invoices_to_invoices_table.php.stub b/packages/invoice/database/migrations/add_preceding_invoices_to_invoices_table.php.stub new file mode 100644 index 000000000..c05c95ed6 --- /dev/null +++ b/packages/invoice/database/migrations/add_preceding_invoices_to_invoices_table.php.stub @@ -0,0 +1,28 @@ +json('preceding_invoices')->nullable()->after('vat_category'); + } + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + if (Schema::hasColumn('invoices', 'preceding_invoices')) { + $table->dropColumn('preceding_invoices'); + } + }); + } +}; diff --git a/packages/invoice/src/InvoiceServiceProvider.php b/packages/invoice/src/InvoiceServiceProvider.php index e09510671..dfa3d9019 100644 --- a/packages/invoice/src/InvoiceServiceProvider.php +++ b/packages/invoice/src/InvoiceServiceProvider.php @@ -21,6 +21,7 @@ public function configureMoox(Package $package): void 'convert_delivery_json_to_party_shape', 'add_document_versioning_to_invoices_table', 'add_vat_category_to_invoices_table', + 'add_preceding_invoices_to_invoices_table', 'add_supplier_number_to_invoices_table', ]) ->hasCommands(); diff --git a/packages/invoice/src/Models/Invoice.php b/packages/invoice/src/Models/Invoice.php index bb2ef5ce6..29efa70dc 100644 --- a/packages/invoice/src/Models/Invoice.php +++ b/packages/invoice/src/Models/Invoice.php @@ -29,6 +29,7 @@ * @property int $document_version * @property bool $is_current * @property list|null $notes + * @property list|null $preceding_invoices */ class Invoice extends BaseItemModel { @@ -60,6 +61,7 @@ class Invoice extends BaseItemModel 'shipping_method', 'delivery_terms', 'notes', + 'preceding_invoices', 'seller', 'buyer', 'delivery', @@ -84,6 +86,7 @@ protected function casts(): array 'document_version' => 'integer', 'is_current' => 'boolean', 'notes' => 'array', + 'preceding_invoices' => 'array', 'net_total' => 'decimal:2', 'vat_rate' => 'decimal:2', 'vat_amount' => 'decimal:2', diff --git a/packages/invoice/src/Support/InvoiceBuilder.php b/packages/invoice/src/Support/InvoiceBuilder.php index c723a9289..89a1bf3be 100644 --- a/packages/invoice/src/Support/InvoiceBuilder.php +++ b/packages/invoice/src/Support/InvoiceBuilder.php @@ -46,6 +46,7 @@ private function persist(InvoiceDraft $draft): Invoice $invoice->shipping_method = $draft->shipping_method; $invoice->delivery_terms = $draft->delivery_terms; $invoice->notes = $draft->notes !== [] ? $draft->notes : null; + $invoice->preceding_invoices = $draft->preceding_invoices !== [] ? $draft->preceding_invoices : null; $invoice->seller = $draft->seller; $invoice->buyer = $draft->buyer; $invoice->delivery = $draft->delivery; diff --git a/packages/invoice/src/Support/InvoiceDraft.php b/packages/invoice/src/Support/InvoiceDraft.php index 1f336f488..7d908aca8 100644 --- a/packages/invoice/src/Support/InvoiceDraft.php +++ b/packages/invoice/src/Support/InvoiceDraft.php @@ -12,6 +12,8 @@ /** * @param list $lines * @param list $headerCharges + * @param list $notes + * @param list $preceding_invoices BG-3: BT-25 number, BT-26 date (Y-m-d) */ public function __construct( public string $invoice_number, @@ -40,6 +42,7 @@ public function __construct( public array $lines, public array $headerCharges, public array $notes = [], + public array $preceding_invoices = [], ) { } } diff --git a/packages/zugferd/CHANGELOG.md b/packages/zugferd/CHANGELOG.md index 23fa21273..db7510f0e 100644 --- a/packages/zugferd/CHANGELOG.md +++ b/packages/zugferd/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - `ZugferdInvoice` gains `?string $supplierNumber` (BT-29). **Breaking for custom implementations.** `ZugferdConverter` emits `addDocumentSellerId` when non-empty (trimmed); omits when null/empty; no scheme ID. +- `ZugferdInvoice` contract gains `array $precedingInvoices` (`list`, BG-3: BT-25 number, BT-26 issue date). **Breaking for custom implementations of the contract.** `ZugferdConverter` emits one `ram:InvoiceReferencedDocument` (IssuerAssignedID + optional FormattedIssueDateTime) per entry. - `ShipToPartyEquality`: ship-to equals buyer when case-insensitive trimmed names match and postal fingerprints match (street, `addressLine2`/street2, postal code, country). City is **not** in the fingerprint. - `ZugferdConverter` emission (ADR 0020): omit BT-70 / BG-15 when ship-to equals buyer; keep BT-72; VAT category **K** still emits full BG-15 (buyer postal acceptable when empty) for BR-IC-12 / BR-IC-11. Promote one shared distinct line ship-to to document BG-13 when the header is empty; otherwise divergent line parties, different PO document numbers, and non-common despatch refs → BT-127 notes. - Line contracts: `itemAttributes` / `itemClassifications` → BG-32 / BT-158; trade refs BT-13 (`purchaseOrderReference`), BT-16 (`despatchAdviceReference`, with common line DN fallback), BT-132 via `purchaseOrderLineReference`; line allowance/charges. diff --git a/packages/zugferd/README.md b/packages/zugferd/README.md index 3242e07a1..53b38e812 100644 --- a/packages/zugferd/README.md +++ b/packages/zugferd/README.md @@ -16,9 +16,9 @@ Moox Zugferd converts invoice data implementing `ZugferdInvoice` into valid ZUGF - Concrete `AllowanceCharge` DTO for tests and simple consumers - Required profile key on convert: MINIMUM, BASIC, EN16931, EXTENDED, XRECHNUNG (unknown keys throw) - Optional `deliveryDate` on invoice (BT-72 via `setDocumentSupplyChainEvent`) and on lines (line billing period with start=end for non-EXTENDED profiles, line actual delivery for EXTENDED); profile key selects the line-date carrier; no header invoicing period (BG-14) is derived from delivery dates -- Optional `shipToName` / `shipToAddress` on invoice and lines (BG-13 via `setDocumentShipTo` / `setDocumentShipToAddress`); address is omitted when country is empty; ship-to tax registration and contact are never emitted - Optional `supplierNumber` (BT-29; emit via `addDocumentSellerId` when non-empty, omit when empty/whitespace), `shipToName` / `shipToAddress` on invoice and lines (BG-13 via `setDocumentShipTo` / `setDocumentShipToAddress`); address is omitted when country is empty; ship-to tax registration and contact are never emitted - Omit duplicate ShipTo when name + postal fingerprint equals buyer (street, street2, postal code, country; city excluded; keep BT-72); VAT **K** still emits BG-15 (buyer postal OK). Promote shared distinct line ship-to when header empty; else BT-127 notes. Line `itemAttributes` / `itemClassifications` → BG-32 / BT-158; trade refs BT-13 / BT-16 (common line DN fallback) / BT-132 (`purchaseOrderLineReference`); line allowance/charges. `purchaseOrderDate` is never OrderReference IssueDate (UBL-CR-018) +- Optional `precedingInvoices` on invoice (BG-3): one `ram:InvoiceReferencedDocument` per entry (IssuerAssignedID + optional FormattedIssueDateTime) @@ -116,7 +116,6 @@ Header, parties, totals, `lines`, `bankAccounts`, and `allowanceCharges`. - Non-empty trimmed `supplierEmail` - Non-empty `bankAccounts` with non-empty IBAN on each account -**Other notable fields:** `documentType` (credit note when value contains `gutschrift` → type code `381`, else `380`), `paymentMeansCode` (default `58`), `dueDate` / `paymentTerms`, `deliveryDate` (BT-72), `documentNotes` (BT-22), `purchaseOrderReference` (BT-13), `despatchAdviceReference` (BT-16; converter may fill from a common line `deliveryNoteNumber`), `purchaseOrderDate` (unstructured notes only — never OrderReference IssueDate), `shipToName` / `shipToAddress` (BG-13; address requires a country; omitted when equal to buyer unless VAT **K**), `vatCategoryCode`, `vatRate`, `netTotal`, `vatAmount`, `grossTotal`. **Other notable fields:** `documentType` (credit note when value contains `gutschrift` → type code `381`, else `380`), `paymentMeansCode` (default `58`), `dueDate` / `paymentTerms`, `supplierNumber` (BT-29; emit via `addDocumentSellerId` when non-empty), `deliveryDate` (BT-72), `documentNotes` (BT-22), `purchaseOrderReference` (BT-13), `despatchAdviceReference` (BT-16; converter may fill from a common line `deliveryNoteNumber`), `purchaseOrderDate` (unstructured notes only — never OrderReference IssueDate), `shipToName` / `shipToAddress` (BG-13; address requires a country; omitted when equal to buyer unless VAT **K**), `precedingInvoices` (BG-3: `list`, BT-25/BT-26 — one `InvoiceReferencedDocument` per entry), `vatCategoryCode`, `vatRate`, `netTotal`, `vatAmount`, `grossTotal`. ### `ZugferdInvoiceLine` diff --git a/packages/zugferd/src/Contracts/ZugferdInvoice.php b/packages/zugferd/src/Contracts/ZugferdInvoice.php index 48a35efbe..d9a369112 100644 --- a/packages/zugferd/src/Contracts/ZugferdInvoice.php +++ b/packages/zugferd/src/Contracts/ZugferdInvoice.php @@ -83,4 +83,11 @@ interface ZugferdInvoice /** @var list */ public array $documentNotes { get; } + + /** + * BG-3 preceding invoice references: BT-25 number, BT-26 issue date (Y-m-d). + * + * @var list + */ + public array $precedingInvoices { get; } } diff --git a/packages/zugferd/src/ZugferdConverter.php b/packages/zugferd/src/ZugferdConverter.php index 7a7cb4365..88b4241dd 100644 --- a/packages/zugferd/src/ZugferdConverter.php +++ b/packages/zugferd/src/ZugferdConverter.php @@ -217,6 +217,7 @@ private function buildDocument(ZugferdInvoice $invoice, string $profileKey): Zug $this->setSeller($document, $invoice); $this->setBuyer($document, $invoice); $this->setTradeReferences($document, $invoice); + $this->setPrecedingInvoices($document, $invoice); $this->setDelivery($document, $invoice); if (trim($invoice->vatCategoryCode) === '') { throw new IncompleteInvoiceException('Missing required field: vatCategoryCode (BT-118).'); @@ -253,6 +254,25 @@ private function setDocumentNotes(ZugferdDocumentBuilder $doc, ZugferdInvoice $i } } + /** + * BG-3: one InvoiceReferencedDocument per preceding invoice (BT-25, optional BT-26). + */ + private function setPrecedingInvoices(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + foreach ($invoice->precedingInvoices as $reference) { + $number = trim($reference['number']); + if ($number === '') { + continue; + } + + $date = $reference['date'] !== null + ? (\DateTime::createFromFormat('!Y-m-d', $reference['date']) ?: null) + : null; + + $doc->addDocumentInvoiceReferencedDocument($number, null, $date); + } + } + // ─── Seller (BG-4) ────────────────────────────────────────── private function setSeller(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void From 2d705f311a11e7687dbe90be552318c9235bfbf4 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 10:10:28 +0200 Subject: [PATCH 05/12] feat(e-billing): validate credit notes and corrected invoices with own profiles --- packages/e-billing/CHANGELOG.md | 4 + packages/e-billing/CONTEXT.md | 9 +- packages/e-billing/README.md | 8 +- packages/e-billing/config/e-billing.php | 432 +++++++++++++++++- ...009-document-type-field-and-ui-profiles.md | 57 +++ .../e-billing/resources/lang/de/fields.php | 4 + .../e-billing/resources/lang/en/fields.php | 4 + .../partials/invoice-field-row.blade.php | 2 + .../src/Actions/ConfirmInvoiceAction.php | 1 + .../src/Approval/AutoApproveEvaluator.php | 6 + .../e-billing/src/Models/EbillingDocument.php | 119 ++--- .../src/Services/InvoiceFieldValidator.php | 52 +++ .../src/Support/FieldValidationProfile.php | 171 +++++++ .../src/Support/InvoiceFieldLabels.php | 13 + .../src/Support/InvoiceUiPresentation.php | 52 +-- .../SeverityReleaseSnapshotCollector.php | 40 +- .../src/ViewModels/FieldViewData.php | 1 + .../src/ViewModels/InvoiceLineViewModel.php | 14 +- .../src/ViewModels/InvoiceViewModel.php | 47 +- 19 files changed, 889 insertions(+), 147 deletions(-) create mode 100644 packages/e-billing/docs/adr/0009-document-type-field-and-ui-profiles.md create mode 100644 packages/e-billing/src/Support/FieldValidationProfile.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 0e682943c..84ad35011 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased ### Added - Supplier number (EN 16931 BT-29, ADR 0012): persist DTO `supplierNumber` onto `invoices.supplier_number` via `ParsedInvoiceMapper` / `InvoiceFactory`; MoSCoW `could` on invoice / credit-note / corrected-invoice field maps; ViewInvoice supplier (BG-4) group shows `supplier_number`; both Zugferd adapters expose `supplierNumber`. No SQL backfill — next `GenerateArtifactJob` / leave-edit fills the column from `bill_data`. +- Document-type MoSCoW / ViewInvoice profiles (ADR 0009): `FieldValidationProfile` selects `field_validation.credit_note_*` and `invoice_ui.credit_note_*_hidden` for BT-3 `381`, falling back to the `invoice_*` siblings when a host omits a `credit_note_*` key; every other document type keeps reading `invoice_*`. `EbillingDocument::profileDocumentType()` (from the linked invoice's `document_type`) is threaded through `fieldValidationsNeedHumanReview()`, `missingMustFields()`, `hasBlockingMustFieldFindings()`, `calculateValidationScore()`, `isFullyValidated()`, and the review-queue scope (now optional trailing `?string $documentType` params). Package default config clones the invoice maps onto `credit_note_fields` / `credit_note_line_fields` / contextual lists and hides `preceding_invoice_number` / `preceding_invoice_date` on invoices. +- Preceding invoice reference (BG-3, ADR 0009 addendum): DTO `Data\Invoice::$precedingInvoices` (bill_data key `preceding_invoices`, via `PrecedingInvoiceReferences`), mapped onto the persisted invoice by `ParsedInvoiceMapper` / `InvoiceFactory`, and onto `ZugferdInvoice::precedingInvoices` by both Zugferd adapters. `InvoiceFieldValidator` looks the referenced invoice up among stored invoices (separators ignored, e.g. `30641.25` matches `3064125`); not found or a differing date is a non-blocking warning (`reason: preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders the field as a link to that invoice. +- Credit-note resource lists 381 and 384 (`resources.credit_notes.document_types`), with `tabs.credit_notes` (per-type tabs) and a document-type badge column when a resource shows more than one type. +- `e-billing.credit_note_payment_terms` (default `null`): BT-20 text emitted for a 381 without due date and payment terms, so BR-CO-25 accepts a positive amount due. - `e-billing.intake.scopes`: optional allowlist of mail-inbox Scope keys for `ProcessInboxAttachmentListener` (null/`[]` = all; non-listed PDFs marked Skipped, no `EbillingDocument`). - Optional `e-billing.delivery.from_name` / `EBILLING_DELIVERY_FROM_NAME` (display name only; From address is host-owned). diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 19e6fcbf3..dc6db967c 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -29,9 +29,10 @@ Glossary for the generic e-billing conversion pipeline (`packages/e-billing`). K - **Implicit ship-to** — when the invoice carries no header or line delivery party, the validator treats the **buyer** party (name + address) as the effective consignee for `delivery_address` corroboration. There is always a ship-to for validation purposes; an empty buyer after inference is a `missing` finding when `delivery_address` is `must`. - **Delivery corroboration** — after customer + company attribution, consignee name (token overlap) **and** address (`AddressFingerprint`) must both match master data: `delivery_address` role first, then `postal_address` / `billing_address` fallback. Divergences flag `needs_review` only; they never clear attribution. *Avoid:* treating buyer-address corroboration as sufficient for ship-to when a distinct delivery party is present on the document. - **Divergence status** — an annotation on the pair `{ master-data record, field }` — never on a document, because one stale field shows up on every document of that customer. Sparse: only exceptions get one. Three kinds, with deliberately different lifetimes: *seen but unclear*; *reported as fixed upstream* (transient — it resolves itself when the corrected master data arrives, and returns if it never does); *permanently ignored*, which **requires a reason**. *Avoid:* resolving a divergence — you resolve the master data, not the finding. -- **Invoice UI denylist** — ViewInvoice-only list of field keys under `invoice_ui.invoice_fields_hidden` / `invoice_line_fields_hidden` that are never rendered (even when filled). Validation and MoSCoW severity are unchanged. Empty groups after filtering are not rendered. *Avoid:* stuffing visibility into MoSCoW entries; skipping validation for hidden fields; counting denylisted fields toward section force-open markers. See ADR `docs/adr/0007-invoice-view-field-denylist-and-collapsible-groups.md`. +- **Invoice UI denylist** — ViewInvoice-only list of field keys under `invoice_ui` that are never rendered (even when filled). Keys are type-scoped: `invoice_fields_hidden` / `invoice_line_fields_hidden` for `document_type` 380; `credit_note_fields_hidden` / `credit_note_line_fields_hidden` for 381 (v1 clone of invoice denylist; omit → fall back to invoice keys). Validation and MoSCoW severity are unchanged. Empty groups after filtering are not rendered. `field_groups` stays shared across types. *Avoid:* stuffing visibility into MoSCoW entries; skipping validation for hidden fields; counting denylisted fields toward section force-open markers; nesting UI under `profiles.{code}` while MoSCoW uses parallel keys. See ADR `docs/adr/0007-invoice-view-field-denylist-and-collapsible-groups.md` and ADR `docs/adr/0009-document-type-field-and-ui-profiles.md`. - **Collapsible field group** — a ViewInvoice section (`document` / `supplier` / `buyer` / `delivery` / `totals` / `notes` / line items) rendered as `
`, with host `invoice_ui.field_groups.*.default_open`. Groups with visible blocking must-findings force-open and show a text+colour summary marker; denylisted fields do not count. *Avoid:* colour-only markers; nesting notes inside delivery. Same ADR. -- **Severity gating (MoSCoW)** — per-field priority (`must`, `should`, `could`) under `field_validation` controls whether a missing or divergent field blocks human review. **must** + absent blocks with no release path; **should** + absent blocks until a **severity release** records actor id, timestamp, and reason via `ReleaseSeverityFieldAction`; **could** + absent is encoded as `not_applicable` by the validator and does not block. Wrong content (`needs_review`) always blocks `needsHumanReview()` / the review queue and is never releasable via severity release. Severity release is not value correction and not dispatch approval. `needsHumanReview()` asks whether unresolved findings remain (no `review_status` filter). `scopeNeedsHumanReview()` is the review queue: same field predicate within awaiting-review statuses, with releases honoured in SQL. **Manual confirm is a narrower gate:** `ConfirmInvoiceAction` hard-blocks only on **must + missing** while `review_status` is `db_validated`; `needs_review` and missing should do not refuse confirm (ADR `docs/adr/0005-confirm-gate-must-missing-only.md`). *Avoid:* treating severity release as fixing wrong content; conflating review clearance with dispatch; assuming confirm uses the same predicate as `needsHumanReview()` ([#13](https://github.com/mooxphp/e-billing/issues/13)). +- **MoSCoW field profile** — the must/should/could map under `field_validation` that `InvoiceFieldValidator` applies. Selected by **`document_type` (BT-3)**: `381` → `credit_note_fields` / `credit_note_line_fields` (and matching contextual keys); `380` → `invoice_*`. Parallel sibling maps (not nested profiles, not a delta overlay). Package owns the type→key switch and ships `credit_note_*` defaults as a clone of `invoice_*`; if a host omits them, fall back to `invoice_*`. Host owns priority values. German vs foreign invoice profiles are deferred. *Avoid:* selecting by Filament resource key; stamping a separate profile name until one type code needs multiple profiles. See ADR `docs/adr/0009-document-type-field-and-ui-profiles.md`. +- **Severity gating (MoSCoW)** — per-field priority (`must`, `should`, `could`) from the active MoSCoW field profile controls whether a missing or divergent field blocks human review. **must** + absent blocks with no release path; **should** + absent blocks until a **severity release** records actor id, timestamp, and reason via `ReleaseSeverityFieldAction`; **could** + absent is encoded as `not_applicable` by the validator and does not block. Wrong content (`needs_review`) always blocks `needsHumanReview()` / the review queue and is never releasable via severity release. Severity release is not value correction and not dispatch approval. `needsHumanReview()` asks whether unresolved findings remain (no `review_status` filter). `scopeNeedsHumanReview()` is the review queue: same field predicate within awaiting-review statuses, with releases honoured in SQL. **Manual confirm is a narrower gate:** `ConfirmInvoiceAction` hard-blocks only on **must + missing** while `review_status` is `db_validated`; `needs_review` and missing should do not refuse confirm (ADR `docs/adr/0005-confirm-gate-must-missing-only.md`). *Avoid:* treating severity release as fixing wrong content; conflating review clearance with dispatch; assuming confirm uses the same predicate as `needsHumanReview()` ([#13](https://github.com/mooxphp/e-billing/issues/13)). - **Severity release** — a recorded act allowing progression despite a missing **should** field only. Stored on `severity_releases` (not mass-assignable). Requires authenticated actor (`released_by_id`, with `released_by` as display copy), non-empty reason, timestamp, and `status: missing`. Gate validates entry shape, not only the action entrance. Distinct from value correction and from approval ([#13](https://github.com/mooxphp/e-billing/issues/13)). - **Dispatch approval** — `approval_status` on the document (`pending` / `approved` / `rejected`) is the operational send gate, distinct from `review_status` and from severity release. Latest `approval_actor_id` (`system` for auto-approve), `approval_acted_at`, and `approval_reason` are the current sign-off (not a history array). Severity-release reasons are forwarded into `approval_reason` on approve when no other reason is given. History is the Activity trail via `moox/audit` (`approval_status`, `approval_reason` in the body; who/when via Activity causer and timestamp). Auto-approve `save()` drops the current user so the causer is host `audit.system_causer` or empty, not the operator; document actor id stays `system`. `DocumentDispatchGuard` never reads Activity; approved-but-missing actor/acted-at is `approval_incomplete`. Initialize and invalidate set `pending` and clear actor/time/reason; rematch and manual attribution invalidate. `approval_flags.duplicate` is synced from duplicate invoice-number validation; hosts may set `approval_flags.anomalies` on the document (no dedicated writer API on the model). Auto-approve still requires every condition including no duplicate/anomaly flags; manual approve requires only pending status, a deliverable gateway artifact, no unresolved human-review findings, and no blocking must-field — duplicate and anomaly flags do not block a human sign-off. `DocumentApprovalTransitioned` is a host notification seam ([#15](https://github.com/mooxphp/e-billing/issues/15)). Enter-review announce ([#17](https://github.com/mooxphp/e-billing/issues/17)): `DocumentEnteredReview` + `NotifyDocumentsNeedReviewJob` via immediate/batched strategy; package does not send mail. - **Action type** — what a reviewer's recorded act *means*: **value correction**, **approval**, **rejection**, or **severity release** (dispatching despite a missing recommended field). Load-bearing, not descriptive: only value corrections carry "the parser got this wrong", so only they feed the parser-feedback report. *Avoid:* treating any reviewer edit as a correction. @@ -50,8 +51,8 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Delivery date carriage.** ✅ A single delivery date is emitted as BT-72 (`ActualDeliverySupplyChainEvent`). Several differing dates stay per line and are never folded into BG-14; EN16931/XRechnung use a line period (BG-26) with start and end equal to that line's day, EXTENDED uses the line-level actual delivery date. BR-IC-11 on an intra-community invoice with several dates is a `needs_review` finding on `delivery_date`, not a silent merge ([#62](https://github.com/mooxphp/e-billing/issues/62)). - **Invoice notes (BT-22).** ✅ `delivery_terms`, `shipping_method`, parser `notes[]`, and `order_date` are shown in the Filament Notes section (`InvoiceViewModel::noteFields()`), persisted on `invoices.notes` (JSON), and emitted as `IncludedNote` via `InvoiceDocumentNotes` → `ZugferdInvoice::documentNotes`. Delivery terms, shipping method, and order date are prefixed with their field labels in XML; parser free-text notes are emitted verbatim. `order_date` is never written as OrderReference IssueDate (UBL-CR-018). -- **Invoice ViewInvoice UI.** ✅ Presentation config `e-billing.invoice_ui`: field denylists + collapsible groups with force-open and text markers for visible blocking must-findings. Does not change MoSCoW validation (ADR `docs/adr/0007-invoice-view-field-denylist-and-collapsible-groups.md`). -- **Severity gating.** ✅ Three distinct MoSCoW behaviours ([#13](https://github.com/mooxphp/e-billing/issues/13)): must blocks without override; should blocks until severity release (`released_by_id` + reason + timestamp, gate-validated); could logs only via validator. `ReleaseSeverityFieldAction`, findings gate vs review queue (`needsHumanReview()` / `scopeNeedsHumanReview()`), `severity_releases` JSON column (nullable, not in `$fillable`). Actorless or malformed release entries do not unblock. Dispatch gating remains [#12](https://github.com/mooxphp/e-billing/issues/12). **Confirm gate** (ADR `docs/adr/0005-confirm-gate-must-missing-only.md`): confirm hard-blocks only must+missing; does not reuse full `needsHumanReview()`. +- **Invoice ViewInvoice UI.** ✅ Presentation config `e-billing.invoice_ui`: field denylists + collapsible groups with force-open and text markers for visible blocking must-findings. Does not change MoSCoW validation (ADR `docs/adr/0007-invoice-view-field-denylist-and-collapsible-groups.md`). Document-type parallel denylist keys for credit notes: ADR `docs/adr/0009-document-type-field-and-ui-profiles.md`. +- **Severity gating.** ✅ Document-type MoSCoW field profiles (ADR `docs/adr/0009-document-type-field-and-ui-profiles.md`). Three distinct MoSCoW behaviours ([#13](https://github.com/mooxphp/e-billing/issues/13)): must blocks without override; should blocks until severity release (`released_by_id` + reason + timestamp, gate-validated); could logs only via validator. `ReleaseSeverityFieldAction`, findings gate vs review queue (`needsHumanReview()` / `scopeNeedsHumanReview()`), `severity_releases` JSON column (nullable, not in `$fillable`). Actorless or malformed release entries do not unblock. Dispatch gating remains [#12](https://github.com/mooxphp/e-billing/issues/12). **Confirm gate** (ADR `docs/adr/0005-confirm-gate-must-missing-only.md`): confirm hard-blocks only must+missing; does not reuse full `needsHumanReview()`. - **Consignee is a party.** ✅ Invoice and line `delivery` is a name + address party. A missing country does **not** drop the party on persist; `ZugferdConverter` decides whether BG-15 is emitted (name-only ShipTo when the country is absent; omit when equal to buyer — see Duplicate consignee emission). Header/line `delivery_address` (label Consignee, hint BG-13) renders via `PartyAddressFormatter`. `ZugferdInvoiceAdapter` (persisted) and `ZugferdInvoiceDtoAdapter` (parsed DTO via `forZugferd()`) map the party to `shipToName` / `shipToAddress`; tax registration and contact are never written ([mooxphp/invoice#8](https://github.com/mooxphp/invoice/issues/8)). - **Duplicate consignee emission.** ✅ When ship-to name and postal fingerprint (street, street2, postal code, country; city excluded) match the buyer, `ZugferdConverter` omits BT-70/BG-15 (XRechnung §11.7); BT-72 may still emit. VAT category **K** still emits full BG-15 for BR-IC-12. Shared distinct line ship-to promotes to document BG-13 when the header is empty; otherwise divergent line parties / PO docs / despatch → BT-127. Adapters expose `shipTo*` without pre-nulling. Storage of a derived buyer-as-consignee remains forbidden (root ADR 0014 / 0020). diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index c1a079aab..a325eb4de 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -94,7 +94,7 @@ Published as `config/e-billing.php`. | `default_customer_country` | Transitional fallback buyer country when the parser derives none (default `DE`); removed in a future master-data phase | | `supplier` | Central supplier master data copied onto invoices as a snapshot at creation time | | `corroboration` | Post-attribution master-data checks (never clears `customer_id`): `name_min_token_length`, `name_legal_form_stop_words`, `buyer_address_roles` (billing + postal), `delivery_address_roles` (delivery first, then postal/billing fallback) | -| `field_validation` | MoSCoW priority rules for invoice and line fields | +| `field_validation` | MoSCoW priority rules for invoice and line fields. `credit_note_*` siblings (fields, line fields, contextual-should lists) apply to BT-3 `381` documents; a host that omits a `credit_note_*` key falls back to the `invoice_*` key (ADR 0009) | | `approval` | Dispatch approval gate: `required`, `auto_approve_enabled` | | `notification` | Review announce strategy: `immediate` / `batched`, batch key, window minutes, optional `recorder` class | | `escalation` | Overdue-approval scan: `day_counting`, `working_weekdays`, `exclude_dates`, ordered `levels` (`key` / `after` / `unit`); empty `levels` disables the feature | @@ -220,6 +220,12 @@ Within awaiting-review statuses, both use the same field predicate (including va Changing a field's configured priority changes its behaviour with no code change. +### Document-type profiles (credit notes, corrected invoices) + +`FieldValidationProfile` selects which MoSCoW and ViewInvoice-denylist maps apply, keyed on the invoice's `document_type` (BT-3), ADR 0009. `field_validation.document_type_profiles` maps a type code to a key prefix — package default `381 => credit_note`, `384 => corrected_invoice`. A mapped type reads `field_validation.{prefix}_fields` / `{prefix}_line_fields` / `{prefix}_contextual_should` / `{prefix}_line_contextual_should` and `invoice_ui.{prefix}_fields_hidden` / `{prefix}_line_fields_hidden`; unmapped types read the `invoice_*` keys, and a host config that omits a `{prefix}_*` key falls back to the matching `invoice_*` key. The package default config spells out both profiles: the credit-note profile adds `preceding_invoice_number` / `preceding_invoice_date` as `could`, the corrected-invoice profile as `should`; invoices hide both fields. + +**Preceding invoice reference (BG-3):** `preceding_invoice_number` / `preceding_invoice_date` map to `Invoice::$preceding_invoices[0]` (BT-25/BT-26). When present, `InvoiceFieldValidator` looks the referenced invoice up among stored invoices — number comparison ignores separators (`30641.25` matches `3064125`) — and never blocks: not found or a differing date is `status: parsed` with a `reason` (`preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders `preceding_invoice_number` as a link to that invoice. + ### Duplicate document-number rule `InvoiceNumberDuplicateChecker` (used by `InvoiceFieldValidator`, and for identical-content discard in `GenerateArtifactJob` / `DiscardIdenticalContentDuplicateAction`) runs during field validation — before review clearance and the dispatch approval gate. diff --git a/packages/e-billing/config/e-billing.php b/packages/e-billing/config/e-billing.php index c35a169be..4f73406a1 100644 --- a/packages/e-billing/config/e-billing.php +++ b/packages/e-billing/config/e-billing.php @@ -207,11 +207,11 @@ | | DocumentTypeCodeResolver only accepts these codes. Anything else throws | UnresolvedCodelistLabelException (routes to needs-review). Defaults cover - | commercial invoice (380) and credit note (381). + | commercial invoice (380), credit note (381) and corrected invoice (384). | */ - 'allowed_document_type_codes' => ['380', '381'], + 'allowed_document_type_codes' => ['380', '381', '384'], /* |-------------------------------------------------------------------------- @@ -250,6 +250,19 @@ 'document_locale' => env('EBILLING_DOCUMENT_LOCALE', 'en'), + /* + |-------------------------------------------------------------------------- + | Credit note payment terms (BT-20) + |-------------------------------------------------------------------------- + | + | BR-CO-25: a positive amount due (BT-115) needs BT-9 or BT-20. Credit + | notes (381) carry positive amounts and usually print neither, so this + | text is emitted as BT-20 when both are empty. null = no fallback. + | + */ + + 'credit_note_payment_terms' => null, + 'preferred_piece_unit_code' => env('EBILLING_PREFERRED_PIECE_UNIT_CODE', 'H87'), 'piece_unit_codes' => ['C62', 'H87'], @@ -304,7 +317,7 @@ 'navigation_icon' => 'heroicon-o-receipt-refund', 'navigation_sort' => 2, 'navigation_count_badge' => true, - 'document_types' => ['381'], + 'document_types' => ['381', '384'], 'soft_delete_tab_key' => 'deleted', 'resource' => CreditNoteResource::class, 'manual_upload' => [ @@ -312,6 +325,9 @@ 'label' => 'Upload credit note', 'scope' => 'credit-notes', 'requires_letterhead_overlay' => true, + // Codes the uploader chooses from (required, no preselection). One code = fixed, [] = the + // parser decides. Must be document_types, document_classification types and allowed codes. + 'document_types' => ['381', '384'], ], ], ], @@ -405,6 +421,126 @@ ], ], ], + 'credit_notes' => [ + 'all' => [ + 'label' => 'trans//e-billing::fields.tab_all', + 'icon' => 'gmdi-filter-list', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'credit_notes' => [ + 'label' => 'trans//e-billing::fields.tab_credit_notes', + 'icon' => 'gmdi-receipt', + 'query' => [ + [ + 'field' => 'document_type', + 'operator' => 'in', + 'value' => ['381'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'corrected_invoices' => [ + 'label' => 'trans//e-billing::fields.tab_corrected_invoices', + 'icon' => 'gmdi-edit-note', + 'query' => [ + [ + 'field' => 'document_type', + 'operator' => 'in', + 'value' => ['384'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'gateway_failed' => [ + 'label' => 'trans//e-billing::fields.tab_gateway_failed', + 'icon' => 'gmdi-error', + 'query' => [ + [ + 'field' => 'gateway_status', + 'operator' => 'in', + 'value' => ['generation_failed', 'validation_failed', 'validator_error'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'processing' => [ + 'label' => 'trans//e-billing::fields.tab_processing', + 'icon' => 'gmdi-hourglass-empty', + 'query' => [ + [ + 'field' => 'gateway_status', + 'operator' => 'in', + 'value' => ['generating', 'validating'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'needs_review' => [ + 'label' => 'trans//e-billing::fields.tab_needs_review', + 'icon' => 'gmdi-warning', + 'query' => [ + [ + 'field' => 'review_status', + 'operator' => 'in', + 'value' => ['parser_created', 'db_validated'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'confirmed' => [ + 'label' => 'trans//e-billing::fields.tab_confirmed', + 'icon' => 'gmdi-check-circle', + 'query' => [ + [ + 'field' => 'review_status', + 'operator' => 'in', + 'value' => ['human_confirmed', 'validated'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'deleted' => [ + 'label' => 'trans//e-billing::fields.tab_deleted', + 'icon' => 'gmdi-delete', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '!=', + 'value' => null, + ], + ], + ], + ], ], /* @@ -585,6 +721,23 @@ | */ + /* + |-------------------------------------------------------------------------- + | Profile per document type (ADR 0009) + |-------------------------------------------------------------------------- + | + | document_type (BT-3) => key prefix. A mapped type is validated against + | the {prefix}_fields / {prefix}_line_fields / contextual lists below and + | the invoice_ui.{prefix}_*_hidden denylists; a missing key falls back to + | the invoice_* key. Unmapped types use the invoice_* maps. + | + */ + + 'document_type_profiles' => [ + '381' => 'credit_note', // Credit note + '384' => 'corrected_invoice', // Corrected invoice + ], + 'invoice_fields' => [ // Document identification — MUST 'invoice_number' => 'must', // BT-1 @@ -698,6 +851,268 @@ 'material', 'delivery_date', ], + + /* + |-------------------------------------------------------------------------- + | Credit notes (ADR 0009) + |-------------------------------------------------------------------------- + | + | Documents with document_type 381 are validated against the credit_note_* + | maps below instead of the invoice_* maps. Package default: same as the + | invoice maps, plus the preceding invoice reference (BG-3). A host that + | omits a credit_note_* key falls back to the matching invoice_* key. + | + */ + + 'credit_note_fields' => [ + // Document identification — MUST + 'invoice_number' => 'must', // BT-1 + 'invoice_date' => 'must', // BT-2 + 'document_type' => 'must', // BT-3 + 'due_date' => 'should', // BT-9 + 'currency' => 'must', // BT-5 + + // Preceding invoice reference + 'preceding_invoice_number' => 'could', // BG-3 / BT-25 + 'preceding_invoice_date' => 'could', // BG-3 / BT-26 + + // Buyer — MUST (core identification) + 'customer_number' => 'must', // BT-46 + 'customer_name' => 'must', // BT-44 + 'customer_address' => 'must', // BG-8 + 'country' => 'could', // BT-55 + 'customer_vat_id' => 'should', // BT-48 + // Inbox To (mail-sourced only; not EN 16931). Empty blocks delivery via inbox_to. + 'buyer_email' => 'must', + + // Buyer reference + 'customer_reference' => 'could', // BT-10 + 'order_number' => 'should', // BT-13 + 'order_date' => 'could', // [GAP] no EN 16931 BT; not BT-13 + + // Delivery + 'delivery_address' => 'must', // BG-15 + 'delivery_date' => 'should', // BT-72 (header ActualDeliverySupplyChainEvent) + + // Seller — MUST (own company data, from system settings later) + 'supplier_name' => 'must', // BT-27 + 'supplier_number' => 'could', // BT-29 + 'supplier_vat_id' => 'must', // BT-31 + 'supplier_tax_number' => 'should', // BT-32 + 'supplier_address' => 'must', // BG-5 + 'supplier_bank_accounts' => 'should', // BG-16 / BG-17 (BT-84 IBAN) + 'supplier_email' => 'should', // BT-34 / BT-43 + 'supplier_phone' => 'should', // BT-42 + 'payment_means' => 'must', // BT-81 + 'vat_category' => 'must', // BT-118 + + // Agent & terms + 'agent' => 'could', // BT-41 + 'payment_terms' => 'should', // BT-20 + 'delivery_terms' => 'could', // BT-22 (invoice note) + 'shipping_method' => 'could', // BT-22 (invoice note) + 'notes' => 'could', // BT-22 (parser free-text notes) + + // Amounts — MUST + 'net_total' => 'must', // BT-109 + 'vat_rate' => 'must', // BT-119 + 'vat_amount' => 'must', // BT-110 + 'gross_total' => 'must', // BT-112 + + // Optional amounts + 'discount_percent' => 'could', // BG-20 / BT-94 + 'discount_amount' => 'could', // BG-20 / BT-92 + 'shipping_cost' => 'could', // BG-21 / BT-99 + 'minimum_quantity_surcharge' => 'could', // BG-21 / BT-99 + 'freight_flat_rate' => 'could', // BG-21 / BT-99 + 'packaging_cost' => 'could', // BG-21 / BT-99 + ], + + 'credit_note_line_fields' => [ + 'position' => 'must', // BT-126 + 'description' => 'must', // BT-153 + 'quantity' => 'must', // BT-129 + 'unit' => 'must', // BT-130 + 'unit_price' => 'must', // BT-146 + 'line_total' => 'must', // BT-131 + 'vat_category' => 'must', // BT-151 (inherits header stamp) + + 'article_number' => 'should', // BT-155 + 'material' => 'should', // BG-32 / BT-160–161 (host-specific) + 'customs_tariff_number' => 'could', // BT-158 + + 'description_detail' => 'could', // BT-154 + 'material_test_certificate' => 'could', // BG-32 / BT-160–161 + 'material_test_certificate_price' => 'could', // BG-28 line charge + 'weight_kg_total' => 'could', + 'weight_kg_net' => 'could', + 'surcharge_amount' => 'could', + 'surcharge_description' => 'could', + 'delivery_date' => 'should', // BG-26 / BT-134 (BillingSpecifiedPeriod in XML) + 'delivery_note_number' => 'could', // BT-16 + 'order_number' => 'could', // BT-132 (item-level override) + 'order_date' => 'could', // [GAP] no EN 16931 BT; not BT-13 + 'delivery_address' => 'could', + ], + + 'credit_note_contextual_should' => [ + 'customer_vat_id', + 'payment_terms', + 'supplier_tax_number', + 'supplier_bank_accounts', + 'supplier_email', + 'supplier_phone', + 'delivery_date', + ], + + 'credit_note_line_contextual_should' => [ + 'article_number', + 'material', + 'delivery_date', + ], + + /* + |-------------------------------------------------------------------------- + | Corrected invoices (ADR 0009, ADR 0011) + |-------------------------------------------------------------------------- + | + | Documents with document_type 384 correct or cancel an earlier invoice and + | should reference it (BG-3). Payment terms stay optional: a reducing + | correction has a negative amount due, and an increasing one without BT-9 + | or BT-20 is rejected by BR-CO-25 at artifact validation anyway. + | + */ + + 'corrected_invoice_fields' => [ + // Document identification — MUST + 'invoice_number' => 'must', // BT-1 + 'invoice_date' => 'must', // BT-2 + 'document_type' => 'must', // BT-3 + 'due_date' => 'could', // BT-9 + 'currency' => 'must', // BT-5 + + // Preceding invoice reference (the corrected invoice) + 'preceding_invoice_number' => 'should', // BG-3 / BT-25 + 'preceding_invoice_date' => 'should', // BG-3 / BT-26 + + // Buyer — MUST (core identification) + 'customer_number' => 'must', // BT-46 + 'customer_name' => 'must', // BT-44 + 'customer_address' => 'must', // BG-8 + 'country' => 'could', // BT-55 + 'customer_vat_id' => 'should', // BT-48 + // Inbox To (mail-sourced only; not EN 16931). Empty blocks delivery via inbox_to. + 'buyer_email' => 'must', + + // Buyer reference + 'customer_reference' => 'could', // BT-10 + 'order_number' => 'should', // BT-13 + 'order_date' => 'could', // [GAP] no EN 16931 BT; not BT-13 + + // Delivery + 'delivery_address' => 'must', // BG-15 + 'delivery_date' => 'should', // BT-72 (header ActualDeliverySupplyChainEvent) + + // Seller — MUST (own company data, from system settings later) + 'supplier_name' => 'must', // BT-27 + 'supplier_number' => 'could', // BT-29 + 'supplier_vat_id' => 'must', // BT-31 + 'supplier_tax_number' => 'should', // BT-32 + 'supplier_address' => 'must', // BG-5 + 'supplier_bank_accounts' => 'should', // BG-16 / BG-17 (BT-84 IBAN) + 'supplier_email' => 'should', // BT-34 / BT-43 + 'supplier_phone' => 'should', // BT-42 + 'payment_means' => 'must', // BT-81 + 'vat_category' => 'must', // BT-118 + + // Agent & terms + 'agent' => 'could', // BT-41 + 'payment_terms' => 'could', // BT-20 + 'delivery_terms' => 'could', // BT-22 (invoice note) + 'shipping_method' => 'could', // BT-22 (invoice note) + 'notes' => 'could', // BT-22 (parser free-text notes) + + // Amounts — MUST + 'net_total' => 'must', // BT-109 + 'vat_rate' => 'must', // BT-119 + 'vat_amount' => 'must', // BT-110 + 'gross_total' => 'must', // BT-112 + + // Optional amounts + 'discount_percent' => 'could', // BG-20 / BT-94 + 'discount_amount' => 'could', // BG-20 / BT-92 + 'shipping_cost' => 'could', // BG-21 / BT-99 + 'minimum_quantity_surcharge' => 'could', // BG-21 / BT-99 + 'freight_flat_rate' => 'could', // BG-21 / BT-99 + 'packaging_cost' => 'could', // BG-21 / BT-99 + ], + + 'corrected_invoice_line_fields' => [ + 'position' => 'must', // BT-126 + 'description' => 'must', // BT-153 + 'quantity' => 'must', // BT-129 + 'unit' => 'must', // BT-130 + 'unit_price' => 'must', // BT-146 + 'line_total' => 'must', // BT-131 + 'vat_category' => 'must', // BT-151 (inherits header stamp) + + 'article_number' => 'should', // BT-155 + 'material' => 'should', // BG-32 / BT-160–161 (host-specific) + 'customs_tariff_number' => 'could', // BT-158 + + 'description_detail' => 'could', // BT-154 + 'material_test_certificate' => 'could', // BG-32 / BT-160–161 + 'material_test_certificate_price' => 'could', // BG-28 line charge + 'weight_kg_total' => 'could', + 'weight_kg_net' => 'could', + 'surcharge_amount' => 'could', + 'surcharge_description' => 'could', + 'delivery_date' => 'should', // BG-26 / BT-134 (BillingSpecifiedPeriod in XML) + 'delivery_note_number' => 'could', // BT-16 + 'order_number' => 'could', // BT-132 (item-level override) + 'order_date' => 'could', // [GAP] no EN 16931 BT; not BT-13 + 'delivery_address' => 'could', + ], + + 'corrected_invoice_contextual_should' => [ + 'customer_vat_id', + 'supplier_tax_number', + 'supplier_bank_accounts', + 'supplier_email', + 'supplier_phone', + 'delivery_date', + 'preceding_invoice_number', + 'preceding_invoice_date', + ], + + 'corrected_invoice_line_contextual_should' => [ + 'article_number', + 'material', + 'delivery_date', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Document classification (ADR 0011) + |-------------------------------------------------------------------------- + | + | Document types a reviewer or uploader chooses between before approval, + | with the sign their amounts carry: a credit note (381) states the credit + | with positive amounts; a corrected invoice (384) issued as a credit (the + | delta) carries negative amounts. A host that issues corrected invoices as + | full, positive restatements sets '384' => 'positive'. Switching between + | different signs negates all document and line amounts. The choice is + | audited as a `document_classified` activity, never a value correction. + | These types also count as one type for duplicate detection. + | + */ + + 'document_classification' => [ + 'types' => [ + '381' => 'positive', + '384' => 'negative', + ], ], /* @@ -727,8 +1142,17 @@ */ 'invoice_ui' => [ - 'invoice_fields_hidden' => [], + 'invoice_fields_hidden' => [ + // Preceding invoice reference (BG-3) is credit-note only (ADR 0009). + 'preceding_invoice_number', + 'preceding_invoice_date', + ], 'invoice_line_fields_hidden' => [], + // Profiles from field_validation.document_type_profiles; fall back to invoice_* when omitted (ADR 0009). + 'credit_note_fields_hidden' => [], + 'credit_note_line_fields_hidden' => [], + 'corrected_invoice_fields_hidden' => [], + 'corrected_invoice_line_fields_hidden' => [], 'field_groups' => [ 'document' => ['default_open' => true], 'supplier' => ['default_open' => true], diff --git a/packages/e-billing/docs/adr/0009-document-type-field-and-ui-profiles.md b/packages/e-billing/docs/adr/0009-document-type-field-and-ui-profiles.md new file mode 100644 index 000000000..4c9c9549f --- /dev/null +++ b/packages/e-billing/docs/adr/0009-document-type-field-and-ui-profiles.md @@ -0,0 +1,57 @@ +--- +status: accepted +date: 2026-09-25 +--- + +# MoSCoW and ViewInvoice denylist profiles are selected by document type via parallel keys + +## Context + +`InvoiceFieldValidator` and ViewInvoice chrome today read a single pair of maps: `field_validation.invoice_*` (MoSCoW) and `invoice_ui.invoice_*_hidden` (denylist, ADR 0007). Credit notes reuse the same `Invoice` entity and pipeline with `document_type = 381` (host ADR pattern / EN 16931 UNTDID 1001), so they currently inherit invoice severities and denylist even when product rules will diverge. + +German vs foreign commercial invoices will need different MoSCoW maps later. That axis is not designed yet (selection is not simply another type code). The immediate need is a second profile for credit notes without painting the future German/foreign keying into the config tree. + +## Decision + +1. **Selector = `document_type` (BT-3).** `381` loads the credit-note maps; `380` (and any other allowed type until further ADRs) loads the invoice maps. Do not key off Filament resource names (pipeline has no resource) or stamp a separate profile name on the document until one type code must map to multiple profiles. + +2. **Parallel sibling keys, not nested profiles and not a delta overlay.** + - MoSCoW: keep `invoice_fields` / `invoice_line_fields` / contextual lists; add `credit_note_fields` / `credit_note_line_fields` / matching contextual keys under `field_validation`. + - ViewInvoice denylist: keep `invoice_fields_hidden` / `invoice_line_fields_hidden`; add `credit_note_fields_hidden` / `credit_note_line_fields_hidden` under `invoice_ui`. + - **`invoice_ui.field_groups` stays shared** across types (collapse chrome is not type-specific in v1). + +3. **Ownership.** `moox/e-billing` owns the type→key switch (`InvoiceFieldValidator` and the ViewInvoice presentation helper). Hosts own the priority and denylist *values*. + +4. **v1 seed and missing-key behaviour.** Package defaults ship `credit_note_*` as a **clone** of the corresponding `invoice_*` maps (identical severities / denylist). If a host omits `credit_note_*`, fall back to `invoice_*` so existing installs do not break. Business tuning of credit-note severities is a later, config-only change. German/foreign invoice profiles remain deferred. + +## Considered options + +- **Nested `profiles.{380|381}` tree.** Rejected for v1: forces a rename of the live invoice maps and invents a matrix before German/foreign selection criteria exist. +- **Delta overlay (invoice map + sparse credit-note overrides).** Rejected: hides intentional absences and is harder to review in host diffs. +- **Filament resource key as selector.** Rejected: mail and upload pipelines validate without a resource. +- **Host-only validator wrapper.** Rejected: selection is generic package behaviour; hosts only supply values. +- **Fail closed when `credit_note_*` missing.** Rejected: too sharp for introducing the switch; fallback preserves today’s behaviour. +- **Duplicate `credit_note_field_groups`.** Rejected for v1: no known divergence; shared `field_groups` is enough. + +## Consequences + +- ADR 0007’s boundary still holds: denylist stays under `invoice_ui`, never inside MoSCoW entries. This ADR only adds type-scoped sibling denylist keys. +- Adding further type codes with the same parallel-key pattern is possible; a second profile for the *same* type code (e.g. German vs foreign `380`) needs a new decision — do not stretch `document_type` alone for that. +- EN 16931 / XRechnung do not require BG-3 / BT-25 (preceding invoice) for type 381; v1 does not add a preceding-invoice MoSCoW key solely because credit notes exist. + +## Addendum (2026-09-25): preceding invoice reference + +The last consequence above is superseded. Integrators whose credit notes print the credited invoice now have a real reason for a key, and `moox/invoice` models BG-3 (BT-25 number, BT-26 date). + +- `credit_note_fields` gets `preceding_invoice_number` and `preceding_invoice_date`, both **`could`** in the package default. Hosts that always print the reference raise them in their own config. The invoice profile does not list them. +- `invoice_ui.invoice_fields_hidden` hides both fields, and the credit-note profile shows them. +- ViewInvoice tries to find the referenced invoice among stored documents. Matching is on the number with separators ignored, so `30641.25` equals `3064125`. A match is shown as a link. No match, or a date that differs from BT-26, is a **warning** and never blocks: invoices older than the system will never be found. + +## Addendum 2 (2026-09-25): corrected invoice (384) and the type → profile map + +A corrected invoice (384) has different rules from a credit note: it must name the corrected invoice by number and date, so it gets its own profile rather than sharing `credit_note_*` (ADR 0011 for how a document becomes 384). + +- The hard-wired `381 → credit_note_*` switch becomes a config map, `field_validation.document_type_profiles` (`'381' => 'credit_note'`, `'384' => 'corrected_invoice'`). A mapped type reads `{prefix}_fields`, `{prefix}_line_fields`, the contextual lists and `invoice_ui.{prefix}_*_hidden`; a missing key still falls back to `invoice_*`, and unmapped types still read `invoice_*`. This is a flat map to sibling keys, not the nested `profiles` tree rejected above. +- Package default `corrected_invoice_fields`: the invoice priorities with `preceding_invoice_number` / `preceding_invoice_date` as `should` (XRechnung recommends BG-3 for 384) and payment terms / due date as `could`: a reducing correction has a negative amount due, and an increasing one without BT-9 or BT-20 is rejected by BR-CO-25 at artifact validation. +- The review-queue query splits by every mapped type whose priorities differ from the invoice priorities. +- The preceding-invoice lookup searches only documents of unmapped types, i.e. the invoices a credit note or correction can refer to. diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php index 94139fdf8..cbab8eaf9 100644 --- a/packages/e-billing/resources/lang/de/fields.php +++ b/packages/e-billing/resources/lang/de/fields.php @@ -74,6 +74,8 @@ 'seller_bank_name' => 'Bank Lieferant', 'buyer_address' => 'Empfängeradresse', 'buyer_tax_id' => 'USt-IdNr. Empfänger', + 'preceding_invoice_number' => 'Bezugsrechnung', + 'preceding_invoice_date' => 'Datum Bezugsrechnung', 'buyer_email' => 'Empfänger-E-Mail', 'tax_number' => 'Steuernummer', 'supplier' => 'Lieferant', @@ -299,6 +301,8 @@ // Field hints — informational (no validation status) 'hint_info_buyer_email' => 'Aus Posteingang (An) — nicht mit Stammdaten abgeglichen.', + 'hint_warning_preceding_invoice_not_found' => 'Bezugsrechnung nicht im System gefunden — bitte prüfen (blockiert nicht).', + 'hint_warning_preceding_invoice_date_mismatch' => 'Datum weicht von der gespeicherten Bezugsrechnung ab — bitte prüfen (blockiert nicht).', 'section_kosit_validations' => 'KoSIT-Validierungen', 'kosit_validations_empty' => 'Noch keine KoSIT-Validierungen.', diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php index abb2feb1c..8473298fb 100644 --- a/packages/e-billing/resources/lang/en/fields.php +++ b/packages/e-billing/resources/lang/en/fields.php @@ -74,6 +74,8 @@ 'seller_bank_name' => 'Supplier bank', 'buyer_address' => 'Recipient address', 'buyer_tax_id' => 'Recipient VAT ID', + 'preceding_invoice_number' => 'Preceding invoice', + 'preceding_invoice_date' => 'Preceding invoice date', 'buyer_email' => 'Recipient email', 'tax_number' => 'Tax number', 'supplier' => 'Supplier', @@ -299,6 +301,8 @@ // Field hints — informational (no validation status) 'hint_info_buyer_email' => 'From inbox To — not checked against master data.', + 'hint_warning_preceding_invoice_not_found' => 'Preceding invoice not found in the system — please check (does not block).', + 'hint_warning_preceding_invoice_date_mismatch' => 'Date differs from the stored preceding invoice — please check (does not block).', 'section_kosit_validations' => 'KoSIT validations', 'kosit_validations_empty' => 'No KoSIT validations yet.', diff --git a/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php index a7c846808..23b6cf1a6 100644 --- a/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php +++ b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php @@ -35,6 +35,8 @@ class="mt-0.5 break-words text-sm whitespace-pre-line text-gray-800 dark:text-gr @elseif(is_string($field->value) && str_contains($field->value, "\n"))
{{ $field->value }}
+ @elseif($field->url !== null && $field->value !== null && $field->value !== '') + {{ $field->value }} @elseif($field->value !== null && $field->value !== '') {{ $field->value }} @else diff --git a/packages/e-billing/src/Actions/ConfirmInvoiceAction.php b/packages/e-billing/src/Actions/ConfirmInvoiceAction.php index 0cb84830a..b279530c8 100644 --- a/packages/e-billing/src/Actions/ConfirmInvoiceAction.php +++ b/packages/e-billing/src/Actions/ConfirmInvoiceAction.php @@ -52,6 +52,7 @@ public function execute(Invoice $invoice): array $missingMustFields = EbillingDocument::missingMustFields( is_array($document->field_validations) ? $document->field_validations : null, + $invoice->document_type, ); if ($missingMustFields !== []) { return $this->failure($missingMustFields); diff --git a/packages/e-billing/src/Approval/AutoApproveEvaluator.php b/packages/e-billing/src/Approval/AutoApproveEvaluator.php index a0794e633..e7fcccad1 100644 --- a/packages/e-billing/src/Approval/AutoApproveEvaluator.php +++ b/packages/e-billing/src/Approval/AutoApproveEvaluator.php @@ -7,6 +7,7 @@ use Moox\EBilling\Enums\AutoApproveFailureReason; use Moox\EBilling\Enums\DocumentApprovalStatus; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Support\CreditNoteSign; final class AutoApproveEvaluator { @@ -32,10 +33,15 @@ public function evaluate(EbillingDocument $document): AutoApproveResult if (EbillingDocument::hasBlockingMustFieldFindings( is_array($document->field_validations) ? $document->field_validations : null, + $document->profileDocumentType(), )) { $failures[] = AutoApproveFailureReason::MustFieldBlocked; } + if (CreditNoteSign::hasNegativeTotal($document->invoice)) { + $failures[] = AutoApproveFailureReason::CreditNoteNegativeTotal; + } + if ($document->hasDuplicateApprovalFlag()) { $failures[] = AutoApproveFailureReason::DuplicateDetected; } diff --git a/packages/e-billing/src/Models/EbillingDocument.php b/packages/e-billing/src/Models/EbillingDocument.php index 95235a717..d51549a75 100644 --- a/packages/e-billing/src/Models/EbillingDocument.php +++ b/packages/e-billing/src/Models/EbillingDocument.php @@ -28,6 +28,7 @@ use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Formats\ArtifactKind; use Moox\EBilling\Support\EBillingArtifactNaming; +use Moox\EBilling\Support\FieldValidationProfile; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Support\InvoiceModels; use Moox\KositValidator\Models\KositValidation; @@ -553,9 +554,12 @@ public function scopeNeedsHumanReview(Builder $query): Builder }); } - public static function fieldValidationsNeedHumanReview(?array $fieldValidations, ?array $severityReleases): bool - { - [$invoiceFields, $lineFields] = self::configuredPriorityMaps(); + public static function fieldValidationsNeedHumanReview( + ?array $fieldValidations, + ?array $severityReleases, + ?string $documentType = null, + ): bool { + [$invoiceFields, $lineFields] = self::configuredPriorityMaps($documentType); $validations = is_array($fieldValidations) ? $fieldValidations : []; if (self::priorityMapBlocksReview($invoiceFields, $validations, $severityReleases)) { @@ -574,34 +578,14 @@ public static function fieldValidationsNeedHumanReview(?array $fieldValidations, /** * @return array{0: array, 1: array} */ - private static function configuredPriorityMaps(): array + private static function configuredPriorityMaps(?string $documentType): array { return [ - self::stringPriorityMap(config('e-billing.field_validation.invoice_fields', [])), - self::stringPriorityMap(config('e-billing.field_validation.invoice_line_fields', [])), + FieldValidationProfile::invoiceFields($documentType), + FieldValidationProfile::lineFields($documentType), ]; } - /** - * @return array - */ - private static function stringPriorityMap(mixed $config): array - { - if (! is_array($config)) { - return []; - } - - $map = []; - - foreach ($config as $field => $priority) { - if (is_string($field) && is_string($priority)) { - $map[$field] = $priority; - } - } - - return $map; - } - /** * @param array $fields * @param array $validations @@ -802,15 +786,7 @@ public function calculateValidationScore(): ?int return null; } - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - $lineFields = config('e-billing.field_validation.invoice_line_fields', []); - - if (! is_array($invoiceFields)) { - $invoiceFields = []; - } - if (! is_array($lineFields)) { - $lineFields = []; - } + [$invoiceFields, $lineFields] = self::configuredPriorityMaps($this->profileDocumentType()); $total = 0; $valid = 0; @@ -881,12 +857,7 @@ public function transitionTo(InvoiceProcessingStatus $newStatus): void public function isFullyValidated(): bool { - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - if (! is_array($invoiceFields)) { - return true; - } - - foreach ($invoiceFields as $field => $priority) { + foreach (FieldValidationProfile::invoiceFields($this->profileDocumentType()) as $field => $priority) { if ($priority !== 'must') { continue; } @@ -904,9 +875,20 @@ public function needsHumanReview(): bool return self::fieldValidationsNeedHumanReview( is_array($this->field_validations) ? $this->field_validations : null, is_array($this->severity_releases) ? $this->severity_releases : null, + $this->profileDocumentType(), ); } + /** + * BT-3 of the linked invoice; selects the MoSCoW profile (ADR 0009). Null reads the invoice profile. + */ + public function profileDocumentType(): ?string + { + $type = $this->invoice?->document_type; + + return is_string($type) && $type !== '' ? $type : null; + } + public function resolveApprovalStatusEnum(): ?DocumentApprovalStatus { $status = $this->approval_status; @@ -980,23 +962,23 @@ public function scopeApprovalPending(Builder $query): Builder * * @return list */ - public static function missingMustFields(?array $fieldValidations): array + public static function missingMustFields(?array $fieldValidations, ?string $documentType = null): array { - return self::mustFieldsWithStatuses($fieldValidations, ['missing']); + return self::mustFieldsWithStatuses($fieldValidations, ['missing'], $documentType); } - public static function hasBlockingMustFieldFindings(?array $fieldValidations): bool + public static function hasBlockingMustFieldFindings(?array $fieldValidations, ?string $documentType = null): bool { - return self::mustFieldsWithStatuses($fieldValidations, ['missing', 'needs_review']) !== []; + return self::mustFieldsWithStatuses($fieldValidations, ['missing', 'needs_review'], $documentType) !== []; } /** * @param list $statuses * @return list */ - private static function mustFieldsWithStatuses(?array $fieldValidations, array $statuses): array + private static function mustFieldsWithStatuses(?array $fieldValidations, array $statuses, ?string $documentType): array { - [$invoiceFields, $lineFields] = self::configuredPriorityMaps(); + [$invoiceFields, $lineFields] = self::configuredPriorityMaps($documentType); $validations = is_array($fieldValidations) ? $fieldValidations : []; $matched = self::collectMustFieldsMatching($invoiceFields, $validations, $statuses); @@ -1045,16 +1027,7 @@ public function hasSeverityRelease(string $field, ?string $lineId = null): bool public function resolveConfiguredFieldPriority(string $field, bool $isLineField = false): string { - $configKey = $isLineField ? 'invoice_line_fields' : 'invoice_fields'; - $fields = config("e-billing.field_validation.{$configKey}", []); - - if (! is_array($fields)) { - return 'could'; - } - - $priority = $fields[$field] ?? null; - - return is_string($priority) ? $priority : 'could'; + return FieldValidationProfile::priority($field, $this->profileDocumentType(), $isLineField); } public function resolveFieldValidationStatus(string $field, ?string $lineId = null): ?string @@ -1151,7 +1124,37 @@ private function readFieldStatus(?array $validations, string $field): ?string */ private static function applyScopeConfiguredFieldBlocksReview(Builder $query): void { - [$invoiceFields, $lineFields] = self::configuredPriorityMaps(); + $ownPriorityTypes = FieldValidationProfile::documentTypesWithOwnPriorities(); + + if ($ownPriorityTypes === []) { + self::applyScopeProfileFieldBlocksReview($query, null); + + return; + } + + $query->where(function (Builder $byType) use ($ownPriorityTypes): void { + foreach ($ownPriorityTypes as $type) { + $byType->orWhere(function (Builder $ofType) use ($type): void { + $ofType->whereHas('invoice', fn (Builder $invoice): Builder => $invoice->where('document_type', $type)) + ->where(fn (Builder $inner) => self::applyScopeProfileFieldBlocksReview($inner, $type)); + }); + } + + $byType->orWhere(function (Builder $others) use ($ownPriorityTypes): void { + $others->whereDoesntHave( + 'invoice', + fn (Builder $invoice): Builder => $invoice->whereIn('document_type', $ownPriorityTypes), + )->where(fn (Builder $inner) => self::applyScopeProfileFieldBlocksReview($inner, null)); + }); + }); + } + + /** + * @param Builder $query + */ + private static function applyScopeProfileFieldBlocksReview(Builder $query, ?string $documentType): void + { + [$invoiceFields, $lineFields] = self::configuredPriorityMaps($documentType); $fvColumn = $query->qualifyColumn('field_validations'); $srColumn = $query->qualifyColumn('severity_releases'); $driver = self::jsonSqlDriver($query); diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index ca3a707c1..8ac51918a 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -17,6 +17,7 @@ use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\InvoiceNumberDuplicateChecker; use Moox\EBilling\Support\LineAllowanceChargeResolver; +use Moox\EBilling\Support\PrecedingInvoiceReferences; use Moox\EBilling\Support\VatIdNormalizer; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Models\InvoiceLine; @@ -324,10 +325,59 @@ private function validateInvoiceField( 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', 'discount_amount', 'discount_percent' => $this->validateHeaderChargeField($invoice, $field, $priority), 'delivery_date' => $this->validateDeliveryDateField($invoice, $priority), + 'preceding_invoice_number' => $this->validatePrecedingInvoiceNumberField($invoice, $priority), default => $this->validateGenericInvoiceField($invoice, $field, $priority), }; } + /** + * BT-25: looks the referenced invoice up among stored documents (separators ignored). + * Not finding it, or finding a different BT-26 date, is a warning (`reason`), never a blocking status: + * invoices older than the system are legitimately unknown (ADR 0009 addendum). + * + * @return array{status: string, reason?: string, matched_id?: string} + */ + private function validatePrecedingInvoiceNumberField(Invoice $invoice, string $priority): array + { + $generic = $this->validateGenericInvoiceField($invoice, 'preceding_invoice_number', $priority); + + if (($generic['status'] ?? null) !== 'parsed') { + return $generic; + } + + $reference = PrecedingInvoiceReferences::first($invoice->preceding_invoices); + if ($reference === null) { + return $generic; + } + + $original = $this->findPrecedingInvoice($invoice, $reference['number']); + + if (! $original instanceof Invoice) { + return ['status' => 'parsed', 'reason' => 'preceding_invoice_not_found']; + } + + $originalDate = substr((string) $original->invoice_date, 0, 10); + + if ($reference['date'] !== null && $originalDate !== $reference['date']) { + return [ + 'status' => 'parsed', + 'reason' => 'preceding_invoice_date_mismatch', + 'matched_id' => (string) $original->getKey(), + ]; + } + + return ['status' => 'parsed', 'matched_id' => (string) $original->getKey()]; + } + + private function findPrecedingInvoice(Invoice $invoice, string $number): ?Invoice + { + $comparable = PrecedingInvoiceReferences::comparableNumber($number); + $digits = preg_replace('/\D/', '', $number); + + if ($comparable === '' || $digits === null || $digits === '') { + return null; + } + /** * @return array{status: string, source?: string, matched_id?: string, reason?: string} */ @@ -720,6 +770,8 @@ private function getInvoiceFieldValue(Invoice $invoice, string $field): mixed 'payment_means' => $invoice->payment_means?->payment_means_code, 'vat_category' => $invoice->vat_category, 'delivery_address' => $invoice->delivery, + 'preceding_invoice_number' => PrecedingInvoiceReferences::first($invoice->preceding_invoices)['number'] ?? null, + 'preceding_invoice_date' => PrecedingInvoiceReferences::first($invoice->preceding_invoices)['date'] ?? null, default => $invoice->getAttribute($field), }; } diff --git a/packages/e-billing/src/Support/FieldValidationProfile.php b/packages/e-billing/src/Support/FieldValidationProfile.php new file mode 100644 index 000000000..ab68dcf1e --- /dev/null +++ b/packages/e-billing/src/Support/FieldValidationProfile.php @@ -0,0 +1,171 @@ + + */ + public static function profiledDocumentTypes(): array + { + // Numeric type codes become int array keys; hand them out as the strings BT-3 carries. + return array_map(strval(...), array_keys(self::profileMap())); + } + + /** + * @return array + */ + public static function invoiceFields(?string $documentType): array + { + return self::priorityMap(self::resolve('field_validation', 'fields', $documentType)); + } + + /** + * @return array + */ + public static function lineFields(?string $documentType): array + { + return self::priorityMap(self::resolve('field_validation', 'line_fields', $documentType)); + } + + /** + * @return list + */ + public static function contextualShould(?string $documentType, bool $forLines = false): array + { + return self::stringList(self::resolve( + 'field_validation', + $forLines ? 'line_contextual_should' : 'contextual_should', + $documentType, + )); + } + + /** + * @return list + */ + public static function hiddenFields(?string $documentType): array + { + return self::stringList(self::resolve('invoice_ui', 'fields_hidden', $documentType)); + } + + /** + * @return list + */ + public static function hiddenLineFields(?string $documentType): array + { + return self::stringList(self::resolve('invoice_ui', 'line_fields_hidden', $documentType)); + } + + public static function priority(string $field, ?string $documentType, bool $isLineField = false): string + { + $map = $isLineField ? self::lineFields($documentType) : self::invoiceFields($documentType); + + return $map[$field] ?? 'could'; + } + + /** + * Mapped document types whose MoSCoW maps differ from the invoice maps; type-blind document + * queries would judge them by the wrong priorities. + * + * @return list + */ + public static function documentTypesWithOwnPriorities(): array + { + return array_values(array_filter( + self::profiledDocumentTypes(), + static fn (string $type): bool => self::invoiceFields($type) !== self::invoiceFields(null) + || self::lineFields($type) !== self::lineFields(null), + )); + } + + private static function resolve(string $section, string $suffix, ?string $documentType): mixed + { + $prefix = self::profileMap()[trim((string) $documentType)] ?? null; + + if ($prefix !== null) { + $profile = config("e-billing.{$section}.{$prefix}_{$suffix}"); + + if (is_array($profile)) { + return $profile; + } + } + + return config("e-billing.{$section}.".self::INVOICE_PREFIX."_{$suffix}", []); + } + + /** + * @return array document type code => config key prefix + */ + private static function profileMap(): array + { + $map = config('e-billing.field_validation.document_type_profiles', []); + + if (! is_array($map)) { + return []; + } + + $profiles = []; + + foreach ($map as $type => $prefix) { + if (is_string($prefix) && $prefix !== '' && $prefix !== self::INVOICE_PREFIX) { + $profiles[(string) $type] = $prefix; + } + } + + return $profiles; + } + + /** + * @return array + */ + private static function priorityMap(mixed $config): array + { + if (! is_array($config)) { + return []; + } + + $map = []; + + foreach ($config as $field => $priority) { + if (is_string($field) && is_string($priority) && $priority !== '') { + $map[$field] = $priority; + } + } + + return $map; + } + + /** + * @return list + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + $value, + static fn (mixed $item): bool => is_string($item) && $item !== '', + )); + } +} diff --git a/packages/e-billing/src/Support/InvoiceFieldLabels.php b/packages/e-billing/src/Support/InvoiceFieldLabels.php index 81d094b4a..6630412b7 100644 --- a/packages/e-billing/src/Support/InvoiceFieldLabels.php +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -82,6 +82,8 @@ public static function get(string $fieldName): string 'seller_bank_name' => __('e-billing::fields.seller_bank_name'), 'buyer_address' => __('e-billing::fields.buyer_address'), 'buyer_tax_id' => __('e-billing::fields.buyer_tax_id'), + 'preceding_invoice_number' => __('e-billing::fields.preceding_invoice_number'), + 'preceding_invoice_date' => __('e-billing::fields.preceding_invoice_date'), default => Str::headline(str_replace('_', ' ', $fieldName)), }; } @@ -158,6 +160,8 @@ public static function btNumber(string $field, ?string $context = null): ?string 'currency' => 'BT-5', 'due_date' => 'BT-9', 'customer_reference' => 'BT-10', + 'preceding_invoice_number' => 'BG-3 / BT-25', + 'preceding_invoice_date' => 'BG-3 / BT-26', 'customer_number' => 'BT-46', 'order_number' => 'BT-13', 'payment_terms' => 'BT-20', @@ -257,6 +261,15 @@ public static function hint(string $field, string $status, ?array $validation = return __('e-billing::fields.hint_info_buyer_email'); } + // Preceding invoice lookup warns without blocking (ADR 0009 addendum). + if ($field === 'preceding_invoice_number') { + return match ($validation['reason'] ?? null) { + 'preceding_invoice_not_found' => __('e-billing::fields.hint_warning_preceding_invoice_not_found'), + 'preceding_invoice_date_mismatch' => __('e-billing::fields.hint_warning_preceding_invoice_date_mismatch'), + default => null, + }; + } + return null; } diff --git a/packages/e-billing/src/Support/InvoiceUiPresentation.php b/packages/e-billing/src/Support/InvoiceUiPresentation.php index 85427bb89..12bec7691 100644 --- a/packages/e-billing/src/Support/InvoiceUiPresentation.php +++ b/packages/e-billing/src/Support/InvoiceUiPresentation.php @@ -36,17 +36,17 @@ public static function withoutHidden(array $fields, array $hidden): array /** * @return list */ - public static function hiddenInvoiceFields(): array + public static function hiddenInvoiceFields(?string $documentType = null): array { - return self::stringList(config('e-billing.invoice_ui.invoice_fields_hidden', [])); + return FieldValidationProfile::hiddenFields($documentType); } /** * @return list */ - public static function hiddenLineFields(): array + public static function hiddenLineFields(?string $documentType = null): array { - return self::stringList(config('e-billing.invoice_ui.invoice_line_fields_hidden', [])); + return FieldValidationProfile::hiddenLineFields($documentType); } public static function groupDefaultOpen(string $group): bool @@ -59,12 +59,16 @@ public static function groupDefaultOpen(string $group): bool * @param 'invoice'|'line' $map * @return array{open: bool, issue_count: int, issue_label: ?string} */ - public static function collapsibleState(array $visible, bool $defaultOpen, string $map = 'invoice'): array - { + public static function collapsibleState( + array $visible, + bool $defaultOpen, + string $map = 'invoice', + ?string $documentType = null, + ): array { $issueCount = 0; foreach ($visible as $field) { - if (self::priority($field->field, $map) === 'must' + if (FieldValidationProfile::priority($field->field, $documentType, $map === 'line') === 'must' && in_array($field->status(), self::BLOCKING_STATUSES, true)) { $issueCount++; } @@ -78,38 +82,4 @@ public static function collapsibleState(array $visible, bool $defaultOpen, strin : null, ]; } - - /** - * @param 'invoice'|'line' $map - */ - private static function priority(string $field, string $map): string - { - $configKey = $map === 'line' - ? 'e-billing.field_validation.invoice_line_fields' - : 'e-billing.field_validation.invoice_fields'; - - $fields = config($configKey, []); - $priority = is_array($fields) ? ($fields[$field] ?? null) : null; - - return is_string($priority) && $priority !== '' ? $priority : 'could'; - } - - /** - * @return list - */ - private static function stringList(mixed $value): array - { - if (! is_array($value)) { - return []; - } - - $out = []; - foreach ($value as $item) { - if (is_string($item) && $item !== '') { - $out[] = $item; - } - } - - return $out; - } } diff --git a/packages/e-billing/src/Support/SeverityReleaseSnapshotCollector.php b/packages/e-billing/src/Support/SeverityReleaseSnapshotCollector.php index 8ba893eb2..0ee783a1d 100644 --- a/packages/e-billing/src/Support/SeverityReleaseSnapshotCollector.php +++ b/packages/e-billing/src/Support/SeverityReleaseSnapshotCollector.php @@ -16,38 +16,36 @@ public static function collect(EbillingDocument $document): array $releases = is_array($document->severity_releases) ? $document->severity_releases : []; $forwarded = []; - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - if (is_array($invoiceFields)) { - foreach ($invoiceFields as $field => $priority) { - if (! is_string($field) || $priority !== 'should') { - continue; - } - - $entry = EbillingDocument::readSeverityReleaseEntry($releases, $field); - if (! EbillingDocument::severityReleaseEntryIsValid($entry)) { - continue; - } + $documentType = $document->profileDocumentType(); + foreach (FieldValidationProfile::invoiceFields($documentType) as $field => $priority) { + if ($priority !== 'should') { + continue; + } - $forwarded[] = new ForwardedSeverityRelease( - field: $field, - lineId: null, - reason: (string) ($entry['reason'] ?? ''), - releasedById: $entry['released_by_id'] ?? null, - releasedAt: (string) ($entry['released_at'] ?? ''), - ); + $entry = EbillingDocument::readSeverityReleaseEntry($releases, $field); + if (! EbillingDocument::severityReleaseEntryIsValid($entry)) { + continue; } + + $forwarded[] = new ForwardedSeverityRelease( + field: $field, + lineId: null, + reason: (string) ($entry['reason'] ?? ''), + releasedById: $entry['released_by_id'] ?? null, + releasedAt: (string) ($entry['released_at'] ?? ''), + ); } - $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + $lineFields = FieldValidationProfile::lineFields($documentType); $lines = is_array($releases['lines'] ?? null) ? $releases['lines'] : []; foreach ($lines as $lineId => $lineReleases) { - if (! is_string($lineId) || ! is_array($lineReleases) || ! is_array($lineFields)) { + if (! is_string($lineId) || ! is_array($lineReleases)) { continue; } foreach ($lineFields as $field => $priority) { - if (! is_string($field) || $priority !== 'should') { + if ($priority !== 'should') { continue; } diff --git a/packages/e-billing/src/ViewModels/FieldViewData.php b/packages/e-billing/src/ViewModels/FieldViewData.php index 8a576ee59..b38f8f62d 100644 --- a/packages/e-billing/src/ViewModels/FieldViewData.php +++ b/packages/e-billing/src/ViewModels/FieldViewData.php @@ -13,6 +13,7 @@ public function __construct( public readonly mixed $value, public readonly ?array $validation, public readonly ?string $hint, + public readonly ?string $url = null, ) { } diff --git a/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php index ead09c8ea..0c8e08bd0 100644 --- a/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php @@ -5,6 +5,7 @@ namespace Moox\EBilling\ViewModels; use Carbon\Carbon; +use Moox\EBilling\Support\FieldValidationProfile; use Moox\EBilling\Support\InvoiceDisplayNumberFormatter; use Moox\EBilling\Support\InvoiceFieldLabels; use Moox\EBilling\Support\InvoiceUiPresentation; @@ -20,6 +21,7 @@ final class InvoiceLineViewModel public function __construct( private InvoiceLine $line, // Extend InvoiceLine in your host app if needed private array $lineValidations = [], + private ?string $documentType = null, ) { $this->line->loadMissing('allowanceCharges'); } @@ -63,7 +65,7 @@ public function relevantFields(): array return InvoiceUiPresentation::withoutHidden( $fields, - InvoiceUiPresentation::hiddenLineFields(), + InvoiceUiPresentation::hiddenLineFields($this->documentType), ); } @@ -77,6 +79,7 @@ public function collapsibleState(?array $visible = null): array $visible ?? $this->relevantFields(), defaultOpen: false, map: 'line', + documentType: $this->documentType, ); } @@ -127,10 +130,7 @@ private function resolveDisplayValidation(string $field, mixed $rawValue, ?array */ private function emptyLineFieldValidation(string $field): array { - $lineFields = config('e-billing.field_validation.invoice_line_fields', []); - $priority = is_array($lineFields) && is_string($lineFields[$field] ?? null) - ? $lineFields[$field] - : 'could'; + $priority = FieldValidationProfile::priority($field, $this->documentType, isLineField: true); if ($priority === 'could') { return ['status' => 'not_applicable']; @@ -140,10 +140,8 @@ private function emptyLineFieldValidation(string $field): array return ['status' => 'missing']; } - $contextual = config('e-billing.field_validation.invoice_line_contextual_should', []); - return [ - 'status' => is_array($contextual) && in_array($field, $contextual, true) + 'status' => in_array($field, FieldValidationProfile::contextualShould($this->documentType, forLines: true), true) ? 'missing' : 'not_applicable', ]; diff --git a/packages/e-billing/src/ViewModels/InvoiceViewModel.php b/packages/e-billing/src/ViewModels/InvoiceViewModel.php index 5713a6341..89492e29c 100644 --- a/packages/e-billing/src/ViewModels/InvoiceViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceViewModel.php @@ -8,10 +8,13 @@ use Moox\EBilling\Enums\EBillingAttachmentProcessingStatus; use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Resources\InvoiceResource; +use Moox\EBilling\Support\FieldValidationProfile; use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\InvoiceFieldLabels; use Moox\EBilling\Support\InvoiceUiPresentation; use Moox\EBilling\Support\PartyAddressFormatter; +use Moox\EBilling\Support\PrecedingInvoiceReferences; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Support\En16931\BankAccount; @@ -38,6 +41,7 @@ public function groupedFields(): array 'subtitle' => '', 'fields' => $this->buildFields([ 'invoice_number', 'invoice_date', 'document_type', + 'preceding_invoice_number', 'preceding_invoice_date', 'due_date', 'currency', 'order_number', 'order_date', 'customer_reference', 'payment_terms', 'material_test_certificate', ]), @@ -86,6 +90,7 @@ public function groupedFields(): array $group['fields'], InvoiceUiPresentation::groupDefaultOpen($key), 'invoice', + $this->documentType(), ); $out[$key] = [ @@ -115,7 +120,7 @@ public function lines(): array ? $lineValidationsRoot[$lineKey] : []; - return new InvoiceLineViewModel($line, $validations); + return new InvoiceLineViewModel($line, $validations, $this->documentType()); }) ->all(); } @@ -152,6 +157,7 @@ public function notesGroup(): ?array $fields, InvoiceUiPresentation::groupDefaultOpen('notes'), 'invoice', + $this->documentType(), ); return [ @@ -346,7 +352,7 @@ public function formatValue(string $field): mixed return number_format((float) $value, 2, ',', '.').' %'; } - if (in_array($field, ['invoice_date', 'due_date', 'order_date', 'delivery_date'], true) + if (in_array($field, ['invoice_date', 'due_date', 'order_date', 'delivery_date', 'preceding_invoice_date'], true) && is_string($value) && $value !== '') { try { return Carbon::parse($value)->format('d.m.Y'); @@ -382,10 +388,35 @@ private function resolveFieldValue(string $field): mixed 'vat_category' => $this->invoice->vat_category, // Keep empty when no distinct consignee (ADR 0020 / 0014). 'delivery_address' => PartyAddressFormatter::format($this->invoice->delivery), + 'preceding_invoice_number' => PrecedingInvoiceReferences::first($this->invoice->preceding_invoices)['number'] ?? null, + 'preceding_invoice_date' => PrecedingInvoiceReferences::first($this->invoice->preceding_invoices)['date'] ?? null, default => $this->invoice->getAttribute($field), }; } + private function documentType(): ?string + { + $type = $this->invoice->document_type; + + return $type !== '' ? $type : null; + } + + /** + * Link to the stored invoice a preceding-invoice reference resolved to (ADR 0009 addendum). + * + * @param array|null $validation + */ + private function fieldUrl(string $field, ?array $validation): ?string + { + $matchedId = $validation['matched_id'] ?? null; + + if ($field !== 'preceding_invoice_number' || ! is_string($matchedId) || $matchedId === '') { + return null; + } + + return InvoiceResource::getUrl('view', ['record' => $matchedId]); + } + /** * @param list>|array> $accounts * @return list @@ -437,12 +468,13 @@ private function buildFields(array $fieldNames): array value: $this->formatValue($name), validation: $validation, hint: InvoiceFieldLabels::hint($name, $status, $validation), + url: $this->fieldUrl($name, $validation), ); }, $fieldNames); return InvoiceUiPresentation::withoutHidden( $fields, - InvoiceUiPresentation::hiddenInvoiceFields(), + InvoiceUiPresentation::hiddenInvoiceFields($this->documentType()), ); } @@ -480,10 +512,7 @@ private function resolveDisplayValidation(string $field, mixed $rawValue, ?array return ['status' => 'missing']; } - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - $priority = is_array($invoiceFields) && is_string($invoiceFields[$field] ?? null) - ? $invoiceFields[$field] - : 'could'; + $priority = FieldValidationProfile::priority($field, $this->documentType()); if ($priority === 'could') { return ['status' => 'not_applicable']; @@ -493,10 +522,8 @@ private function resolveDisplayValidation(string $field, mixed $rawValue, ?array return ['status' => 'missing']; } - $contextual = config('e-billing.field_validation.invoice_contextual_should', []); - return [ - 'status' => is_array($contextual) && in_array($field, $contextual, true) + 'status' => in_array($field, FieldValidationProfile::contextualShould($this->documentType()), true) ? 'missing' : 'not_applicable', ]; From 034de72b0593176eea5086f08ade8b950009f6fe Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 11:08:20 +0200 Subject: [PATCH 06/12] feat(e-billing): block approval of credit notes with a negative total --- ...ote-with-negative-total-blocks-approval.md | 14 +++++++++++ .../src/Approval/DocumentApprovalGuard.php | 6 +++++ .../src/Approval/DocumentDispatchGuard.php | 5 ++++ .../src/Enums/AutoApproveFailureReason.php | 1 + .../e-billing/src/Support/CreditNoteSign.php | 23 +++++++++++++++++++ 5 files changed, 49 insertions(+) create mode 100644 packages/e-billing/docs/adr/0010-credit-note-with-negative-total-blocks-approval.md create mode 100644 packages/e-billing/src/Support/CreditNoteSign.php diff --git a/packages/e-billing/docs/adr/0010-credit-note-with-negative-total-blocks-approval.md b/packages/e-billing/docs/adr/0010-credit-note-with-negative-total-blocks-approval.md new file mode 100644 index 000000000..bf2407f61 --- /dev/null +++ b/packages/e-billing/docs/adr/0010-credit-note-with-negative-total-blocks-approval.md @@ -0,0 +1,14 @@ +--- +status: accepted +date: 2026-09-25 +--- + +# A credit note with a negative total blocks approval + +A credit note (BT-3 = `381`) already says "money back to the buyer", so its quantities and amounts must be positive. A source system that prints credit notes with minus signs produces a 381 with a negative grand total (BT-112). The double negative reads as a debit in the buyer's accounting. KOSIT schema and Schematron validation pass it (observed: a 381 with `GrandTotalAmount -173.14` reported *valid*), so nothing downstream catches it. We therefore check it in `moox/e-billing` itself: a 381 whose BT-112 is negative is a **blocking** finding, and the document cannot be approved or dispatched. The check is generic and knows nothing about any source system. Making the signs positive is the parser's job, because the quirk belongs to the source. + +## Considered options + +- **Warning only.** Rejected: the XML is valid but books money in the wrong direction, and a warning gets clicked away. +- **Flipping signs automatically in the package.** Rejected: that silently rewrites parsed amounts, and it would hide a parser defect instead of surfacing it. +- **Emitting such documents as 380 with negative amounts.** Rejected: it changes the document kind the integrator declared. diff --git a/packages/e-billing/src/Approval/DocumentApprovalGuard.php b/packages/e-billing/src/Approval/DocumentApprovalGuard.php index 9be81e757..ca318fad0 100644 --- a/packages/e-billing/src/Approval/DocumentApprovalGuard.php +++ b/packages/e-billing/src/Approval/DocumentApprovalGuard.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use Moox\EBilling\Enums\DocumentApprovalStatus; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Support\CreditNoteSign; final class DocumentApprovalGuard { @@ -24,8 +25,13 @@ public function canApprove(EbillingDocument $document): bool return false; } + if (CreditNoteSign::hasNegativeTotal($document->invoice)) { + return false; + } + return ! EbillingDocument::hasBlockingMustFieldFindings( is_array($document->field_validations) ? $document->field_validations : null, + $document->profileDocumentType(), ); } diff --git a/packages/e-billing/src/Approval/DocumentDispatchGuard.php b/packages/e-billing/src/Approval/DocumentDispatchGuard.php index b57532487..47f902f9b 100644 --- a/packages/e-billing/src/Approval/DocumentDispatchGuard.php +++ b/packages/e-billing/src/Approval/DocumentDispatchGuard.php @@ -7,6 +7,7 @@ use Moox\EBilling\Enums\DocumentApprovalStatus; use Moox\EBilling\Exceptions\DocumentNotDispatchableException; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Support\CreditNoteSign; final class DocumentDispatchGuard { @@ -41,6 +42,10 @@ public function dispatchBlockReason(EbillingDocument $document): ?string return 'human_review_required'; } + if (CreditNoteSign::hasNegativeTotal($document->invoice)) { + return 'credit_note_negative_total'; + } + if (! $this->isApprovalRequired()) { return null; } diff --git a/packages/e-billing/src/Enums/AutoApproveFailureReason.php b/packages/e-billing/src/Enums/AutoApproveFailureReason.php index 70496b8da..68df96e5b 100644 --- a/packages/e-billing/src/Enums/AutoApproveFailureReason.php +++ b/packages/e-billing/src/Enums/AutoApproveFailureReason.php @@ -13,4 +13,5 @@ enum AutoApproveFailureReason: string case AnomalyFlagged = 'anomaly_flagged'; case AutoApproveDisabled = 'auto_approve_disabled'; case ApprovalNotPending = 'approval_not_pending'; + case CreditNoteNegativeTotal = 'credit_note_negative_total'; } diff --git a/packages/e-billing/src/Support/CreditNoteSign.php b/packages/e-billing/src/Support/CreditNoteSign.php new file mode 100644 index 000000000..3ea48961a --- /dev/null +++ b/packages/e-billing/src/Support/CreditNoteSign.php @@ -0,0 +1,23 @@ +document_type)) { + return false; + } + + return (float) $invoice->gross_total < 0; + } +} From dcbb60962a052f1dd49ac2e1c4239aab98b5fdab Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 11:11:02 +0200 Subject: [PATCH 07/12] feat(e-billing): emit fallback payment terms on credit notes --- packages/e-billing/CHANGELOG.md | 1 + packages/e-billing/README.md | 4 +++ .../src/Support/CreditNotePaymentTerms.php | 33 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 packages/e-billing/src/Support/CreditNotePaymentTerms.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 84ad35011..12d23c209 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -7,6 +7,7 @@ - Preceding invoice reference (BG-3, ADR 0009 addendum): DTO `Data\Invoice::$precedingInvoices` (bill_data key `preceding_invoices`, via `PrecedingInvoiceReferences`), mapped onto the persisted invoice by `ParsedInvoiceMapper` / `InvoiceFactory`, and onto `ZugferdInvoice::precedingInvoices` by both Zugferd adapters. `InvoiceFieldValidator` looks the referenced invoice up among stored invoices (separators ignored, e.g. `30641.25` matches `3064125`); not found or a differing date is a non-blocking warning (`reason: preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders the field as a link to that invoice. - Credit-note resource lists 381 and 384 (`resources.credit_notes.document_types`), with `tabs.credit_notes` (per-type tabs) and a document-type badge column when a resource shows more than one type. - `e-billing.credit_note_payment_terms` (default `null`): BT-20 text emitted for a 381 without due date and payment terms, so BR-CO-25 accepts a positive amount due. +- Credit note with a negative total blocks approval (ADR 0010): `CreditNoteSign::hasNegativeTotal()` (BT-3 `381` with negative BT-112) is a new blocking condition in `DocumentApprovalGuard`, `AutoApproveEvaluator` (`AutoApproveFailureReason::CreditNoteNegativeTotal`), and `DocumentDispatchGuard` (reason `credit_note_negative_total`). - `e-billing.intake.scopes`: optional allowlist of mail-inbox Scope keys for `ProcessInboxAttachmentListener` (null/`[]` = all; non-listed PDFs marked Skipped, no `EbillingDocument`). - Optional `e-billing.delivery.from_name` / `EBILLING_DELIVERY_FROM_NAME` (display name only; From address is host-owned). diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index a325eb4de..dda0730bf 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -224,6 +224,8 @@ Changing a field's configured priority changes its behaviour with no code change `FieldValidationProfile` selects which MoSCoW and ViewInvoice-denylist maps apply, keyed on the invoice's `document_type` (BT-3), ADR 0009. `field_validation.document_type_profiles` maps a type code to a key prefix — package default `381 => credit_note`, `384 => corrected_invoice`. A mapped type reads `field_validation.{prefix}_fields` / `{prefix}_line_fields` / `{prefix}_contextual_should` / `{prefix}_line_contextual_should` and `invoice_ui.{prefix}_fields_hidden` / `{prefix}_line_fields_hidden`; unmapped types read the `invoice_*` keys, and a host config that omits a `{prefix}_*` key falls back to the matching `invoice_*` key. The package default config spells out both profiles: the credit-note profile adds `preceding_invoice_number` / `preceding_invoice_date` as `could`, the corrected-invoice profile as `should`; invoices hide both fields. +**Credit note payment terms:** `e-billing.credit_note_payment_terms` (default `null`) is emitted as BT-20 for a 381 without due date and payment terms, which BR-CO-25 otherwise rejects for a positive amount due. + **Preceding invoice reference (BG-3):** `preceding_invoice_number` / `preceding_invoice_date` map to `Invoice::$preceding_invoices[0]` (BT-25/BT-26). When present, `InvoiceFieldValidator` looks the referenced invoice up among stored invoices — number comparison ignores separators (`30641.25` matches `3064125`) — and never blocks: not found or a differing date is `status: parsed` with a `reason` (`preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders `preceding_invoice_number` as a link to that invoice. ### Duplicate document-number rule @@ -256,6 +258,8 @@ Distinct from `review_status` (field-review clearance) and `gateway_status` (KOS Transitions write latest-only `approval_reason`, `approval_actor_id` (string; `'system'` for auto-approve), and `approval_acted_at` on the document. Approving a document that carries valid severity releases forwards those release reasons into `approval_reason` when no other reason is supplied. History is the `moox/audit` Activity trail on `EbillingDocument` (`approval_status`, `approval_reason` in the body; actor and time come from the Activity causer/timestamp, not extra attribute rows); the invoice detail Activity table aggregates the document via `aggregate_subjects`. Only `RecordApprovalTransitionAction` writes approval state for approve/reject/restore. Initialize and invalidate set `pending` and clear actor, time, and reason so a prior sign-off cannot dispatch. Rematch and manual attribution both invalidate prior approval. `DocumentApprovalTransitioned` is emitted for host listeners (the package does not send mail). +A credit note (BT-3 `381`) with a negative gross total (BT-112) blocks approval and dispatch (ADR 0010): `CreditNoteSign::hasNegativeTotal()` is checked by `DocumentApprovalGuard` (manual approve refused), `AutoApproveEvaluator` (`AutoApproveFailureReason::CreditNoteNegativeTotal`), and `DocumentDispatchGuard` (block reason `credit_note_negative_total`). Flipping the sign is the parser's job, not this package's. + `DocumentDispatchGuard` requires `approval_status = approved` and a non-empty actor id plus `approval_acted_at` when approval is required. Approved-but-missing actor or acted-at blocks with `approval_incomplete`. It never reads Activity. Auto-approve persists with no authenticated user so the Activity causer is the host `audit.system_causer` (when set), not a logged-in operator. Document actor id on the row stays `'system'`. **Automatic approval** runs after gateway validation when every condition holds separately: gateway validated, no unresolved review findings, no blocking must-field, no duplicate flag (`approval_flags.duplicate`), no anomaly flag (`approval_flags.anomalies`). Failing any one leaves the document pending. Field validation syncs `approval_flags.duplicate` when `invoice_number` has reason `duplicate_invoice_number`; hosts may set `approval_flags.anomalies` on the document for anomaly flags (no dedicated writer API on the model). **Manual approve** requires pending status, a deliverable gateway artifact, no unresolved human-review findings, and no blocking must-field; duplicate and anomaly flags do not block a human sign-off after review is clear. diff --git a/packages/e-billing/src/Support/CreditNotePaymentTerms.php b/packages/e-billing/src/Support/CreditNotePaymentTerms.php new file mode 100644 index 000000000..bfa2317d9 --- /dev/null +++ b/packages/e-billing/src/Support/CreditNotePaymentTerms.php @@ -0,0 +1,33 @@ + Date: Tue, 29 Sep 2026 11:18:26 +0200 Subject: [PATCH 08/12] feat(e-billing): classify documents as credit note or corrected invoice --- packages/e-billing/CHANGELOG.md | 3 + packages/e-billing/CONTEXT.md | 3 + packages/e-billing/README.md | 4 + ...illing_uploaded_pdf_sources_table.php.stub | 28 +++ ...cument-classification-is-a-reviewer-act.md | 32 +++ .../e-billing/resources/lang/de/fields.php | 33 +++ .../e-billing/resources/lang/en/fields.php | 33 +++ .../Actions/ClassifyDocumentTypeAction.php | 101 +++++++++ .../CreateManualUploadDocumentAction.php | 47 +++- .../e-billing/src/EBillingServiceProvider.php | 1 + .../e-billing/src/Jobs/StoreBillDataJob.php | 18 ++ .../src/Models/UploadedPdfSource.php | 2 + .../src/Resources/InvoiceResource.php | 85 +++++++- .../src/Services/InvoiceFieldValidator.php | 72 ++++--- .../src/Support/DocumentClassification.php | 204 ++++++++++++++++++ .../Support/DocumentClassificationLabels.php | 82 +++++++ .../src/Support/InvoiceFieldLabels.php | 3 + .../Support/InvoiceNumberDuplicateChecker.php | 9 +- 18 files changed, 724 insertions(+), 36 deletions(-) create mode 100644 packages/e-billing/database/migrations/add_document_type_to_ebilling_uploaded_pdf_sources_table.php.stub create mode 100644 packages/e-billing/docs/adr/0011-document-classification-is-a-reviewer-act.md create mode 100644 packages/e-billing/src/Actions/ClassifyDocumentTypeAction.php create mode 100644 packages/e-billing/src/Support/DocumentClassification.php create mode 100644 packages/e-billing/src/Support/DocumentClassificationLabels.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 12d23c209..876b1d5c5 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -5,6 +5,9 @@ - Supplier number (EN 16931 BT-29, ADR 0012): persist DTO `supplierNumber` onto `invoices.supplier_number` via `ParsedInvoiceMapper` / `InvoiceFactory`; MoSCoW `could` on invoice / credit-note / corrected-invoice field maps; ViewInvoice supplier (BG-4) group shows `supplier_number`; both Zugferd adapters expose `supplierNumber`. No SQL backfill — next `GenerateArtifactJob` / leave-edit fills the column from `bill_data`. - Document-type MoSCoW / ViewInvoice profiles (ADR 0009): `FieldValidationProfile` selects `field_validation.credit_note_*` and `invoice_ui.credit_note_*_hidden` for BT-3 `381`, falling back to the `invoice_*` siblings when a host omits a `credit_note_*` key; every other document type keeps reading `invoice_*`. `EbillingDocument::profileDocumentType()` (from the linked invoice's `document_type`) is threaded through `fieldValidationsNeedHumanReview()`, `missingMustFields()`, `hasBlockingMustFieldFindings()`, `calculateValidationScore()`, `isFullyValidated()`, and the review-queue scope (now optional trailing `?string $documentType` params). Package default config clones the invoice maps onto `credit_note_fields` / `credit_note_line_fields` / contextual lists and hides `preceding_invoice_number` / `preceding_invoice_date` on invoices. - Preceding invoice reference (BG-3, ADR 0009 addendum): DTO `Data\Invoice::$precedingInvoices` (bill_data key `preceding_invoices`, via `PrecedingInvoiceReferences`), mapped onto the persisted invoice by `ParsedInvoiceMapper` / `InvoiceFactory`, and onto `ZugferdInvoice::precedingInvoices` by both Zugferd adapters. `InvoiceFieldValidator` looks the referenced invoice up among stored invoices (separators ignored, e.g. `30641.25` matches `3064125`); not found or a differing date is a non-blocking warning (`reason: preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders the field as a link to that invoice. +- Corrected invoice (384) profile (ADR 0009 addendum 2): `field_validation.document_type_profiles` maps type codes to profile key prefixes (default `381 => credit_note`, `384 => corrected_invoice`); new `corrected_invoice_*` MoSCoW maps and `invoice_ui.corrected_invoice_*_hidden`; the review-queue scope splits by every mapped type with its own priorities; the preceding-invoice lookup only searches unmapped types. `FieldValidationProfile::priorityMapsDifferByType()` is replaced by `documentTypesWithOwnPriorities()`. +- Document classification (ADR 0011): `ClassifyDocumentTypeAction` switches a document between `e-billing.document_classification.types` (381 / 384) before approval, negating all amounts when the signs differ, field changes are audited by moox/audit, and the act is logged as a `document_classified` activity (`ClassifyDocumentTypeAction::ACTIVITY_EVENT`) when moox/audit is installed. Translatable labels and hover hints via `DocumentClassificationLabels`. The dropdown itself lands with the review workspace (#47). +- Declared document type at manual upload (ADR 0011 addendum): `resources.{key}.manual_upload.document_types` (credit notes `['381', '384']`) makes the upload dialog show a required type choice without preselection and a collapsible "which type?" instruction (`e-billing::fields.document_classification.{code}.rule|examples`, `document_classification_help.*`, en + de; hosts override the examples). The choice is stored on `UploadedPdfSource.document_type` (migration `add_document_type_to_ebilling_uploaded_pdf_sources_table`), logged as a `document_classified` activity (origin `upload`) and applied after parsing by `StoreBillDataJob` through `DocumentClassification::applyDeclaredType()` (sign flip included). A parsed type outside the selectable set is kept and `document_type` becomes `needs_review` (`declared_document_type_mismatch`). Classification types count as one type in both duplicate checks. `allowed_document_type_codes` now includes `384`. `DocumentClassification` centralises signs, the duplicate family and the activity; `ClassifyDocumentTypeAction::classificationSigns()` is replaced by `DocumentClassification::signs()`. - Credit-note resource lists 381 and 384 (`resources.credit_notes.document_types`), with `tabs.credit_notes` (per-type tabs) and a document-type badge column when a resource shows more than one type. - `e-billing.credit_note_payment_terms` (default `null`): BT-20 text emitted for a 381 without due date and payment terms, so BR-CO-25 accepts a positive amount due. - Credit note with a negative total blocks approval (ADR 0010): `CreditNoteSign::hasNegativeTotal()` (BT-3 `381` with negative BT-112) is a new blocking condition in `DocumentApprovalGuard`, `AutoApproveEvaluator` (`AutoApproveFailureReason::CreditNoteNegativeTotal`), and `DocumentDispatchGuard` (reason `credit_note_negative_total`). diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index dc6db967c..98c6a21d1 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -11,6 +11,9 @@ Glossary for the generic e-billing conversion pipeline (`packages/e-billing`). K - **Foreign disposition** — host config for what happens after a Foreign invoice is classified: `ignore` (default; settle inbox `Ignored` only) or `forward` (Source-PDF relay, then settle `Ignored`). *Avoid:* hard-coding one behaviour in the job. - **Selective redispatch** — the operator *Erneut zustellen* act that runs a **chosen subset** of configured delivery channels and records new attempts (previous attempts kept). Distinct from first post-approval dispatch, which still runs every configured channel. Modal defaults to failed or never-run channels; successful channels may be re-selected with a warning; empty selection is invalid; no recipient override. *Avoid:* reading this as changing “both channels, always” for the initial path; conflating with Source-PDF relay or ad-hoc recipient override. See ADR `docs/adr/0008-selective-redispatch-channel-choice.md`. - **Source-PDF relay** — when Foreign disposition is `forward`: a new outbound mail to Inbox To (`to_email`) carrying the classified source PDF and the inbound subject/body unchanged. Not e-invoice delivery (no approval, no portal, no Artifact). Still `IgnoredForeign`; a delivery attempt is recorded; idempotency uses a stable correlation key from inbox `external_id` + attachment id. Missing/invalid/self To → failed attempt, no send. *Avoid:* Graph forward; master-data recipient; domestic `DispatchDocumentJob`. +- **Document classification** — a reviewer deciding which kind of document this is where the source cannot tell, e.g. credit note (381) vs corrected invoice (384). Audited through moox/audit as its own `document_classified` activity (who, when, old and new type); the amounts' sign follows the chosen type. It is **not** a value correction: the document reads the same either way, so it never counts as a parser error. See ADR `docs/adr/0011-document-classification-is-a-reviewer-act.md`. *Avoid:* modelling it as a correction of `document_type`; re-signing amounts by hand. +- **Declared document type** — the document type an uploader chooses in the manual upload, from the resource's configured selectable codes, with no preselection. It is a statement about the uploaded source and takes precedence over the parser's reading when the parsed type is itself selectable (381 vs 384 cannot be parsed apart). A parsed type outside the selectable set is kept and flagged for review instead. Recorded at upload as a document classification by the uploader. *Avoid:* preselecting a type; silently overwriting a parsed invoice (380) with a declared credit type. +- **Classification family** — the configured classification types (e.g. 381 and 384) count as one document type for duplicate detection: the same source PDF under the other type is an identical-content duplicate, and the same number under the other type is a number duplicate for review. Types outside the family (e.g. invoice 380) stay separate. A wrongly declared type is fixed by reclassifying, never by uploading again. *Avoid:* letting a different type choice create a second live document for the same Beleg. - **Artifact** — the file produced by generation: a loose `.xml` (XRechnung) or a `.pdf` (ZUGFeRD/Factur-X). What gets validated and delivered. - **Validation** — KOSIT/EN16931 conformance check. For pure XML it runs on the `.xml`; for a hybrid PDF it must run on the XML **as embedded in the PDF/A-3** plus PDF-conformance/XMP checks that only exist once the PDF is built. - **Format choice** — customer-selected (planned: customer portal). Makes format a per-customer/per-invoice input rather than a global setting. Motivates generating the chosen artifact *first*, then validating that artifact. The preference belongs to the **Customer**, not the Company: `customer_id` is the identity, while `company_id` is reporting-only, and a debtor with no or several company assignments would otherwise fall back to the default silently. diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index dda0730bf..07e5cc873 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -224,6 +224,10 @@ Changing a field's configured priority changes its behaviour with no code change `FieldValidationProfile` selects which MoSCoW and ViewInvoice-denylist maps apply, keyed on the invoice's `document_type` (BT-3), ADR 0009. `field_validation.document_type_profiles` maps a type code to a key prefix — package default `381 => credit_note`, `384 => corrected_invoice`. A mapped type reads `field_validation.{prefix}_fields` / `{prefix}_line_fields` / `{prefix}_contextual_should` / `{prefix}_line_contextual_should` and `invoice_ui.{prefix}_fields_hidden` / `{prefix}_line_fields_hidden`; unmapped types read the `invoice_*` keys, and a host config that omits a `{prefix}_*` key falls back to the matching `invoice_*` key. The package default config spells out both profiles: the credit-note profile adds `preceding_invoice_number` / `preceding_invoice_date` as `could`, the corrected-invoice profile as `should`; invoices hide both fields. +**Document classification (ADR 0011):** `ClassifyDocumentTypeAction` lets a reviewer switch a document between the types in `e-billing.document_classification.types` (default `381 => positive`, `384 => negative`) before approval. It negates all document, line and allowance/charge amounts when the signs differ. The field changes are audited by moox/audit like any invoice update, and the act is logged as its own `document_classified` activity (from, to, amounts negated, origin) — never a value correction. `384 => negative` describes a correction issued as a credit (the delta); a host that issues full, positive restatements sets `positive`. Dropdown labels and "when to choose" hints: `DocumentClassificationLabels` (`e-billing::fields.document_classification.{code}.label|hint`). The credit-note list shows both types (`resources.credit_notes.document_types`) with per-type tabs and a type badge. + +**Declared document type at upload:** `resources.{key}.manual_upload.document_types` (credit notes: `['381', '384']`) adds a required type choice without preselection to the upload dialog, with a collapsible instruction built from `document_classification.{code}.rule|examples` and `document_classification_help.*`. Codes must be in the resource's `document_types`, `document_classification.types` and `allowed_document_type_codes`. One code is applied without a choice; an empty list leaves the parser in charge. The choice is stored on the uploaded source, logged as `document_classified` (origin `upload`) and applied after parsing: a parsed 381/384 is replaced (with the sign flip), any other parsed type is kept and flagged for review. Credit notes and corrected invoices count as one type for duplicate detection. Hosts override the examples in `lang/vendor/e-billing/{locale}/fields.php`; example lists merge by index, so keep them the same length. Run the migration `add_document_type_to_ebilling_uploaded_pdf_sources_table`. + **Credit note payment terms:** `e-billing.credit_note_payment_terms` (default `null`) is emitted as BT-20 for a 381 without due date and payment terms, which BR-CO-25 otherwise rejects for a positive amount due. **Preceding invoice reference (BG-3):** `preceding_invoice_number` / `preceding_invoice_date` map to `Invoice::$preceding_invoices[0]` (BT-25/BT-26). When present, `InvoiceFieldValidator` looks the referenced invoice up among stored invoices — number comparison ignores separators (`30641.25` matches `3064125`) — and never blocks: not found or a differing date is `status: parsed` with a `reason` (`preceding_invoice_not_found` / `preceding_invoice_date_mismatch`); a match sets `matched_id` and ViewInvoice renders `preceding_invoice_number` as a link to that invoice. diff --git a/packages/e-billing/database/migrations/add_document_type_to_ebilling_uploaded_pdf_sources_table.php.stub b/packages/e-billing/database/migrations/add_document_type_to_ebilling_uploaded_pdf_sources_table.php.stub new file mode 100644 index 000000000..af2b1c8dd --- /dev/null +++ b/packages/e-billing/database/migrations/add_document_type_to_ebilling_uploaded_pdf_sources_table.php.stub @@ -0,0 +1,28 @@ +string('document_type', 3)->nullable()->after('requires_letterhead_overlay'); + } + }); + } + + public function down(): void + { + Schema::table('ebilling_uploaded_pdf_sources', function (Blueprint $table): void { + if (Schema::hasColumn('ebilling_uploaded_pdf_sources', 'document_type')) { + $table->dropColumn('document_type'); + } + }); + } +}; diff --git a/packages/e-billing/docs/adr/0011-document-classification-is-a-reviewer-act.md b/packages/e-billing/docs/adr/0011-document-classification-is-a-reviewer-act.md new file mode 100644 index 000000000..83470a512 --- /dev/null +++ b/packages/e-billing/docs/adr/0011-document-classification-is-a-reviewer-act.md @@ -0,0 +1,32 @@ +--- +status: accepted +date: 2026-09-25 +--- + +# Choosing between credit note (381) and corrected invoice (384) is an audited reviewer act, not a value correction + +A credit note (381) credits an amount without correcting a specific invoice. A corrected invoice (384) corrects or cancels an issued invoice and must reference it (BG-3). The source document often reads the same for both, so a parser cannot tell them apart; a reviewer has to decide. `ClassifyDocumentTypeAction` performs that decision. The resulting field changes (`document_type`, the totals) are audited by **moox/audit** like every other invoice update, and the act itself is logged as its own activity, `document_classified` (from, to, whether amounts were negated), on the document. It is deliberately **not** a value correction (mooxphp/e-billing#40 / #45): a value correction means "the parser read the document wrong" and feeds the parser-feedback report, while a classification leaves the document's content untouched. Counting it as a correction would report every 384 as a parser defect in `document_type`. + +The types a reviewer may switch between, and the sign their amounts carry, are configured in `e-billing.document_classification.types` (package default `381 => positive`, `384 => negative`, following the KoSIT/e-rechnung-bund FAQ and ADR 0010). Switching between types with different signs negates every document, line and allowance/charge amount in the same transaction, because the sign follows from the type, not from what the document prints. A classification is refused once the document is approved, like any review change. The choice is offered in the mixed review workspace (mooxphp/e-billing#47); leaving the workspace regenerates and re-validates the artifact (#48), so an artifact never carries the old type. The dropdown labels and "when to choose" hints are package translations (`e-billing::fields.document_classification.{code}`), which hosts override by publishing them. + +## Considered options + +- **A value correction on `document_type`.** Rejected: it would skew the parser-feedback metric that #40 exists to measure. +- **A dedicated review-actions table.** Rejected: moox/audit already records who changed which invoice field when; a second log would duplicate it. The distinction from a correction needs only a distinct activity event. +- **Re-signing amounts by hand as per-field corrections.** Rejected: dozens of corrections per document, all counted as parser errors, for a change that follows mechanically from the type. + +## Consequences + +- Like the other e-billing activities (e.g. `delivery_attempted`), the `document_classified` activity is written only when moox/audit is installed. +- The 384 profile is selected through `field_validation.document_type_profiles` (ADR 0009 addendum 2). +- The negative-total block of ADR 0010 stays limited to 381; a 384 is expected to carry negative amounts when it reduces or cancels. + +## Addendum (2026-09-28): the uploader declares the type; sign convention for 384 + +Research: `docs/research/2026-09-28-credit-note-381-vs-corrected-invoice-384-examples.md` (web repo). + +- **Declared document type.** The manual upload offers the resource's selectable codes, `resources.{key}.manual_upload.document_types` (credit notes: `381`, `384`). The list must be a subset of the resource's `document_types`, of `document_classification.types` and of `allowed_document_type_codes` (which now includes 384); a violation fails with a clear error when the upload action is built. With one code there is no dropdown, and with none the parser decides as before. There is no preselection: the choice is required. The choice is stored on the uploaded source (`UploadedPdfSource.document_type`) and recorded at upload time as a `document_classified` activity with the uploader as actor. After parsing, the pipeline applies it through the same classification core as the reviewer switch, including the sign flip, without an actor check; when that replaces the parsed type, a second `document_classified` activity (origin `parsing`) records the parsed and the declared type and whether the amounts were negated. A parsed type that is itself selectable is overridden: 381 and 384 cannot be told apart by parsing. A parsed type outside the selectable set (e.g. 380) is kept, and `document_type` becomes a review finding; it is never overwritten silently. +- **Classification family for duplicates.** The classification types count as one type for the identical-content and the number duplicate check. Uploading the same PDF again under the other type is an identical duplicate, and a wrongly declared type is fixed by reclassifying, never by a second upload. Until the review workspace (#47) ships, the interim fix is to delete the unapproved document and upload it again. +- **Instruction in the upload dialog.** A collapsible section, closed by default, with the rule (384: the original invoice was wrong or is cancelled, reference required; 381: the original was correct and the amount owed changed later, §17 UStG) and examples per code, as one compact text block (bold label, rule, bullet list; inline styles, all texts escaped). A 384 cancellation is labelled "Stornorechnung": XRechnung has no separate storno code (BR-DE-17), KoSIT calls a Stornorechnung a corrected invoice. It is translatable (`e-billing::fields.document_classification.*`, en and de), carries generic examples only, and hosts override them with their own cases. Sources stay in the research note, not in the UI. +- **Sign for 384.** Two valid patterns exist: a full, positive restatement of the corrected invoice (KoSIT test case 01.18) and a correction issued as a credit, a delta (e-rechnung-bund FAQ: "Hierzu wird eine Gutschrift ausgesprochen"). The package default `384 => negative` describes the delta. A host that issues full restatements configures `positive`. The earlier statement that a 384 "reverses the corrected invoice" applies to the delta pattern only. +- **Wording.** The label "Gutschrift" alone does not trigger § 14c UStG (UStAE 14.3 Abs. 1 S. 6, 14c.1 Abs. 3 S. 4). The legal weight lies in the type code and the reference to the original invoice. Choosing 381 where the original invoice was wrong leaves that invoice uncorrected. diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php index cbab8eaf9..8c83f5b9d 100644 --- a/packages/e-billing/resources/lang/de/fields.php +++ b/packages/e-billing/resources/lang/de/fields.php @@ -163,6 +163,38 @@ 'tab_needs_review' => 'Prüfung nötig', 'tab_confirmed' => 'Bestätigt', 'tab_deleted' => 'Gelöscht', + 'tab_credit_notes' => 'Kaufmännische Gutschriften', + 'tab_corrected_invoices' => 'Rechnungskorrekturen', + + // Document classification (ADR 0011): label, "when to choose" hint, upload instruction (rule + examples). + // Hosts override via lang/vendor/e-billing; example lists merge by index, so keep the same length. + 'document_classification' => [ + '381' => [ + 'label' => 'Kaufmännische Gutschrift (381)', + 'hint' => 'Wählen, wenn die ursprüngliche Rechnung korrekt war und sich der geschuldete Betrag danach aus einem eigenständigen Grund geändert hat – z. B. Bonus, Rabatt oder Rücksendung. Beträge werden positiv ausgewiesen. Nicht gemeint ist die Gutschrift, die der Leistungsempfänger selbst ausstellt (§ 14 Abs. 2 Satz 2 UStG).', + 'rule' => 'Die ursprüngliche Rechnung war korrekt. Der geschuldete Betrag hat sich danach aus einem eigenständigen kaufmännischen Grund geändert. Ein Bezug auf die Ursprungsrechnung ist nicht nötig.', + 'examples' => [ + 'Jahresrückvergütung oder Bonus für ein erreichtes Umsatzvolumen', + 'Nachträglicher Rabatt, Preisnachlass oder Skonto', + 'Rücksendung (Retoure) ordnungsgemäß gelieferter und berechneter Ware', + ], + ], + '384' => [ + 'label' => 'Rechnungskorrektur (384)', + 'hint' => 'Wählen, wenn eine bereits gestellte Rechnung fehlerhaft war oder storniert wird – z. B. falsche Menge, falscher Preis oder vollständiger Storno. Die ursprüngliche Rechnung muss mit Nummer und Datum angegeben sein (§ 31 Abs. 5 UStDV). Wird die Korrektur als Gutschrift des Differenzbetrags ausgestellt, sind die Beträge negativ.', + 'rule' => 'Die ursprüngliche Rechnung war fehlerhaft oder wird storniert. Die Ursprungsrechnung muss mit Nummer und Datum angegeben sein.', + 'examples' => [ + 'Falsche Menge oder falscher Preis wurde berechnet', + 'Falsche oder fehlende Pflichtangabe auf der Rechnung', + 'Stornorechnung: vollständige Stornierung einer bereits übermittelten Rechnung', + ], + ], + ], + 'document_classification_help' => [ + 'title' => 'Welchen Belegtyp wähle ich?', + 'placeholder' => 'Belegtyp wählen', + 'helper' => 'Pflichtangabe. Entscheidend ist, ob die ursprüngliche Rechnung fehlerhaft war – nicht, ob das Dokument „Gutschrift“ heißt.', + ], // Filters 'filter_needs_review' => 'Prüfung nötig', @@ -297,6 +329,7 @@ 'hint_review_delivery_date' => 'Mehrere Lieferdaten erfüllen die Regel zur tatsächlichen Lieferung ' .'bei innergemeinschaftlicher Lieferung nicht, ohne sie zu einem Zeitraum zusammenzuziehen.', 'hint_review_duplicate_invoice_number' => 'Diese Belegnummer existiert bereits und muss geprüft werden.', + 'hint_review_declared_document_type' => 'Beim Hochladen wurde ein anderer Belegtyp gewählt, als das Dokument trägt – bitte prüfen.', 'hint_review_default' => 'Dieses Feld sollte manuell überprüft werden.', // Field hints — informational (no validation status) diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php index 8473298fb..78be91985 100644 --- a/packages/e-billing/resources/lang/en/fields.php +++ b/packages/e-billing/resources/lang/en/fields.php @@ -163,6 +163,38 @@ 'tab_needs_review' => 'Review needed', 'tab_confirmed' => 'Confirmed', 'tab_deleted' => 'Deleted', + 'tab_credit_notes' => 'Credit notes', + 'tab_corrected_invoices' => 'Corrected invoices', + + // Document classification (ADR 0011): label, "when to choose" hint, upload instruction (rule + examples). + // Hosts override via lang/vendor/e-billing; example lists merge by index, so keep the same length. + 'document_classification' => [ + '381' => [ + 'label' => 'Credit note (381)', + 'hint' => 'Choose when the original invoice was correct and the amount owed changed afterwards for an independent reason – for example a bonus, a discount or a return. Amounts are stated as positive values. Not meant: self-billing, where the buyer issues the document.', + 'rule' => 'The original invoice was correct. The amount owed changed afterwards for an independent commercial reason. No reference to the original invoice is needed.', + 'examples' => [ + 'Year-end rebate or bonus for a reached sales volume', + 'Subsequent discount, price reduction or cash discount', + 'Return of goods that were delivered and invoiced correctly', + ], + ], + '384' => [ + 'label' => 'Corrected invoice (384)', + 'hint' => 'Choose when an invoice already issued was wrong or is cancelled – for example a wrong quantity, a wrong price or a full cancellation. The original invoice must be referenced by number and date. When the correction is issued as a credit of the difference, the amounts are negative.', + 'rule' => 'The original invoice was wrong or is cancelled. The original invoice must be referenced by number and date.', + 'examples' => [ + 'A wrong quantity or price was invoiced', + 'A mandatory invoice detail was wrong or missing', + 'Cancellation invoice: full cancellation of an invoice already sent', + ], + ], + ], + 'document_classification_help' => [ + 'title' => 'Which document type do I choose?', + 'placeholder' => 'Choose a document type', + 'helper' => 'Required. What matters is whether the original invoice was wrong – not whether the document is called a credit note.', + ], // Filters 'filter_needs_review' => 'Review needed', @@ -297,6 +329,7 @@ 'hint_review_delivery_date' => 'Several delivery dates cannot satisfy the intra-community ' .'actual delivery date rule without aggregating them into a period.', 'hint_review_duplicate_invoice_number' => 'This document number already exists and needs review.', + 'hint_review_declared_document_type' => 'A different document type was chosen at upload than the document carries – please check.', 'hint_review_default' => 'This field should be reviewed manually.', // Field hints — informational (no validation status) diff --git a/packages/e-billing/src/Actions/ClassifyDocumentTypeAction.php b/packages/e-billing/src/Actions/ClassifyDocumentTypeAction.php new file mode 100644 index 000000000..9af563784 --- /dev/null +++ b/packages/e-billing/src/Actions/ClassifyDocumentTypeAction.php @@ -0,0 +1,101 @@ +user() === null) { + throw new InvalidArgumentException('An authenticated actor is required to classify a document.'); + } + + if ($document->resolveApprovalStatusEnum() === DocumentApprovalStatus::Approved) { + throw new InvalidArgumentException('An approved document cannot be reclassified.'); + } + + $invoice = $document->invoice; + if (! $invoice instanceof Invoice) { + throw new InvalidArgumentException("Document #{$document->id} has no linked invoice."); + } + + $currentType = (string) $invoice->document_type; + + if (! DocumentClassification::isClassificationType($currentType) + || ! DocumentClassification::isClassificationType($documentType)) { + throw new InvalidArgumentException( + "Document type {$currentType} cannot be reclassified as {$documentType}.", + ); + } + + if ($currentType === $documentType) { + return false; + } + + DB::transaction(function () use ($document, $invoice, $currentType, $documentType): void { + $amountsNegated = DocumentClassification::signsDiffer($currentType, $documentType); + if ($amountsNegated) { + $this->negateAmounts($invoice); + } + + $invoice->document_type = $documentType; + $invoice->save(); + + DocumentClassification::recordActivity($document, $currentType, $documentType, $amountsNegated, 'review'); + }); + + return true; + } + + /** + * Sign flip on the persisted invoice. Keep in step with the flip on parsed data, + * {@see DocumentClassification::negateBillData()}: both must cover every amount. + */ + private function negateAmounts(Invoice $invoice): void + { + $invoice->net_total = -(float) $invoice->net_total; + $invoice->vat_amount = -(float) $invoice->vat_amount; + $invoice->gross_total = -(float) $invoice->gross_total; + + $invoice->load(['lines.allowanceCharges', 'allowanceCharges']); + + foreach ($invoice->lines as $line) { + $line->quantity = -(float) $line->quantity; + $line->line_total = -(float) $line->line_total; + $line->save(); + + $line->allowanceCharges->each(fn (InvoiceAllowanceCharge $charge) => $this->negateCharge($charge)); + } + + $invoice->allowanceCharges->each(fn (InvoiceAllowanceCharge $charge) => $this->negateCharge($charge)); + } + + private function negateCharge(InvoiceAllowanceCharge $charge): void + { + $charge->amount = -(float) $charge->amount; + $charge->base_amount = $charge->base_amount !== null ? -(float) $charge->base_amount : null; + $charge->save(); + } +} diff --git a/packages/e-billing/src/Actions/CreateManualUploadDocumentAction.php b/packages/e-billing/src/Actions/CreateManualUploadDocumentAction.php index da7435f91..9f3efa603 100644 --- a/packages/e-billing/src/Actions/CreateManualUploadDocumentAction.php +++ b/packages/e-billing/src/Actions/CreateManualUploadDocumentAction.php @@ -10,6 +10,7 @@ use Moox\EBilling\Jobs\StoreBillDataJob; use Moox\EBilling\Models\EbillingDocument; use Moox\EBilling\Models\UploadedPdfSource; +use Moox\EBilling\Support\DocumentClassification; use Moox\EBilling\Support\IdenticalDuplicateNotifier; use Moox\EBilling\Support\StoredRelativePath; @@ -21,11 +22,15 @@ final class CreateManualUploadDocumentAction * source_pdf_disk?: string, * original_filename?: ?string, * scope?: ?string, - * requires_letterhead_overlay?: bool - * } $data + * requires_letterhead_overlay?: bool, + * resource?: ?string, + * document_type?: string|int|null + * } $data `resource` is the e-billing resource config key; `document_type` the declared BT-3 code */ public function execute(array $data): EbillingDocument { + $declaredType = $this->declaredDocumentType($data); + $configuredDisk = (string) config('e-billing.manual_upload.source_disk', 'local'); $directory = (string) config('e-billing.manual_upload.source_path', 'ebilling/manual-uploads/source'); $requestedDisk = $data['source_pdf_disk'] ?? $configuredDisk; @@ -44,6 +49,7 @@ public function execute(array $data): EbillingDocument 'original_filename' => $data['original_filename'] ?? null, 'scope' => $scope, 'requires_letterhead_overlay' => (bool) ($data['requires_letterhead_overlay'] ?? false), + 'document_type' => $declaredType, ]); $document = EbillingDocument::query()->create([ @@ -54,6 +60,10 @@ public function execute(array $data): EbillingDocument 'review_status' => InvoiceProcessingStatus::ParserCreated, ]); + if ($declaredType !== null) { + DocumentClassification::recordActivity($document, null, $declaredType, false, 'upload'); + } + app(IdenticalDuplicateNotifier::class)->rememberCurrentUser($document); StoreBillDataJob::dispatch($document->getKey()); @@ -61,6 +71,39 @@ public function execute(array $data): EbillingDocument return $document; } + /** + * The declared type must be one of the resource's selectable codes; a single code is declared implicitly. + * + * @param array $data + */ + private function declaredDocumentType(array $data): ?string + { + $resource = $data['resource'] ?? null; + $selectable = is_string($resource) && $resource !== '' + ? DocumentClassification::selectableForUpload($resource) + : []; + + if ($selectable === []) { + return null; + } + + if (count($selectable) === 1) { + return $selectable[0]; + } + + // A select with numeric option keys hands the code back as an int. + $declared = $data['document_type'] ?? null; + $declared = is_int($declared) || is_string($declared) ? (string) $declared : null; + + if ($declared === null || ! in_array($declared, $selectable, true)) { + throw new InvalidArgumentException( + 'Choose the document type of the upload: one of '.implode(', ', $selectable).'.', + ); + } + + return $declared; + } + private function assertWithinMaxSize(string $disk, string $path): void { $maxSizeKb = max(1, (int) config('e-billing.manual_upload.max_size_kb', 20480)); diff --git a/packages/e-billing/src/EBillingServiceProvider.php b/packages/e-billing/src/EBillingServiceProvider.php index 3d97781f9..b555e63f6 100644 --- a/packages/e-billing/src/EBillingServiceProvider.php +++ b/packages/e-billing/src/EBillingServiceProvider.php @@ -93,6 +93,7 @@ public function configureMoox(Package $package): void 'create_ebilling_uploaded_pdf_sources_table', 'add_profile_to_ebilling_documents_table', 'create_ebilling_delivery_attempts_table', + 'add_document_type_to_ebilling_uploaded_pdf_sources_table', ]); $this->getMooxPackage() diff --git a/packages/e-billing/src/Jobs/StoreBillDataJob.php b/packages/e-billing/src/Jobs/StoreBillDataJob.php index 9d65a1974..f46df1836 100644 --- a/packages/e-billing/src/Jobs/StoreBillDataJob.php +++ b/packages/e-billing/src/Jobs/StoreBillDataJob.php @@ -13,6 +13,7 @@ use Moox\EBilling\Enums\EBillingAttachmentProcessingStatus; use Moox\EBilling\Models\EbillingDocument; use Moox\EBilling\Services\EBilling; +use Moox\EBilling\Support\DocumentClassification; use Moox\EBilling\Support\SourceContentHasher; use Moox\Jobs\Traits\JobProgress; use Moox\MailInbox\Enums\InboxAttachmentProcessingStatus; @@ -77,6 +78,23 @@ public function handle(EBilling $eBilling): void $this->setProgress(20); $invoice = $eBilling->parseInvoiceFromPdf($document->sourceFullPath()); + + $declaredType = DocumentClassification::declaredFor($document); + if ($declaredType !== null) { + $parsedType = $invoice->documentTypeCode; + $invoice = DocumentClassification::applyDeclaredType($invoice, $declaredType); + + if ($invoice->documentTypeCode !== $parsedType) { + DocumentClassification::recordActivity( + $document, + $parsedType, + $invoice->documentTypeCode, + DocumentClassification::signsDiffer($parsedType, $invoice->documentTypeCode), + 'parsing', + ); + } + } + $document->bill_data = $invoice->toArray(); $document->save(); diff --git a/packages/e-billing/src/Models/UploadedPdfSource.php b/packages/e-billing/src/Models/UploadedPdfSource.php index 33395823c..42300717d 100644 --- a/packages/e-billing/src/Models/UploadedPdfSource.php +++ b/packages/e-billing/src/Models/UploadedPdfSource.php @@ -28,6 +28,8 @@ class UploadedPdfSource extends Model 'original_filename', 'scope', 'requires_letterhead_overlay', + // Declared document type (BT-3) chosen at upload (ADR 0011 addendum); null = the parser decides. + 'document_type', ]; /** diff --git a/packages/e-billing/src/Resources/InvoiceResource.php b/packages/e-billing/src/Resources/InvoiceResource.php index b75519413..e9e1e26a9 100644 --- a/packages/e-billing/src/Resources/InvoiceResource.php +++ b/packages/e-billing/src/Resources/InvoiceResource.php @@ -12,6 +12,8 @@ use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; use Filament\Notifications\Notification; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Text; use Filament\Schemas\Schema; use Filament\Support\Enums\Alignment; use Filament\Support\Icons\Heroicon; @@ -26,6 +28,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Support\HtmlString; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Core\Traits\InteractsWithAuditResourceRelations; use Moox\Core\Traits\Relations\HasResourceRelations; @@ -40,6 +43,8 @@ use Moox\EBilling\Resources\InvoiceResource\Pages\ListInvoices; use Moox\EBilling\Resources\InvoiceResource\Pages\ViewInvoice; use Moox\EBilling\Resources\InvoiceResource\RelationManagers\MailSendLogsRelationManager; +use Moox\EBilling\Support\DocumentClassification; +use Moox\EBilling\Support\DocumentClassificationLabels; use Moox\EBilling\Support\InvoiceFieldLabels; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Support\InvoiceModels; @@ -201,6 +206,14 @@ private static function invoiceListTableColumns(): array ->color('primary') ->weight('medium') ->toggleable(), + TextColumn::make('document_type') + ->label(__('e-billing::fields.document_type')) + ->badge() + ->formatStateUsing(fn (?string $state): ?string => $state !== null + ? DocumentClassificationLabels::label($state) + : null) + ->visible(count(static::documentTypes()) > 1) + ->toggleable(), TextColumn::make('document_version') ->label(__('e-billing::fields.document_version')) ->formatStateUsing(function ($state, Invoice $record): string { @@ -689,6 +702,71 @@ public static function applyListTabConditions(Builder $query, array $conditions) return $query; } + /** + * Declared document type for the manual upload (ADR 0011 addendum): a collapsible "which type?" + * instruction and a required choice without preselection. Nothing to choose with fewer than two codes. + * + * @param list $selectableTypes + * @return list + */ + private static function manualUploadDocumentTypeComponents(array $selectableTypes): array + { + if (count($selectableTypes) < 2) { + return []; + } + + $options = array_intersect_key(DocumentClassificationLabels::options(), array_flip($selectableTypes)); + + return [ + Section::make(__('e-billing::fields.document_classification_help.title')) + ->collapsible() + ->collapsed() + ->schema([ + Text::make(self::documentTypeInstructionHtml( + DocumentClassificationLabels::instruction($selectableTypes), + )), + ]), + Select::make('document_type') + ->label(__('e-billing::fields.document_type')) + ->options($options) + ->placeholder(__('e-billing::fields.document_classification_help.placeholder')) + ->helperText(__('e-billing::fields.document_classification_help.helper')) + ->required(), + ]; + } + + /** + * One compact block per code: bold label, rule, bulleted examples. Inline styles, because the panel + * theme does not ship utility classes for package markup; every text is escaped. + * + * @param list}> $blocks + */ + private static function documentTypeInstructionHtml(array $blocks): HtmlString + { + $html = ''; + + foreach ($blocks as $index => $block) { + $html .= $index > 0 ? '
' : '
'; + $html .= '

'.e($block['label']).'

'; + + if ($block['rule'] !== null) { + $html .= '

'.e($block['rule']).'

'; + } + + if ($block['examples'] !== []) { + $html .= '
    '; + foreach ($block['examples'] as $example) { + $html .= '
  • '.e($example).'
  • '; + } + $html .= '
'; + } + + $html .= '
'; + } + + return new HtmlString($html); + } + public static function getManualUploadAction(): ?Action { $config = config('e-billing.resources.'.static::resourceConfigKey().'.manual_upload'); @@ -703,6 +781,8 @@ public static function getManualUploadAction(): ?Action $label = is_string($config['label'] ?? null) ? $config['label'] : __('e-billing::fields.action_manual_upload'); $scope = is_string($config['scope'] ?? null) ? $config['scope'] : static::resourceConfigKey(); $requiresLetterhead = (bool) ($config['requires_letterhead_overlay'] ?? false); + $resourceKey = static::resourceConfigKey(); + $selectableTypes = DocumentClassification::selectableForUpload($resourceKey); return Action::make('manualUpload') ->label($label) @@ -722,8 +802,9 @@ public static function getManualUploadAction(): ?Action ->directory($directory) ->storeFileNamesIn('pdf_original_filename') ->required(), + ...self::manualUploadDocumentTypeComponents($selectableTypes), ]) - ->action(function (array $data, $livewire) use ($disk, $scope, $requiresLetterhead): void { + ->action(function (array $data, $livewire) use ($disk, $scope, $requiresLetterhead, $resourceKey): void { $path = $data['pdf'] ?? null; if (! is_string($path) || $path === '') { @@ -736,6 +817,8 @@ public static function getManualUploadAction(): ?Action 'original_filename' => self::resolveUploadedOriginalFilename($data, $path), 'scope' => $scope, 'requires_letterhead_overlay' => $requiresLetterhead, + 'resource' => $resourceKey, + 'document_type' => $data['document_type'] ?? null, ]); Notification::make() diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index 8ac51918a..a4e11f566 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -14,6 +14,8 @@ use Moox\EBilling\Support\CompanyNameMatcher; use Moox\EBilling\Support\CustomerMatcher; use Moox\EBilling\Support\DeliveryDateTransmission; +use Moox\EBilling\Support\DocumentClassification; +use Moox\EBilling\Support\FieldValidationProfile; use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\InvoiceNumberDuplicateChecker; use Moox\EBilling\Support\LineAllowanceChargeResolver; @@ -27,6 +29,9 @@ class InvoiceFieldValidator { + /** BT-3 of the invoice being validated; selects the MoSCoW profile (ADR 0009). */ + private ?string $profileDocumentType = null; + /** * Populate field_validations on the document (invoice-level + lines sub-structure), * attribute the document to a {@see Customer} via buyer identifier when present, @@ -40,15 +45,9 @@ public function fillFieldValidations(EbillingDocument $document): void $invoice = $this->resolveInvoice($document); - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - $lineFields = config('e-billing.field_validation.invoice_line_fields', []); - - if (! is_array($invoiceFields)) { - $invoiceFields = []; - } - if (! is_array($lineFields)) { - $lineFields = []; - } + $this->profileDocumentType = $invoice->document_type; + $invoiceFields = FieldValidationProfile::invoiceFields($this->profileDocumentType); + $lineFields = FieldValidationProfile::lineFields($this->profileDocumentType); $invoice->loadMissing(['allowanceCharges', 'lines.allowanceCharges']); @@ -84,9 +83,6 @@ public function fillFieldValidations(EbillingDocument $document): void $invoiceValidations = []; foreach ($invoiceFields as $field => $priority) { - if (! is_string($field) || ! is_string($priority)) { - continue; - } $invoiceValidations[$field] = $this->validateInvoiceField( $invoice, $field, @@ -155,15 +151,9 @@ public function refreshReviewOutcome(EbillingDocument $document): void private function applyReviewStatusFromFieldValidations(EbillingDocument $document): void { - $invoiceFields = config('e-billing.field_validation.invoice_fields', []); - $lineFields = config('e-billing.field_validation.invoice_line_fields', []); - - if (! is_array($invoiceFields)) { - $invoiceFields = []; - } - if (! is_array($lineFields)) { - $lineFields = []; - } + $documentType = $document->profileDocumentType(); + $invoiceFields = FieldValidationProfile::invoiceFields($documentType); + $lineFields = FieldValidationProfile::lineFields($documentType); if ($this->allMustAndShouldFieldsAreClean($document, $invoiceFields, $lineFields)) { $document->transitionTo(InvoiceProcessingStatus::Validated); @@ -281,6 +271,7 @@ private function validateInvoiceField( ): array { return match ($field) { 'invoice_number' => $this->validateInvoiceNumberField($invoice, $priority), + 'document_type' => $this->validateDocumentTypeField($invoice, $priority, $document), 'customer_number' => $this->validateCustomerNumberField( $invoice, $priority, @@ -330,6 +321,24 @@ private function validateInvoiceField( }; } + /** + * BT-3 against the type the uploader declared (ADR 0011 addendum): a parsed type the declaration could + * not replace, e.g. an invoice (380) uploaded as a corrected invoice, is a review case, never overwritten. + * + * @return array{status: string, reason?: string} + */ + private function validateDocumentTypeField(Invoice $invoice, string $priority, ?EbillingDocument $document): array + { + $generic = $this->validateGenericInvoiceField($invoice, 'document_type', $priority); + $declared = $document instanceof EbillingDocument ? DocumentClassification::declaredFor($document) : null; + + if ($declared !== null && ($generic['status'] ?? null) === 'parsed' && $declared !== $invoice->document_type) { + return ['status' => 'needs_review', 'reason' => 'declared_document_type_mismatch']; + } + + return $generic; + } + /** * BT-25: looks the referenced invoice up among stored documents (separators ignored). * Not finding it, or finding a different BT-26 date, is a warning (`reason`), never a blocking status: @@ -378,6 +387,18 @@ private function findPrecedingInvoice(Invoice $invoice, string $number): ?Invoic return null; } + // Narrow in SQL on the digit run, then compare separator-insensitively in PHP. + return $invoice->newQuery() + ->whereKeyNot($invoice->getKey()) + ->whereNotIn('document_type', FieldValidationProfile::profiledDocumentTypes()) + ->where('invoice_number', 'like', '%'.substr($digits, 0, 5).'%') + ->orderByDesc('is_current') + ->get() + ->first(fn (Invoice $candidate): bool => PrecedingInvoiceReferences::comparableNumber( + (string) $candidate->invoice_number, + ) === $comparable); + } + /** * @return array{status: string, source?: string, matched_id?: string, reason?: string} */ @@ -900,14 +921,7 @@ private function entryForEmptyField(string $field, string $priority, bool $isInv return ['status' => 'missing']; } - $key = $isInvoiceLine ? 'invoice_line_contextual_should' : 'invoice_contextual_should'; - $list = config("e-billing.field_validation.{$key}", []); - - if (! is_array($list)) { - return ['status' => 'not_applicable']; - } - - if (in_array($field, $list, true)) { + if (in_array($field, FieldValidationProfile::contextualShould($this->profileDocumentType, $isInvoiceLine), true)) { return ['status' => 'missing']; } diff --git a/packages/e-billing/src/Support/DocumentClassification.php b/packages/e-billing/src/Support/DocumentClassification.php new file mode 100644 index 000000000..7bc58d126 --- /dev/null +++ b/packages/e-billing/src/Support/DocumentClassification.php @@ -0,0 +1,204 @@ + bill_data amounts negated with the sign of the document type */ + private const DOCUMENT_AMOUNTS = [ + 'net_total', 'vat_amount', 'gross_total', 'discount_amount', + 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', + ]; + + /** @var list bill_data line amounts negated with the sign of the document type */ + private const LINE_AMOUNTS = ['quantity', 'line_total', 'surcharge_amount', 'material_test_certificate_price']; + + /** + * Configured classification types with the sign their amounts carry. + * + * @return array + */ + public static function signs(): array + { + $types = config('e-billing.document_classification.types', []); + + if (! is_array($types)) { + return []; + } + + $signs = []; + + foreach ($types as $type => $sign) { + if ($sign === 'positive' || $sign === 'negative') { + $signs[(string) $type] = $sign; + } + } + + return $signs; + } + + public static function isClassificationType(string $documentType): bool + { + return isset(self::signs()[$documentType]); + } + + public static function signsDiffer(string $from, string $to): bool + { + $signs = self::signs(); + + return isset($signs[$from], $signs[$to]) && $signs[$from] !== $signs[$to]; + } + + /** + * The types a duplicate check compares against: the whole classification family for a classification + * type (a credit note and a corrected invoice for the same source invoice are one document), else the type itself. + * + * @return list + */ + public static function familyOf(string $documentType): array + { + return self::isClassificationType($documentType) + ? array_map(strval(...), array_keys(self::signs())) + : [$documentType]; + } + + /** + * Codes an uploader may declare for a resource's manual upload. Each must be one of the resource's + * document types, a classification type and an allowed BT-3 code; anything else is a configuration error. + * + * @return list + */ + public static function selectableForUpload(string $resourceKey): array + { + $configured = config("e-billing.resources.{$resourceKey}.manual_upload.document_types", []); + + if (! is_array($configured) || $configured === []) { + return []; + } + + $resourceTypes = array_map(strval(...), (array) config("e-billing.resources.{$resourceKey}.document_types", [])); + $allowedCodes = array_map(strval(...), (array) config('e-billing.allowed_document_type_codes', [])); + $selectable = []; + + foreach ($configured as $code) { + $code = (string) $code; + + if (! in_array($code, $resourceTypes, true) + || ! self::isClassificationType($code) + || ! in_array($code, $allowedCodes, true)) { + throw new InvalidArgumentException( + "e-billing.resources.{$resourceKey}.manual_upload.document_types: code {$code} must be one of the " + .'resource\'s document_types, a document_classification type and an allowed_document_type_code.', + ); + } + + $selectable[] = $code; + } + + return array_values(array_unique($selectable)); + } + + /** + * The type the uploader declared for the document's source, if any. + */ + public static function declaredFor(EbillingDocument $document): ?string + { + $source = $document->source; + $declared = $source instanceof UploadedPdfSource ? $source->document_type : null; + + return is_string($declared) && $declared !== '' ? $declared : null; + } + + /** + * Applies a declared type to the parsed data. A parsed classification type is replaced (381 and 384 + * cannot be told apart by parsing) and the amounts follow the new sign; any other parsed type is kept + * and surfaces as a review finding on `document_type`. + */ + public static function applyDeclaredType(InvoiceDto $parsed, string $declaredType): InvoiceDto + { + $parsedType = $parsed->documentTypeCode; + + if ($parsedType === $declaredType || ! self::isClassificationType($parsedType)) { + return $parsed; + } + + $data = $parsed->toArray(); + $data['document_type_code'] = $declaredType; + $data['document_type'] = DocumentClassificationLabels::label($declaredType); + + if (self::signsDiffer($parsedType, $declaredType)) { + $data = self::negateBillData($data); + } + + return InvoiceDto::fromArray($data); + } + + /** + * Logs the classification as its own activity (never a value correction). Skipped without moox/audit. + */ + public static function recordActivity( + EbillingDocument $document, + ?string $from, + string $to, + bool $amountsNegated, + string $origin, + ): void { + if (! class_exists(MooxActivityLogger::class)) { + return; + } + + MooxActivityLogger::log('e-billing', self::ACTIVITY_EVENT, [ + 'event' => self::ACTIVITY_EVENT, + 'entry_type' => 'log', + 'subject' => $document, + 'properties' => [ + 'from' => $from, + 'to' => $to, + 'amounts_negated' => $amountsNegated, + 'origin' => $origin, + ], + ]); + } + + /** + * Sign flip on parsed data, before persistence. Keep in step with the flip on the persisted invoice, + * {@see ClassifyDocumentTypeAction::negateAmounts()}: both must cover every amount. + * + * @param array $data {@see InvoiceDto::toArray()} + * @return array + */ + private static function negateBillData(array $data): array + { + foreach (self::DOCUMENT_AMOUNTS as $key) { + if (is_numeric($data[$key] ?? null)) { + $data[$key] = -(float) $data[$key]; + } + } + + foreach ($data['lines'] ?? [] as $index => $line) { + foreach (self::LINE_AMOUNTS as $key) { + if (is_array($line) && is_numeric($line[$key] ?? null)) { + $data['lines'][$index][$key] = -(float) $line[$key]; + } + } + } + + return $data; + } +} diff --git a/packages/e-billing/src/Support/DocumentClassificationLabels.php b/packages/e-billing/src/Support/DocumentClassificationLabels.php new file mode 100644 index 000000000..c63ffc592 --- /dev/null +++ b/packages/e-billing/src/Support/DocumentClassificationLabels.php @@ -0,0 +1,82 @@ + document type code => label + */ + public static function options(): array + { + $options = []; + + foreach (array_keys(DocumentClassification::signs()) as $documentType) { + // Numeric type codes come back as int array keys. + $options[(string) $documentType] = self::label((string) $documentType); + } + + return $options; + } + + /** + * Content of the collapsible upload instruction, per code: label, rule and examples. Plain strings; + * the resource renders them with schema components. + * + * @param list $documentTypes + * @return list}> + */ + public static function instruction(array $documentTypes): array + { + $blocks = []; + + foreach ($documentTypes as $documentType) { + $rule = self::translated("e-billing::fields.document_classification.{$documentType}.rule"); + $examples = self::translated("e-billing::fields.document_classification.{$documentType}.examples"); + + $blocks[] = [ + 'label' => self::label($documentType), + 'rule' => is_string($rule) ? $rule : null, + 'examples' => is_array($examples) ? array_values(array_filter($examples, is_string(...))) : [], + ]; + } + + return $blocks; + } + + /** + * @return string|array|null + */ + private static function translated(string $key): string|array|null + { + $value = __($key); + + return $value === $key ? null : $value; + } +} diff --git a/packages/e-billing/src/Support/InvoiceFieldLabels.php b/packages/e-billing/src/Support/InvoiceFieldLabels.php index 6630412b7..b87d30eb0 100644 --- a/packages/e-billing/src/Support/InvoiceFieldLabels.php +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -249,6 +249,9 @@ public static function hint(string $field, string $status, ?array $validation = 'minimum_quantity_surcharge' => __('e-billing::fields.hint_review_minimum_quantity_surcharge'), 'freight_flat_rate' => __('e-billing::fields.hint_review_freight_flat_rate'), 'delivery_date' => __('e-billing::fields.hint_review_delivery_date'), + 'document_type' => ($validation['reason'] ?? null) === 'declared_document_type_mismatch' + ? __('e-billing::fields.hint_review_declared_document_type') + : __('e-billing::fields.hint_review_default'), 'invoice_number' => ($validation['reason'] ?? null) === 'duplicate_invoice_number' ? __('e-billing::fields.hint_review_duplicate_invoice_number') : __('e-billing::fields.hint_review_default'), diff --git a/packages/e-billing/src/Support/InvoiceNumberDuplicateChecker.php b/packages/e-billing/src/Support/InvoiceNumberDuplicateChecker.php index 0aedc9ffd..70baf82d2 100644 --- a/packages/e-billing/src/Support/InvoiceNumberDuplicateChecker.php +++ b/packages/e-billing/src/Support/InvoiceNumberDuplicateChecker.php @@ -30,7 +30,8 @@ public function findDuplicate(Invoice $invoice): ?Invoice } /** - * Other non-deleted invoices with the same number and document type. + * Other non-deleted invoices with the same number and document type. Classification types + * (e.g. 381 and 384) count as one type: they are the same document classified differently. * * @return Collection */ @@ -44,7 +45,7 @@ public function findDuplicates(Invoice $invoice): Collection $query = Invoice::query() ->where('invoice_number', $number) - ->where('document_type', $invoice->document_type); + ->whereIn('document_type', DocumentClassification::familyOf((string) $invoice->document_type)); $key = $invoice->getKey(); @@ -64,7 +65,7 @@ public function isDuplicate(Invoice $invoice): bool } /** - * Same number + type + source PDF hash as an already stored document. + * Same number + type (classification types as one family) + source PDF hash as an already stored document. * Both hashes must be non-empty; missing hashes never count as identical. * When scope is `issuer`, `$sellerVatId` narrows the match (blank buckets with blank). */ @@ -85,7 +86,7 @@ public function findIdenticalContentDuplicate( ->whereHas('invoice', function ($invoiceQuery) use ($invoiceNumber, $documentType): void { $invoiceQuery ->where('invoice_number', $invoiceNumber) - ->where('document_type', $documentType); + ->whereIn('document_type', DocumentClassification::familyOf($documentType)); }) ->with('invoice') ->orderBy('created_at') From 52b27626e5b3a150bc265d4fa86e6e198453fb06 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 11:20:47 +0200 Subject: [PATCH 09/12] fix(e-billing): map a PO box line to BT-50 instead of repeating the company --- packages/e-billing/CHANGELOG.md | 3 ++ packages/e-billing/src/Data/Address.php | 28 ++++++++++++++ .../e-billing/src/Services/InvoiceFactory.php | 38 +------------------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 876b1d5c5..4198812f3 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -14,6 +14,9 @@ - `e-billing.intake.scopes`: optional allowlist of mail-inbox Scope keys for `ProcessInboxAttachmentListener` (null/`[]` = all; non-listed PDFs marked Skipped, no `EbillingDocument`). - Optional `e-billing.delivery.from_name` / `EBILLING_DELIVERY_FROM_NAME` (display name only; From address is host-owned). +### Fixed +- Buyer/seller address mapping: without a street, the next address line (e.g. a PO box "Postfach 16 20") becomes BT-50 instead of repeating the company name; the company only fills BT-50 when no address line exists. Shared as `Data\Address::toEn16931Address()` for `ParsedInvoiceMapper` and `InvoiceFactory` (removed the unused `InvoiceAddress` import). + ### Changed - BG-32 BT-160 attribute names and CAE `reason_text` fallbacks follow `e-billing.document_locale` / `EBILLING_DOCUMENT_LOCALE` (package default `en`; lang keys in `e-billing::emission`). Independent of Filament UI locale. Already-emitted artifacts are not regenerated. Hosts that need German (or other) labels set the env/config override. diff --git a/packages/e-billing/src/Data/Address.php b/packages/e-billing/src/Data/Address.php index 6c57635ec..2d2fe64f9 100644 --- a/packages/e-billing/src/Data/Address.php +++ b/packages/e-billing/src/Data/Address.php @@ -52,6 +52,34 @@ public function toEn16931DeliveryParty(): ?Party return Party::deliveryConsignee($name, $address); } + /** + * Buyer/seller postal address (BG-5 / BG-8). The first printed line after the name is BT-50: the street, + * or — when there is none, e.g. a PO box — the next address line. The company only fills BT-50 when + * nothing else is there; it is the party name, not an address line. Null without a country. + */ + public function toEn16931Address(): ?En16931Address + { + $countryCode = $this->country !== null ? strtoupper(trim($this->country)) : ''; + if ($countryCode === '') { + return null; + } + + $lines = array_values(array_filter( + [self::trimmedNonEmpty($this->street), self::trimmedNonEmpty($this->addressLine2), self::trimmedNonEmpty($this->addressLine3)], + static fn (?string $line): bool => $line !== null, + )); + $line1 = array_shift($lines) ?? self::trimmedNonEmpty($this->company) ?? ''; + + return new En16931Address( + line1: $line1, + line2: $lines !== [] ? implode("\n", $lines) : null, + city: trim((string) ($this->city ?? '')), + postal_code: trim((string) ($this->zip ?? '')), + subdivision: null, + country_code: $countryCode, + ); + } + private static function joinedAddressLines(?string $line2, ?string $line3): ?string { $trimmedLine2 = self::trimmedNonEmpty($line2); diff --git a/packages/e-billing/src/Services/InvoiceFactory.php b/packages/e-billing/src/Services/InvoiceFactory.php index f1b43a149..274150083 100644 --- a/packages/e-billing/src/Services/InvoiceFactory.php +++ b/packages/e-billing/src/Services/InvoiceFactory.php @@ -19,7 +19,6 @@ use Moox\Invoice\Support\ChargeDraft; use Moox\Invoice\Support\En16931\BankAccount as En16931BankAccount; use Moox\Invoice\Support\En16931\PaymentMeans; -use Moox\Invoice\Support\InvoiceAddress; use Moox\Invoice\Support\InvoiceBuilder; use Moox\Invoice\Support\InvoiceContact; use Moox\Invoice\Support\InvoiceDraft; @@ -144,6 +143,7 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft ), headerCharges: $this->buildHeaderChargeDraftsFromDto($dto), notes: $dto->notes, + preceding_invoices: $dto->precedingInvoices, ); } @@ -306,7 +306,7 @@ private function mapInvoiceParty( return null; } - $invoiceAddress = $this->mapInvoiceAddress($address); + $invoiceAddress = $address?->toEn16931Address(); if ($invoiceAddress === null) { return null; } @@ -320,40 +320,6 @@ private function mapInvoiceParty( ); } - private function mapInvoiceAddress(?Address $address): ?InvoiceAddress - { - if ($address === null) { - return null; - } - - $countryCode = $address->country !== null ? strtoupper(trim($address->country)) : ''; - if ($countryCode === '') { - return null; - } - - $line1 = trim((string) ($address->street ?? '')); - if ($line1 === '' && $address->company !== null) { - $line1 = trim($address->company); - } - - $line2 = $address->addressLine2; - if ($address->addressLine3 !== null && trim($address->addressLine3) !== '') { - $line3 = trim($address->addressLine3); - $line2 = $line2 !== null && trim($line2) !== '' - ? trim($line2)."\n".$line3 - : $line3; - } - - return new InvoiceAddress( - line1: $line1, - line2: $line2 !== null && trim($line2) !== '' ? trim($line2) : null, - city: trim((string) ($address->city ?? '')), - postal_code: trim((string) ($address->zip ?? '')), - subdivision: null, - country_code: $countryCode, - ); - } - private function isNonZeroAmount(?float $amount): bool { return $amount !== null && (float) $amount !== 0.0; From bb523226d043e685e4c5ce16293e10e5ad74941b Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 12:14:43 +0200 Subject: [PATCH 10/12] fix codacy issues --- packages/e-billing/resources/lang/de/fields.php | 10 ++++++++-- packages/e-billing/resources/lang/en/fields.php | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php index 8c83f5b9d..d146f17f6 100644 --- a/packages/e-billing/resources/lang/de/fields.php +++ b/packages/e-billing/resources/lang/de/fields.php @@ -171,7 +171,10 @@ 'document_classification' => [ '381' => [ 'label' => 'Kaufmännische Gutschrift (381)', - 'hint' => 'Wählen, wenn die ursprüngliche Rechnung korrekt war und sich der geschuldete Betrag danach aus einem eigenständigen Grund geändert hat – z. B. Bonus, Rabatt oder Rücksendung. Beträge werden positiv ausgewiesen. Nicht gemeint ist die Gutschrift, die der Leistungsempfänger selbst ausstellt (§ 14 Abs. 2 Satz 2 UStG).', + 'hint' => 'Wählen, wenn die ursprüngliche Rechnung korrekt war und sich der geschuldete Betrag danach ' + .'aus einem eigenständigen Grund geändert hat – z. B. Bonus, Rabatt oder Rücksendung. ' + .'Beträge werden positiv ausgewiesen. Nicht gemeint ist die Gutschrift, die der ' + .'Leistungsempfänger selbst ausstellt (§ 14 Abs. 2 Satz 2 UStG).', 'rule' => 'Die ursprüngliche Rechnung war korrekt. Der geschuldete Betrag hat sich danach aus einem eigenständigen kaufmännischen Grund geändert. Ein Bezug auf die Ursprungsrechnung ist nicht nötig.', 'examples' => [ 'Jahresrückvergütung oder Bonus für ein erreichtes Umsatzvolumen', @@ -181,7 +184,10 @@ ], '384' => [ 'label' => 'Rechnungskorrektur (384)', - 'hint' => 'Wählen, wenn eine bereits gestellte Rechnung fehlerhaft war oder storniert wird – z. B. falsche Menge, falscher Preis oder vollständiger Storno. Die ursprüngliche Rechnung muss mit Nummer und Datum angegeben sein (§ 31 Abs. 5 UStDV). Wird die Korrektur als Gutschrift des Differenzbetrags ausgestellt, sind die Beträge negativ.', + 'hint' => 'Wählen, wenn eine bereits gestellte Rechnung fehlerhaft war oder storniert wird – ' + .'z. B. falsche Menge, falscher Preis oder vollständiger Storno. Die ursprüngliche ' + .'Rechnung muss mit Nummer und Datum angegeben sein (§ 31 Abs. 5 UStDV). Wird die ' + .'Korrektur als Gutschrift des Differenzbetrags ausgestellt, sind die Beträge negativ.', 'rule' => 'Die ursprüngliche Rechnung war fehlerhaft oder wird storniert. Die Ursprungsrechnung muss mit Nummer und Datum angegeben sein.', 'examples' => [ 'Falsche Menge oder falscher Preis wurde berechnet', diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php index 78be91985..844c7d6c0 100644 --- a/packages/e-billing/resources/lang/en/fields.php +++ b/packages/e-billing/resources/lang/en/fields.php @@ -171,7 +171,10 @@ 'document_classification' => [ '381' => [ 'label' => 'Credit note (381)', - 'hint' => 'Choose when the original invoice was correct and the amount owed changed afterwards for an independent reason – for example a bonus, a discount or a return. Amounts are stated as positive values. Not meant: self-billing, where the buyer issues the document.', + 'hint' => 'Choose when the original invoice was correct and the amount owed changed afterwards ' + .'for an independent reason – for example a bonus, a discount or a return. ' + .'Amounts are stated as positive values. Not meant: self-billing, where the buyer ' + .'issues the document.', 'rule' => 'The original invoice was correct. The amount owed changed afterwards for an independent commercial reason. No reference to the original invoice is needed.', 'examples' => [ 'Year-end rebate or bonus for a reached sales volume', @@ -181,7 +184,10 @@ ], '384' => [ 'label' => 'Corrected invoice (384)', - 'hint' => 'Choose when an invoice already issued was wrong or is cancelled – for example a wrong quantity, a wrong price or a full cancellation. The original invoice must be referenced by number and date. When the correction is issued as a credit of the difference, the amounts are negative.', + 'hint' => 'Choose when an invoice already issued was wrong or is cancelled – for example a ' + .'wrong quantity, a wrong price or a full cancellation. The original invoice must ' + .'be referenced by number and date. When the correction is issued as a credit of ' + .'the difference, the amounts are negative.', 'rule' => 'The original invoice was wrong or is cancelled. The original invoice must be referenced by number and date.', 'examples' => [ 'A wrong quantity or price was invoiced', From 5c57e917a4e0bff77b96933ff35eb8384e179208 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Tue, 29 Sep 2026 13:08:10 +0200 Subject: [PATCH 11/12] fix sonarcloud issues --- .../e-billing/resources/lang/de/fields.php | 19 +++++++++++++------ .../e-billing/resources/lang/en/fields.php | 18 ++++++++++++------ .../partials/invoice-field-row.blade.php | 5 ++++- packages/e-billing/src/Data/Address.php | 9 ++++++--- .../e-billing/src/Models/EbillingDocument.php | 12 +++++++++--- .../src/Services/InvoiceFieldValidator.php | 10 +++++++--- .../src/Support/DocumentClassification.php | 5 ++++- .../src/Support/InvoiceFieldLabels.php | 4 +++- .../src/ViewModels/InvoiceLineViewModel.php | 4 +++- .../src/ViewModels/InvoiceViewModel.php | 14 +++++++++++--- 10 files changed, 72 insertions(+), 28 deletions(-) diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php index d146f17f6..6f34433e0 100644 --- a/packages/e-billing/resources/lang/de/fields.php +++ b/packages/e-billing/resources/lang/de/fields.php @@ -175,7 +175,9 @@ .'aus einem eigenständigen Grund geändert hat – z. B. Bonus, Rabatt oder Rücksendung. ' .'Beträge werden positiv ausgewiesen. Nicht gemeint ist die Gutschrift, die der ' .'Leistungsempfänger selbst ausstellt (§ 14 Abs. 2 Satz 2 UStG).', - 'rule' => 'Die ursprüngliche Rechnung war korrekt. Der geschuldete Betrag hat sich danach aus einem eigenständigen kaufmännischen Grund geändert. Ein Bezug auf die Ursprungsrechnung ist nicht nötig.', + 'rule' => 'Die ursprüngliche Rechnung war korrekt. Der geschuldete Betrag hat sich danach ' + .'aus einem eigenständigen kaufmännischen Grund geändert. ' + .'Ein Bezug auf die Ursprungsrechnung ist nicht nötig.', 'examples' => [ 'Jahresrückvergütung oder Bonus für ein erreichtes Umsatzvolumen', 'Nachträglicher Rabatt, Preisnachlass oder Skonto', @@ -188,7 +190,8 @@ .'z. B. falsche Menge, falscher Preis oder vollständiger Storno. Die ursprüngliche ' .'Rechnung muss mit Nummer und Datum angegeben sein (§ 31 Abs. 5 UStDV). Wird die ' .'Korrektur als Gutschrift des Differenzbetrags ausgestellt, sind die Beträge negativ.', - 'rule' => 'Die ursprüngliche Rechnung war fehlerhaft oder wird storniert. Die Ursprungsrechnung muss mit Nummer und Datum angegeben sein.', + 'rule' => 'Die ursprüngliche Rechnung war fehlerhaft oder wird storniert. ' + .'Die Ursprungsrechnung muss mit Nummer und Datum angegeben sein.', 'examples' => [ 'Falsche Menge oder falscher Preis wurde berechnet', 'Falsche oder fehlende Pflichtangabe auf der Rechnung', @@ -199,7 +202,8 @@ 'document_classification_help' => [ 'title' => 'Welchen Belegtyp wähle ich?', 'placeholder' => 'Belegtyp wählen', - 'helper' => 'Pflichtangabe. Entscheidend ist, ob die ursprüngliche Rechnung fehlerhaft war – nicht, ob das Dokument „Gutschrift“ heißt.', + 'helper' => 'Pflichtangabe. Entscheidend ist, ob die ursprüngliche Rechnung fehlerhaft war – ' + .'nicht, ob das Dokument „Gutschrift“ heißt.', ], // Filters @@ -335,13 +339,16 @@ 'hint_review_delivery_date' => 'Mehrere Lieferdaten erfüllen die Regel zur tatsächlichen Lieferung ' .'bei innergemeinschaftlicher Lieferung nicht, ohne sie zu einem Zeitraum zusammenzuziehen.', 'hint_review_duplicate_invoice_number' => 'Diese Belegnummer existiert bereits und muss geprüft werden.', - 'hint_review_declared_document_type' => 'Beim Hochladen wurde ein anderer Belegtyp gewählt, als das Dokument trägt – bitte prüfen.', + 'hint_review_declared_document_type' => 'Beim Hochladen wurde ein anderer Belegtyp gewählt, ' + .'als das Dokument trägt – bitte prüfen.', 'hint_review_default' => 'Dieses Feld sollte manuell überprüft werden.', // Field hints — informational (no validation status) 'hint_info_buyer_email' => 'Aus Posteingang (An) — nicht mit Stammdaten abgeglichen.', - 'hint_warning_preceding_invoice_not_found' => 'Bezugsrechnung nicht im System gefunden — bitte prüfen (blockiert nicht).', - 'hint_warning_preceding_invoice_date_mismatch' => 'Datum weicht von der gespeicherten Bezugsrechnung ab — bitte prüfen (blockiert nicht).', + 'hint_warning_preceding_invoice_not_found' => 'Bezugsrechnung nicht im System gefunden — bitte prüfen ' + .'(blockiert nicht).', + 'hint_warning_preceding_invoice_date_mismatch' => 'Datum weicht von der gespeicherten Bezugsrechnung ab — ' + .'bitte prüfen (blockiert nicht).', 'section_kosit_validations' => 'KoSIT-Validierungen', 'kosit_validations_empty' => 'Noch keine KoSIT-Validierungen.', diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php index 844c7d6c0..d91e3ac31 100644 --- a/packages/e-billing/resources/lang/en/fields.php +++ b/packages/e-billing/resources/lang/en/fields.php @@ -175,7 +175,8 @@ .'for an independent reason – for example a bonus, a discount or a return. ' .'Amounts are stated as positive values. Not meant: self-billing, where the buyer ' .'issues the document.', - 'rule' => 'The original invoice was correct. The amount owed changed afterwards for an independent commercial reason. No reference to the original invoice is needed.', + 'rule' => 'The original invoice was correct. The amount owed changed afterwards ' + .'for an independent commercial reason. No reference to the original invoice is needed.', 'examples' => [ 'Year-end rebate or bonus for a reached sales volume', 'Subsequent discount, price reduction or cash discount', @@ -188,7 +189,8 @@ .'wrong quantity, a wrong price or a full cancellation. The original invoice must ' .'be referenced by number and date. When the correction is issued as a credit of ' .'the difference, the amounts are negative.', - 'rule' => 'The original invoice was wrong or is cancelled. The original invoice must be referenced by number and date.', + 'rule' => 'The original invoice was wrong or is cancelled. ' + .'The original invoice must be referenced by number and date.', 'examples' => [ 'A wrong quantity or price was invoiced', 'A mandatory invoice detail was wrong or missing', @@ -199,7 +201,8 @@ 'document_classification_help' => [ 'title' => 'Which document type do I choose?', 'placeholder' => 'Choose a document type', - 'helper' => 'Required. What matters is whether the original invoice was wrong – not whether the document is called a credit note.', + 'helper' => 'Required. What matters is whether the original invoice was wrong – ' + .'not whether the document is called a credit note.', ], // Filters @@ -335,13 +338,16 @@ 'hint_review_delivery_date' => 'Several delivery dates cannot satisfy the intra-community ' .'actual delivery date rule without aggregating them into a period.', 'hint_review_duplicate_invoice_number' => 'This document number already exists and needs review.', - 'hint_review_declared_document_type' => 'A different document type was chosen at upload than the document carries – please check.', + 'hint_review_declared_document_type' => 'A different document type was chosen at upload than the document carries ' + .'– please check.', 'hint_review_default' => 'This field should be reviewed manually.', // Field hints — informational (no validation status) 'hint_info_buyer_email' => 'From inbox To — not checked against master data.', - 'hint_warning_preceding_invoice_not_found' => 'Preceding invoice not found in the system — please check (does not block).', - 'hint_warning_preceding_invoice_date_mismatch' => 'Date differs from the stored preceding invoice — please check (does not block).', + 'hint_warning_preceding_invoice_not_found' => 'Preceding invoice not found in the system — please check ' + .'(does not block).', + 'hint_warning_preceding_invoice_date_mismatch' => 'Date differs from the stored preceding invoice — ' + .'please check (does not block).', 'section_kosit_validations' => 'KoSIT validations', 'kosit_validations_empty' => 'No KoSIT validations yet.', diff --git a/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php index 23b6cf1a6..5bb6d2066 100644 --- a/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php +++ b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php @@ -36,7 +36,10 @@ class="mt-0.5 break-words text-sm whitespace-pre-line text-gray-800 dark:text-gr
{{ $field->value }}
@elseif($field->url !== null && $field->value !== null && $field->value !== '') - {{ $field->value }} + {{ $field->value }} @elseif($field->value !== null && $field->value !== '') {{ $field->value }} @else diff --git a/packages/e-billing/src/Data/Address.php b/packages/e-billing/src/Data/Address.php index 2d2fe64f9..1bc80c698 100644 --- a/packages/e-billing/src/Data/Address.php +++ b/packages/e-billing/src/Data/Address.php @@ -18,8 +18,7 @@ public function __construct( public ?string $country = null, public ?string $addressLine2 = null, public ?string $addressLine3 = null, - ) { - } + ) {} public function equals(?self $other): bool { @@ -65,7 +64,11 @@ public function toEn16931Address(): ?En16931Address } $lines = array_values(array_filter( - [self::trimmedNonEmpty($this->street), self::trimmedNonEmpty($this->addressLine2), self::trimmedNonEmpty($this->addressLine3)], + [ + self::trimmedNonEmpty($this->street), + self::trimmedNonEmpty($this->addressLine2), + self::trimmedNonEmpty($this->addressLine3), + ], static fn (?string $line): bool => $line !== null, )); $line1 = array_shift($lines) ?? self::trimmedNonEmpty($this->company) ?? ''; diff --git a/packages/e-billing/src/Models/EbillingDocument.php b/packages/e-billing/src/Models/EbillingDocument.php index d51549a75..cca0f48a0 100644 --- a/packages/e-billing/src/Models/EbillingDocument.php +++ b/packages/e-billing/src/Models/EbillingDocument.php @@ -976,8 +976,11 @@ public static function hasBlockingMustFieldFindings(?array $fieldValidations, ?s * @param list $statuses * @return list */ - private static function mustFieldsWithStatuses(?array $fieldValidations, array $statuses, ?string $documentType): array - { + private static function mustFieldsWithStatuses( + ?array $fieldValidations, + array $statuses, + ?string $documentType, + ): array { [$invoiceFields, $lineFields] = self::configuredPriorityMaps($documentType); $validations = is_array($fieldValidations) ? $fieldValidations : []; $matched = self::collectMustFieldsMatching($invoiceFields, $validations, $statuses); @@ -1135,7 +1138,10 @@ private static function applyScopeConfiguredFieldBlocksReview(Builder $query): v $query->where(function (Builder $byType) use ($ownPriorityTypes): void { foreach ($ownPriorityTypes as $type) { $byType->orWhere(function (Builder $ofType) use ($type): void { - $ofType->whereHas('invoice', fn (Builder $invoice): Builder => $invoice->where('document_type', $type)) + $ofType->whereHas( + 'invoice', + fn (Builder $invoice): Builder => $invoice->where('document_type', $type), + ) ->where(fn (Builder $inner) => self::applyScopeProfileFieldBlocksReview($inner, $type)); }); } diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index a4e11f566..9134100dd 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -775,6 +775,8 @@ private function validateGenericInvoiceField(Invoice $invoice, string $field, st private function getInvoiceFieldValue(Invoice $invoice, string $field): mixed { + $precedingReference = PrecedingInvoiceReferences::first($invoice->preceding_invoices); + return match ($field) { 'customer_name' => $invoice->buyer?->name, 'customer_vat_id' => $invoice->buyer?->vat_id, @@ -791,8 +793,8 @@ private function getInvoiceFieldValue(Invoice $invoice, string $field): mixed 'payment_means' => $invoice->payment_means?->payment_means_code, 'vat_category' => $invoice->vat_category, 'delivery_address' => $invoice->delivery, - 'preceding_invoice_number' => PrecedingInvoiceReferences::first($invoice->preceding_invoices)['number'] ?? null, - 'preceding_invoice_date' => PrecedingInvoiceReferences::first($invoice->preceding_invoices)['date'] ?? null, + 'preceding_invoice_number' => $precedingReference['number'] ?? null, + 'preceding_invoice_date' => $precedingReference['date'] ?? null, default => $invoice->getAttribute($field), }; } @@ -921,7 +923,9 @@ private function entryForEmptyField(string $field, string $priority, bool $isInv return ['status' => 'missing']; } - if (in_array($field, FieldValidationProfile::contextualShould($this->profileDocumentType, $isInvoiceLine), true)) { + $contextualShould = FieldValidationProfile::contextualShould($this->profileDocumentType, $isInvoiceLine); + + if (in_array($field, $contextualShould, true)) { return ['status' => 'missing']; } diff --git a/packages/e-billing/src/Support/DocumentClassification.php b/packages/e-billing/src/Support/DocumentClassification.php index 7bc58d126..864feebe9 100644 --- a/packages/e-billing/src/Support/DocumentClassification.php +++ b/packages/e-billing/src/Support/DocumentClassification.php @@ -92,7 +92,10 @@ public static function selectableForUpload(string $resourceKey): array return []; } - $resourceTypes = array_map(strval(...), (array) config("e-billing.resources.{$resourceKey}.document_types", [])); + $resourceTypes = array_map( + strval(...), + (array) config("e-billing.resources.{$resourceKey}.document_types", []), + ); $allowedCodes = array_map(strval(...), (array) config('e-billing.allowed_document_type_codes', [])); $selectable = []; diff --git a/packages/e-billing/src/Support/InvoiceFieldLabels.php b/packages/e-billing/src/Support/InvoiceFieldLabels.php index b87d30eb0..e66fb0938 100644 --- a/packages/e-billing/src/Support/InvoiceFieldLabels.php +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -268,7 +268,9 @@ public static function hint(string $field, string $status, ?array $validation = if ($field === 'preceding_invoice_number') { return match ($validation['reason'] ?? null) { 'preceding_invoice_not_found' => __('e-billing::fields.hint_warning_preceding_invoice_not_found'), - 'preceding_invoice_date_mismatch' => __('e-billing::fields.hint_warning_preceding_invoice_date_mismatch'), + 'preceding_invoice_date_mismatch' => __( + 'e-billing::fields.hint_warning_preceding_invoice_date_mismatch', + ), default => null, }; } diff --git a/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php index 0c8e08bd0..3f77868de 100644 --- a/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php @@ -140,8 +140,10 @@ private function emptyLineFieldValidation(string $field): array return ['status' => 'missing']; } + $contextualShould = FieldValidationProfile::contextualShould($this->documentType, forLines: true); + return [ - 'status' => in_array($field, FieldValidationProfile::contextualShould($this->documentType, forLines: true), true) + 'status' => in_array($field, $contextualShould, true) ? 'missing' : 'not_applicable', ]; diff --git a/packages/e-billing/src/ViewModels/InvoiceViewModel.php b/packages/e-billing/src/ViewModels/InvoiceViewModel.php index 89492e29c..35e54a52c 100644 --- a/packages/e-billing/src/ViewModels/InvoiceViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceViewModel.php @@ -352,7 +352,13 @@ public function formatValue(string $field): mixed return number_format((float) $value, 2, ',', '.').' %'; } - if (in_array($field, ['invoice_date', 'due_date', 'order_date', 'delivery_date', 'preceding_invoice_date'], true) + if (in_array($field, [ + 'invoice_date', + 'due_date', + 'order_date', + 'delivery_date', + 'preceding_invoice_date', + ], true) && is_string($value) && $value !== '') { try { return Carbon::parse($value)->format('d.m.Y'); @@ -370,6 +376,8 @@ private function resolveFieldValue(string $field): mixed return HeaderChargeResolver::resolveAmount($this->invoice->allowanceCharges, $field); } + $precedingReference = PrecedingInvoiceReferences::first($this->invoice->preceding_invoices); + return match ($field) { 'buyer_email' => $this->document?->inboxToEmail(), 'customer_name' => $this->invoice->buyer?->name, @@ -388,8 +396,8 @@ private function resolveFieldValue(string $field): mixed 'vat_category' => $this->invoice->vat_category, // Keep empty when no distinct consignee (ADR 0020 / 0014). 'delivery_address' => PartyAddressFormatter::format($this->invoice->delivery), - 'preceding_invoice_number' => PrecedingInvoiceReferences::first($this->invoice->preceding_invoices)['number'] ?? null, - 'preceding_invoice_date' => PrecedingInvoiceReferences::first($this->invoice->preceding_invoices)['date'] ?? null, + 'preceding_invoice_number' => $precedingReference['number'] ?? null, + 'preceding_invoice_date' => $precedingReference['date'] ?? null, default => $this->invoice->getAttribute($field), }; } From 2e6334f4f9f72293d84ba5e3ad2fee6e9887819f Mon Sep 17 00:00:00 2001 From: jbagsik <234342240+jbagsik@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:09:49 +0000 Subject: [PATCH 12/12] Fix styling --- packages/e-billing/src/Data/Address.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e-billing/src/Data/Address.php b/packages/e-billing/src/Data/Address.php index 1bc80c698..19a76c307 100644 --- a/packages/e-billing/src/Data/Address.php +++ b/packages/e-billing/src/Data/Address.php @@ -18,7 +18,8 @@ public function __construct( public ?string $country = null, public ?string $addressLine2 = null, public ?string $addressLine3 = null, - ) {} + ) { + } public function equals(?self $other): bool {