From 9ca21e0cd6fc9a1aeee34effb42379c1c1a5bc61 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Wed, 5 Aug 2026 13:10:10 +0200 Subject: [PATCH 1/6] feat(invoice): persist EN 16931 BT-46 as invoices.customer_number --- packages/e-billing/config/e-billing.php | 4 ++-- .../src/Adapters/ZugferdInvoiceAdapter.php | 4 +++- packages/e-billing/src/Services/InvoiceFactory.php | 1 + .../src/Services/InvoiceFieldValidator.php | 14 ++++++++------ .../e-billing/src/Services/ParsedInvoiceMapper.php | 1 + .../e-billing/src/Support/InvoiceFieldLabels.php | 1 + .../e-billing/src/ViewModels/InvoiceViewModel.php | 1 - packages/invoice/README.md | 4 +++- .../invoice/database/factories/InvoiceFactory.php | 1 + .../migrations/create_invoices_table.php.stub | 1 + packages/invoice/src/Models/Invoice.php | 1 + packages/invoice/src/Support/InvoiceBuilder.php | 1 + packages/invoice/src/Support/InvoiceDraft.php | 1 + 13 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/e-billing/config/e-billing.php b/packages/e-billing/config/e-billing.php index 1da0764071..f4e5221bd5 100644 --- a/packages/e-billing/config/e-billing.php +++ b/packages/e-billing/config/e-billing.php @@ -316,14 +316,14 @@ 'currency' => 'must', // BT-5 // Buyer — MUST (core identification) - 'customer_number' => 'must', + '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 // Buyer reference - 'customer_reference' => 'should', // BT-10 + 'customer_reference' => 'could', // BT-10 'order_number' => 'should', // BT-13 'order_date' => 'could', // BT-13 date diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php index 3dbab42dc0..462a31836f 100644 --- a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php @@ -48,7 +48,9 @@ public function __construct( } public string $customerNumber { - get => ''; + get => $this->model->customer_number !== null + ? (string) $this->model->customer_number + : ''; } public ?string $customerReference { diff --git a/packages/e-billing/src/Services/InvoiceFactory.php b/packages/e-billing/src/Services/InvoiceFactory.php index 75b3f854f4..ffa5325384 100644 --- a/packages/e-billing/src/Services/InvoiceFactory.php +++ b/packages/e-billing/src/Services/InvoiceFactory.php @@ -104,6 +104,7 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft document_type: $documentType, due_date: $dto->dueDate, currency: $dto->currency, + customer_number: $dto->customerNumber !== '' ? $dto->customerNumber : null, customer_reference: $dto->customerReference, order_number: $dto->orderNumber, order_date: $dto->orderDate, diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index 4912966ec4..e49647a29e 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -23,7 +23,6 @@ class InvoiceFieldValidator * @var list */ private const INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE = [ - 'customer_number', // matchable when company identifier field lands later 'payment_terms', 'shipping_method', ]; @@ -223,7 +222,7 @@ private function validateInvoiceField( ?Company $matchedCompany, ): array { return match ($field) { - 'customer_number' => $this->validateCustomerNumberField($priority), + 'customer_number' => $this->validateCustomerNumberField($invoice, $priority), 'customer_name' => $this->validateCustomerNameField($invoice, $priority, $matchedCompany), 'customer_vat_id' => $this->validateCustomerVatField($invoice, $priority, $matchedCompany), 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', @@ -233,13 +232,16 @@ private function validateInvoiceField( } /** - * customer_number has no persisted source until a company identifier field exists on the invoice. - * * @return array{status: string, source?: string, matched_id?: string} */ - private function validateCustomerNumberField(string $priority): array + private function validateCustomerNumberField(Invoice $invoice, string $priority): array { - return $this->entryForEmptyField('customer_number', $priority, false); + $raw = $invoice->customer_number; + if ($this->isScalarEmpty($raw)) { + return $this->entryForEmptyField('customer_number', $priority, false); + } + + return ['status' => 'parsed']; } /** diff --git a/packages/e-billing/src/Services/ParsedInvoiceMapper.php b/packages/e-billing/src/Services/ParsedInvoiceMapper.php index 410d9bbba1..e86fb0c467 100644 --- a/packages/e-billing/src/Services/ParsedInvoiceMapper.php +++ b/packages/e-billing/src/Services/ParsedInvoiceMapper.php @@ -120,6 +120,7 @@ private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft document_type: $documentType, due_date: $dto->dueDate, currency: $dto->currency, + customer_number: $dto->customerNumber !== '' ? $dto->customerNumber : 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 f00e524134..aaf4890a82 100644 --- a/packages/e-billing/src/Support/InvoiceFieldLabels.php +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -134,6 +134,7 @@ public static function btNumber(string $field, ?string $context = null): ?string 'currency' => 'BT-5', 'due_date' => 'BT-9', 'customer_reference' => 'BT-10', + 'customer_number' => 'BT-46', 'order_number' => 'BT-13', 'payment_terms' => 'BT-20', 'supplier_name' => 'BT-27', diff --git a/packages/e-billing/src/ViewModels/InvoiceViewModel.php b/packages/e-billing/src/ViewModels/InvoiceViewModel.php index 81267f3f47..71ff632cd1 100644 --- a/packages/e-billing/src/ViewModels/InvoiceViewModel.php +++ b/packages/e-billing/src/ViewModels/InvoiceViewModel.php @@ -20,7 +20,6 @@ final class InvoiceViewModel * @var list */ private const FIELDS_WITHOUT_PERSISTED_SOURCE = [ - 'customer_number', 'payment_terms', 'shipping_method', ]; diff --git a/packages/invoice/README.md b/packages/invoice/README.md index 0862a7bd62..6f1e9e86de 100644 --- a/packages/invoice/README.md +++ b/packages/invoice/README.md @@ -57,6 +57,7 @@ $draft = new InvoiceDraft( document_type: '380', due_date: '2026-06-15', currency: 'EUR', + customer_number: null, customer_reference: null, order_number: null, order_date: null, @@ -179,7 +180,8 @@ The `Invoice` model (`Moox\Invoice\Models\Invoice`) stores the invoice header. I - `document_type` (string) - EN 16931 document type code (e.g. `380` for invoice) - `due_date` (string, nullable) - Payment due date - `currency` (string, default: `EUR`) - ISO 4217 currency code -- `customer_reference` (string, nullable) - Buyer reference +- `customer_number` (string, nullable, indexed) - Buyer identifier assigned by the seller (EN 16931 **BT-46**); 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 - `pricing_basis` (string, nullable) - Incoterms / pricing basis (serialized as note in e-billing / ZUGFeRD layer) diff --git a/packages/invoice/database/factories/InvoiceFactory.php b/packages/invoice/database/factories/InvoiceFactory.php index c75c4380aa..4ba0fd218a 100644 --- a/packages/invoice/database/factories/InvoiceFactory.php +++ b/packages/invoice/database/factories/InvoiceFactory.php @@ -28,6 +28,7 @@ public function definition(): array 'document_type' => '380', 'due_date' => fake()->optional()->date('Y-m-d'), 'currency' => 'EUR', + 'customer_number' => fake()->optional()->bothify('000####'), '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/create_invoices_table.php.stub b/packages/invoice/database/migrations/create_invoices_table.php.stub index bfda88244e..98a9ff307f 100644 --- a/packages/invoice/database/migrations/create_invoices_table.php.stub +++ b/packages/invoice/database/migrations/create_invoices_table.php.stub @@ -17,6 +17,7 @@ return new class extends Migration $table->string('document_type'); $table->string('due_date')->nullable(); $table->string('currency')->default('EUR'); + $table->string('customer_number')->nullable()->index(); $table->string('customer_reference')->nullable(); $table->string('order_number')->nullable(); $table->string('order_date')->nullable(); diff --git a/packages/invoice/src/Models/Invoice.php b/packages/invoice/src/Models/Invoice.php index 8f0a7f6ce9..3c8eece3d7 100644 --- a/packages/invoice/src/Models/Invoice.php +++ b/packages/invoice/src/Models/Invoice.php @@ -43,6 +43,7 @@ class Invoice extends BaseItemModel 'document_type', 'due_date', 'currency', + 'customer_number', 'customer_reference', 'order_number', 'order_date', diff --git a/packages/invoice/src/Support/InvoiceBuilder.php b/packages/invoice/src/Support/InvoiceBuilder.php index e5ff6fc81e..1688bc8a08 100644 --- a/packages/invoice/src/Support/InvoiceBuilder.php +++ b/packages/invoice/src/Support/InvoiceBuilder.php @@ -35,6 +35,7 @@ private function persist(InvoiceDraft $draft): Invoice $invoice->document_type = $draft->document_type; $invoice->due_date = $draft->due_date; $invoice->currency = $draft->currency; + $invoice->customer_number = $draft->customer_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 36966f5d99..dda5ce8d85 100644 --- a/packages/invoice/src/Support/InvoiceDraft.php +++ b/packages/invoice/src/Support/InvoiceDraft.php @@ -20,6 +20,7 @@ public function __construct( public string $document_type, public ?string $due_date, public string $currency, + public ?string $customer_number, public ?string $customer_reference, public ?string $order_number, public ?string $order_date, From b2a278a64603bf0e212ea894a69102b3c6ab6540 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Wed, 5 Aug 2026 16:50:23 +0200 Subject: [PATCH 2/6] refactor(e-billing): unify company name matching in CompanyNameMatcher --- packages/e-billing/CHANGELOG.md | 2 ++ packages/e-billing/CONTEXT.md | 1 + .../src/Services/InvoiceFieldValidator.php | 20 ++---------- .../src/Support/CompanyNameMatcher.php | 32 +++++++++++++++++++ .../src/Support/EBillingFormatResolver.php | 30 +++-------------- 5 files changed, 41 insertions(+), 44 deletions(-) create mode 100644 packages/e-billing/src/Support/CompanyNameMatcher.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 197c8e8b0d..9dadcf02ef 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Buyer identifier (`customer_number`, EN 16931 BT-46) now flows from the parser DTO onto the persisted invoice via `ParsedInvoiceMapper` / `InvoiceFactory`. Empty DTO values become `null`; non-empty values are stored unchanged. The field is validated from the invoice like any other field (present ⇒ `parsed`, absent ⇒ configured MoSCoW priority) and is no longer listed under `INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE` ([#23](https://github.com/mooxphp/e-billing/issues/23)). - Three-format registry: XRechnung (pure CII XML), ZUGFeRD (hybrid PDF), Factur-X (hybrid PDF). All share one CII generator; XRechnung uses `XRECHNUNG` profile, hybrids use `EN16931`. - Per-customer format resolution via `EbillingFormatResolver`: reads `companies.data.preferred_ebilling_format`, falls back to `default_format` config (default `zugferd`). Format is frozen on the document at generation time; preference changes affect only future documents. - XRechnung documents are validated by KOSIT only (no PDF, veraPDF not invoked) and reach `Validated` on pass. @@ -11,6 +12,7 @@ ### Changed +- Company name matching for field validation and format resolution is unified in `CompanyNameMatcher`; both consumers match against the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact match, unique hit only). No longer filters on removed `company_type` / `is_active` columns. `EBillingFormatResolver` no longer reads `bill_data['customer_name']` ([#22](https://github.com/mooxphp/e-billing/issues/22)). - Reduced cyclomatic/NPath complexity in `ValidateArtifactJob::handle` (named stages: resolve document/inputs, run validations, persist success/failure), `UnitCodeResolver::lookupMaps`, and `DocumentTypeCodeResolver::resolveLabel`. Behaviour unchanged. - Deduplicated KOSIT/veraPDF validation persistence in `ValidateArtifactJob` via `ArtifactValidationPersister`; supplemental verdicts (veraPDF) stay as closures so the shared seam does not type-hint optional validator packages. No behaviour change. - Replaced `GenerateXmlJob` / `ValidateXmlJob` / `MergeZugferdPdfJob` with `GenerateArtifactJob` → `ValidateArtifactJob` (generate-first pipeline). diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 9fb106294b..472ea89b87 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -24,6 +24,7 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Scope now:** the three **CII-based** formats are live. UBL and Peppol are deferred future strategies. - **Validator stays single.** KOSIT is the only XML validator. XRechnung = KOSIT only. Hybrids = KOSIT + veraPDF. - **Format binding.** ✅ `EbillingFormatResolver` reads `companies.data.preferred_ebilling_format` → falls back to `default_format` config (default `zugferd`). Format frozen on `ebilling_documents.format` at generation time (freeze = `xml_storage_path` is set). Preference changes affect future documents only. +- **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution. Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. - **Validation stack (validate the real artifact).** XML conformance → **KOSIT** for *every* format (pure XRechnung XML, or the XML **extracted from the hybrid PDF** via horstoeko `ZugferdDocumentPdfReader`). PDF/A-3 conformance → **veraPDF** (licensed MPL-2.0; commercial-safe as a CLI process). A hybrid passes iff **KOSIT(xml) AND veraPDF(pdf)**; a pure XRechnung passes iff **KOSIT(xml)**. - **`moox/verapdf` = own package** (mirrors `kosit-validator`/`zugferd` boundary): config + `verapdf:install` command + `VeraPdfService::validate()` + persisted `VeraPdfValidation` model + morph pivot. Generic, no e-billing knowledge; e-billing orchestrates KOSIT+veraPDF. Note: veraPDF ships as an **installer zip** (headless IzPack install → `verapdf` launcher script), *not* a single `java -jar` standalone like KOSIT — the install command is heavier. - **Failure handling.** Artifact is generated before validation; on failure it is **retained on disk + flagged** (never auto-deleted), validator reports persisted (KositValidation + VeraPdfValidation), `gateway_status` → failed, surfaced in "needs review". **Delivery is validation-gated** — an invalid artifact is never sent even though it exists. diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index e49647a29e..8ecc05a5e0 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -8,6 +8,7 @@ use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Events\InvoiceValidationCompleted; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Support\CompanyNameMatcher; use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\LineAllowanceChargeResolver; use Moox\Invoice\Models\Invoice; @@ -145,19 +146,7 @@ private function resolveInvoice(EbillingDocument $document): Invoice private function resolveCompanyMatch(Invoice $invoice): ?Company { - $name = $this->normalizeNameForCompanyMatch((string) ($invoice->buyer?->name ?? '')); - - if ($name === '') { - return null; - } - - $matches = Company::query() - ->where('company_type', 'customer') - ->where('is_active', true) - ->whereRaw('LOWER(TRIM(name)) = ?', [$name]) - ->get(); - - return $matches->count() === 1 ? $matches->first() : null; + return (new CompanyNameMatcher)->match($invoice->buyer?->name); } /** @@ -524,11 +513,6 @@ private function stringsLooselyMatch(mixed $a, mixed $b): bool return strcasecmp($left, $right) === 0; } - private function normalizeNameForCompanyMatch(string $value): string - { - return mb_strtolower($this->normalizeString($value)); - } - private function normalizeString(?string $value): string { if ($value === null) { diff --git a/packages/e-billing/src/Support/CompanyNameMatcher.php b/packages/e-billing/src/Support/CompanyNameMatcher.php new file mode 100644 index 0000000000..ada7aeefe0 --- /dev/null +++ b/packages/e-billing/src/Support/CompanyNameMatcher.php @@ -0,0 +1,32 @@ +normalizeName($name ?? ''); + + if ($normalized === '') { + return null; + } + + $matches = Company::query() + ->whereRaw('LOWER(TRIM(name)) = ?', [$normalized]) + ->get(); + + return $matches->count() === 1 ? $matches->first() : null; + } + + private function normalizeName(string $value): string + { + $trimmed = trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + + return mb_strtolower($trimmed); + } +} diff --git a/packages/e-billing/src/Support/EBillingFormatResolver.php b/packages/e-billing/src/Support/EBillingFormatResolver.php index e114f8a87b..90abb54ed2 100644 --- a/packages/e-billing/src/Support/EBillingFormatResolver.php +++ b/packages/e-billing/src/Support/EBillingFormatResolver.php @@ -8,7 +8,6 @@ use Moox\Company\Models\Company; use Moox\EBilling\Formats\FormatRegistry; use Moox\EBilling\Models\EbillingDocument; -use Moox\EBilling\Services\InvoiceFieldValidator; final class EBillingFormatResolver { @@ -53,7 +52,7 @@ private function isFrozen(EbillingDocument $document): bool private function preferredFormatFromCompany(EbillingDocument $document): ?string { - $company = $document->company ?? $this->matchCompanyFromBillData($document); + $company = $document->company ?? $this->matchCompanyFromInvoice($document); if ($company === null) { return null; @@ -65,31 +64,10 @@ private function preferredFormatFromCompany(EbillingDocument $document): ?string return is_string($preferred) && $preferred !== '' ? $preferred : null; } - /** - * Same loose name-match as {@see InvoiceFieldValidator::resolveCompanyMatch()}. - */ - private function matchCompanyFromBillData(EbillingDocument $document): ?Company + private function matchCompanyFromInvoice(EbillingDocument $document): ?Company { - $billData = $document->bill_data; - - if (! is_array($billData)) { - return null; - } - - $name = trim((string) ($billData['customer_name'] ?? '')); - - if ($name === '') { - return null; - } - - $normalised = mb_strtolower(trim($name)); - - $matches = Company::query() - ->where('company_type', 'customer') - ->where('is_active', true) - ->whereRaw('LOWER(TRIM(name)) = ?', [$normalised]) - ->get(); + $document->loadMissing('invoice'); - return $matches->count() === 1 ? $matches->first() : null; + return (new CompanyNameMatcher)->match($document->invoice?->buyer?->name); } } From d0b5d8abe4baaed019c7419354c246ad1492b384 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Thu, 6 Aug 2026 09:47:18 +0200 Subject: [PATCH 3/6] feat(e-billing): attribute documents to Customer via buyer identifier (#24) --- packages/e-billing/CHANGELOG.md | 2 + packages/e-billing/CONTEXT.md | 12 +++- packages/e-billing/README.md | 9 ++- packages/e-billing/composer.json | 1 + .../create_ebilling_documents_table.php.stub | 1 + .../e-billing/src/Models/EbillingDocument.php | 13 +++- .../src/Services/InvoiceFieldValidator.php | 60 +++++++++++++++--- .../e-billing/src/Support/CustomerMatcher.php | 62 +++++++++++++++++++ 8 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 packages/e-billing/src/Support/CustomerMatcher.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 9dadcf02ef..2a53849588 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Customer attribution on `EbillingDocument`: nullable `customer_id` FK → `customers` (`nullOnDelete`), `customer()` BelongsTo, and internal `Support\CustomerMatcher` (normalises buyer identifier; looks up `Customer::withTrashed()` by `customer_number`; derives `company_id` from exactly one Company morph assignment). Package now requires `moox/customer` ([#24](https://github.com/mooxphp/e-billing/issues/24)). - Buyer identifier (`customer_number`, EN 16931 BT-46) now flows from the parser DTO onto the persisted invoice via `ParsedInvoiceMapper` / `InvoiceFactory`. Empty DTO values become `null`; non-empty values are stored unchanged. The field is validated from the invoice like any other field (present ⇒ `parsed`, absent ⇒ configured MoSCoW priority) and is no longer listed under `INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE` ([#23](https://github.com/mooxphp/e-billing/issues/23)). - Three-format registry: XRechnung (pure CII XML), ZUGFeRD (hybrid PDF), Factur-X (hybrid PDF). All share one CII generator; XRechnung uses `XRECHNUNG` profile, hybrids use `EN16931`. - Per-customer format resolution via `EbillingFormatResolver`: reads `companies.data.preferred_ebilling_format`, falls back to `default_format` config (default `zugferd`). Format is frozen on the document at generation time; preference changes affect only future documents. @@ -12,6 +13,7 @@ ### Changed +- `InvoiceFieldValidator` attributes documents by buyer identifier first: unique `CustomerMatcher` hit sets `customer_id` and derived `company_id` (`db_validated` when active; soft-deleted/inactive still attributed with `customer_number` = `needs_review`). No match or missing identifier leaves `customer_id` null; name fallback via `CompanyNameMatcher` may set `company_id` only. `customer_id` is the identity / visibility gate; `company_id` is reporting-only. Name/VAT/address corroboration is not included ([#24](https://github.com/mooxphp/e-billing/issues/24)). - Company name matching for field validation and format resolution is unified in `CompanyNameMatcher`; both consumers match against the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact match, unique hit only). No longer filters on removed `company_type` / `is_active` columns. `EBillingFormatResolver` no longer reads `bill_data['customer_name']` ([#22](https://github.com/mooxphp/e-billing/issues/22)). - Reduced cyclomatic/NPath complexity in `ValidateArtifactJob::handle` (named stages: resolve document/inputs, run validations, persist success/failure), `UnitCodeResolver::lookupMaps`, and `DocumentTypeCodeResolver::resolveLabel`. Behaviour unchanged. - Deduplicated KOSIT/veraPDF validation persistence in `ValidateArtifactJob` via `ArtifactValidationPersister`; supplemental verdicts (veraPDF) stay as closures so the shared seam does not type-hint optional validator packages. No behaviour change. diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 472ea89b87..20c6797b5d 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -10,6 +10,15 @@ Glossary for the generic e-billing conversion pipeline (`packages/e-billing`). K - **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. +### Review and correction + +- **Value correction** — a reviewer restoring what the **document actually says** after the bound parser mis-read it. The document is the truth; the correction records field, originally parsed value, corrected value, actor and time, and the originally parsed value is never destroyed. It **never** aligns a parsed value to master data: a parsed value is persisted unchanged precisely so a divergence from master data stays visible, and that divergence is the evidence. Permitted only before approval. *Avoid:* edit, fix, override (that is the severity release); "correction" in the accounting sense — there is no cancellation flow. +- **Master-data divergence** — this field on the document and the matched master-data record disagree, or the master record is empty. The queryable, per-field form of a corroboration result — a secondary check of a match that may flag it for review but can never overturn it (ADR 0003). Arises **automatically for every document**, changes nothing, and is evidence that master data is stale, not that the parser erred. **Derived, never stored**: both sides already persist, so it is a query — and it disappears by itself once the master data is fixed. Never the same record as a *Value correction*; they differ in producer, volume, consumer and lifetime. *Avoid:* correction, error, mismatch (reserved for matching). +- **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. +- **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. + +Reports over these — the parser-feedback report and the master-data divergence report — live in this package rather than in a reporting package or an integrator's private one. See ADR `docs/adr/0002-reports-live-in-the-package-that-owns-their-data.md`. + ## Current pipeline (generate-first, dual validation for hybrids) `GenerateArtifactJob` (build chosen artifact — for ZUGFeRD: decrypt input PDF, merge unencrypted PDF/A-3 with embedded XML) → `ValidateArtifactJob` (KOSIT on the XML that will be delivered; + veraPDF on the PDF for hybrids when installed; SHA-256 hash on pass). When veraPDF is not installed, hybrid validation runs KOSIT-only (degraded mode). See ADR `docs/adr/0001-generate-then-validate-per-format-artifacts.md`. @@ -24,7 +33,8 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Scope now:** the three **CII-based** formats are live. UBL and Peppol are deferred future strategies. - **Validator stays single.** KOSIT is the only XML validator. XRechnung = KOSIT only. Hybrids = KOSIT + veraPDF. - **Format binding.** ✅ `EbillingFormatResolver` reads `companies.data.preferred_ebilling_format` → falls back to `default_format` config (default `zugferd`). Format frozen on `ebilling_documents.format` at generation time (freeze = `xml_storage_path` is set). Preference changes affect future documents only. -- **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution. Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. +- **Document identity = Customer.** ✅ `EbillingDocument.customer_id` is the matched buyer (identity / visibility gate), set via buyer identifier through internal `CustomerMatcher`. `company_id` is derived from the customer's Company assignment when unique, or from `CompanyNameMatcher` name fallback when there is no identifier match — reporting only, never an access boundary. Name/VAT/address corroboration is out of scope for this change ([#24](https://github.com/mooxphp/e-billing/issues/24)). +- **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution (and the no-identifier / no-customer-match company fallback). Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. - **Validation stack (validate the real artifact).** XML conformance → **KOSIT** for *every* format (pure XRechnung XML, or the XML **extracted from the hybrid PDF** via horstoeko `ZugferdDocumentPdfReader`). PDF/A-3 conformance → **veraPDF** (licensed MPL-2.0; commercial-safe as a CLI process). A hybrid passes iff **KOSIT(xml) AND veraPDF(pdf)**; a pure XRechnung passes iff **KOSIT(xml)**. - **`moox/verapdf` = own package** (mirrors `kosit-validator`/`zugferd` boundary): config + `verapdf:install` command + `VeraPdfService::validate()` + persisted `VeraPdfValidation` model + morph pivot. Generic, no e-billing knowledge; e-billing orchestrates KOSIT+veraPDF. Note: veraPDF ships as an **installer zip** (headless IzPack install → `verapdf` launcher script), *not* a single `java -jar` standalone like KOSIT — the install command is heavier. - **Failure handling.** Artifact is generated before validation; on failure it is **retained on disk + flagged** (never auto-deleted), validator reports persisted (KositValidation + VeraPdfValidation), `gateway_status` → failed, surfaced in "needs review". **Delivery is validation-gated** — an invalid artifact is never sent even though it exists. diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index 9228c107a2..cde5053fdc 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -45,8 +45,9 @@ This package composes the other Moox e-billing packages. Composer requires: | Package | Role | | --- | --- | -| `moox/company` | Company FK on `EbillingDocument` | +| `moox/company` | Company FK on `EbillingDocument` (reporting-only; derived from customer) | | `moox/core` | Base model, Filament resource, Moox installer | +| `moox/customer` | Customer FK on `EbillingDocument` (document identity / visibility gate) | | `moox/invoice` | Invoice domain models (`Invoice`, lines, parties) | | `moox/jobs` | Job progress traits | | `moox/kosit-validator` | KoSIT XML validation and audit persistence | @@ -159,7 +160,8 @@ Queries `EbillingDocument` rows where `field_validations` is not null and `valid | `error_message` | `text` | nullable | Last pipeline error | | `created_at` | `timestamp` | NOT NULL | | | `updated_at` | `timestamp` | NOT NULL | | -| `company_id` | `uuid` FK | nullable | References `companies.id` (`nullOnDelete`) | +| `company_id` | `uuid` FK | nullable | References `companies.id` (`nullOnDelete`). Reporting only — derived from the matched customer; never an access boundary | +| `customer_id` | `uuid` FK | nullable | References `customers.id` (`nullOnDelete`). Document identity (matched customer); gate visibility on this | | `invoice_id` | `uuid` FK | nullable | References `invoices.id` (`nullOnDelete`) | | `scope` | `string` | nullable | Tenant / mailbox scope (indexed) | @@ -167,7 +169,8 @@ Queries `EbillingDocument` rows where `field_validations` is not null and `valid - `source()` — `MorphTo` (typically `InboxAttachment`) - `invoice()` — `BelongsTo` `Moox\Invoice\Models\Invoice` -- `company()` — `BelongsTo` `Moox\Company\Models\Company` +- `customer()` — `BelongsTo` `Moox\Customer\Models\Customer` +- `company()` — `BelongsTo` `Moox\Company\Models\Company` (reporting only) - `kositValidations()` — `MorphToMany` via `kosit_validatables` - `veraPdfValidations()` — `MorphToMany` via `verapdf_validatables` (hybrid formats when veraPDF is configured) diff --git a/packages/e-billing/composer.json b/packages/e-billing/composer.json index f4deff288e..09a2e0161e 100644 --- a/packages/e-billing/composer.json +++ b/packages/e-billing/composer.json @@ -25,6 +25,7 @@ "php": "^8.4", "moox/company": "dev-main", "moox/core": "dev-main", + "moox/customer": "dev-main", "moox/data": "dev-main", "moox/invoice": "dev-main", "moox/jobs": "dev-main", diff --git a/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub index 7452d593ec..2b69b98347 100644 --- a/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub +++ b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub @@ -29,6 +29,7 @@ return new class extends Migration $table->timestamps(); $table->foreignUuid('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignUuid('customer_id')->nullable()->index()->constrained('customers')->nullOnDelete(); $table->foreignUuid('invoice_id')->nullable()->constrained('invoices')->nullOnDelete(); $table->string('scope')->nullable()->index(); diff --git a/packages/e-billing/src/Models/EbillingDocument.php b/packages/e-billing/src/Models/EbillingDocument.php index fad983e0d4..72f83a7e38 100644 --- a/packages/e-billing/src/Models/EbillingDocument.php +++ b/packages/e-billing/src/Models/EbillingDocument.php @@ -17,6 +17,7 @@ use Moox\Company\Models\Company; use Moox\Core\Entities\Items\Item\BaseItemModel; use Moox\Core\Traits\MorphPivot\HasMorphPivotRelations; +use Moox\Customer\Models\Customer; use Moox\EBilling\Enums\EBillingAttachmentProcessingStatus; use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Formats\ArtifactKind; @@ -43,7 +44,8 @@ * @property InvoiceProcessingStatus|null $review_status * @property array|null $field_validations * @property string|null $invoice_id - * @property string|null $company_id + * @property string|null $customer_id Identity of the document (matched customer). Gate visibility on this, resolved live. + * @property string|null $company_id Reporting only — derived from the matched customer; never an access boundary. * @property int|null $validation_score * @property string|null $scope */ @@ -86,6 +88,7 @@ class EbillingDocument extends BaseItemModel 'error_message', 'invoice_id', 'company_id', + 'customer_id', 'scope', ]; @@ -240,6 +243,14 @@ public function company(): BelongsTo return $this->belongsTo(Company::class, 'company_id'); } + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + /** * Line-level field validations are stored under {@see $field_validations}['lines'] keyed by line id. * diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index 8ecc05a5e0..810201025b 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -5,10 +5,12 @@ namespace Moox\EBilling\Services; use Moox\Company\Models\Company; +use Moox\Customer\Models\Customer; use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Events\InvoiceValidationCompleted; use Moox\EBilling\Models\EbillingDocument; use Moox\EBilling\Support\CompanyNameMatcher; +use Moox\EBilling\Support\CustomerMatcher; use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\LineAllowanceChargeResolver; use Moox\Invoice\Models\Invoice; @@ -30,7 +32,8 @@ class InvoiceFieldValidator /** * Populate field_validations on the document (invoice-level + lines sub-structure), - * match buyer name to {@see Company}, and set company_id when unambiguous. + * attribute the document to a {@see Customer} via buyer identifier when present, + * and set company_id by derivation (or name fallback when no identifier match). * * Does NOT change review_status or fire events. */ @@ -52,7 +55,23 @@ public function fillFieldValidations(EbillingDocument $document): void $invoice->loadMissing(['allowanceCharges', 'lines.allowanceCharges']); - $matchedCompany = $this->resolveCompanyMatch($invoice); + $hasIdentifier = ! $this->isScalarEmpty($invoice->customer_number); + $matchedCustomer = $hasIdentifier + ? $this->resolveCustomerMatch($invoice) + : null; + + $derivedCompanyId = null; + $matchedCompany = null; + if ($matchedCustomer !== null) { + $derivedCompanyId = (new CustomerMatcher)->resolveCompanyId($matchedCustomer); + if ($derivedCompanyId !== null) { + $matchedCompany = Company::query()->find($derivedCompanyId); + } + } else { + // Name fallback when no identifier, or identifier present but unmatched (#21). + $matchedCompany = $this->resolveCompanyMatch($invoice); + $derivedCompanyId = $matchedCompany?->id; + } $invoiceValidations = []; foreach ($invoiceFields as $field => $priority) { @@ -64,6 +83,7 @@ public function fillFieldValidations(EbillingDocument $document): void $field, $priority, $matchedCompany, + $matchedCustomer, ); } @@ -75,7 +95,8 @@ public function fillFieldValidations(EbillingDocument $document): void $invoiceValidations['lines'] = $lineValidations; $document->field_validations = $invoiceValidations; - $document->company_id = $matchedCompany?->id; + $document->customer_id = $matchedCustomer?->id; + $document->company_id = $derivedCompanyId; $document->validation_score = $document->calculateValidationScore(); $document->save(); } @@ -149,6 +170,13 @@ private function resolveCompanyMatch(Invoice $invoice): ?Company return (new CompanyNameMatcher)->match($invoice->buyer?->name); } + private function resolveCustomerMatch(Invoice $invoice): ?Customer + { + return (new CustomerMatcher)->match( + is_string($invoice->customer_number) ? $invoice->customer_number : null, + ); + } + /** * @param array $invoiceFields * @param array $lineFields @@ -209,9 +237,14 @@ private function validateInvoiceField( string $field, string $priority, ?Company $matchedCompany, + ?Customer $matchedCustomer = null, ): array { return match ($field) { - 'customer_number' => $this->validateCustomerNumberField($invoice, $priority), + 'customer_number' => $this->validateCustomerNumberField( + $invoice, + $priority, + $matchedCustomer, + ), 'customer_name' => $this->validateCustomerNameField($invoice, $priority, $matchedCompany), 'customer_vat_id' => $this->validateCustomerVatField($invoice, $priority, $matchedCompany), 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', @@ -223,14 +256,27 @@ private function validateInvoiceField( /** * @return array{status: string, source?: string, matched_id?: string} */ - private function validateCustomerNumberField(Invoice $invoice, string $priority): array - { + private function validateCustomerNumberField( + Invoice $invoice, + string $priority, + ?Customer $matchedCustomer, + ): array { $raw = $invoice->customer_number; if ($this->isScalarEmpty($raw)) { return $this->entryForEmptyField('customer_number', $priority, false); } - return ['status' => 'parsed']; + if ($matchedCustomer === null) { + return ['status' => 'needs_review']; + } + + return [ + 'status' => (new CustomerMatcher)->isReviewableMatch($matchedCustomer) + ? 'needs_review' + : 'db_validated', + 'source' => 'auto', + 'matched_id' => (string) $matchedCustomer->id, + ]; } /** diff --git a/packages/e-billing/src/Support/CustomerMatcher.php b/packages/e-billing/src/Support/CustomerMatcher.php new file mode 100644 index 0000000000..dac6c0eb2c --- /dev/null +++ b/packages/e-billing/src/Support/CustomerMatcher.php @@ -0,0 +1,62 @@ +normalizeIdentifier($identifier ?? ''); + + if ($normalized === '') { + return null; + } + + $matches = Customer::query() + ->withTrashed() + ->whereRaw('UPPER(TRIM(customer_number)) = ?', [$normalized]) + ->get(); + + return $matches->count() === 1 ? $matches->first() : null; + } + + /** + * Derive company id from the customer: exactly one company assignment, else null. + */ + public function resolveCompanyId(Customer $customer): ?string + { + $companyMorph = (new Company)->getMorphClass(); + + $companyIds = CustomerAssignment::query() + ->where('customer_id', $customer->getKey()) + ->where('assignable_type', $companyMorph) + ->limit(2) + ->pluck('assignable_id'); + + return $companyIds->count() === 1 ? (string) $companyIds->first() : null; + } + + public function isReviewableMatch(Customer $customer): bool + { + return $customer->trashed() || $customer->is_active === false; + } + + private function normalizeIdentifier(string $value): string + { + $trimmed = trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + + return mb_strtoupper($trimmed); + } +} From 79d3f745f4be79617e5834e51b5dd4bb86f5a593 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Thu, 6 Aug 2026 13:46:22 +0200 Subject: [PATCH 4/6] feat(e-billing): corroborate attributed Customer against buyer master data (#25) --- packages/e-billing/CHANGELOG.md | 5 +- packages/e-billing/CONTEXT.md | 4 +- packages/e-billing/README.md | 2 + packages/e-billing/composer.json | 1 + packages/e-billing/config/e-billing.php | 62 ++++ .../src/Services/InvoiceFieldValidator.php | 170 +++++++++- .../src/Support/AttributionCorroborator.php | 296 ++++++++++++++++++ .../e-billing/src/Support/CustomerMatcher.php | 12 +- 8 files changed, 537 insertions(+), 15 deletions(-) create mode 100644 packages/e-billing/src/Support/AttributionCorroborator.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index 2a53849588..e9fccca7ab 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Attribution corroboration via internal `Support\AttributionCorroborator`: after a customer match, name (significant token overlap ≥ `corroboration.name_min_token_length`, legal-form stop words ignored), VAT, country, and buyer address (existence among company billing addresses via `AddressFingerprint` and `corroboration.address_roles`) are checked against master data. Divergences only flag `needs_review`; corroboration never clears or rewrites attribution. Package now requires `moox/address`. Config keys under `e-billing.corroboration` ([#25](https://github.com/mooxphp/e-billing/issues/25)). - Customer attribution on `EbillingDocument`: nullable `customer_id` FK → `customers` (`nullOnDelete`), `customer()` BelongsTo, and internal `Support\CustomerMatcher` (normalises buyer identifier; looks up `Customer::withTrashed()` by `customer_number`; derives `company_id` from exactly one Company morph assignment). Package now requires `moox/customer` ([#24](https://github.com/mooxphp/e-billing/issues/24)). - Buyer identifier (`customer_number`, EN 16931 BT-46) now flows from the parser DTO onto the persisted invoice via `ParsedInvoiceMapper` / `InvoiceFactory`. Empty DTO values become `null`; non-empty values are stored unchanged. The field is validated from the invoice like any other field (present ⇒ `parsed`, absent ⇒ configured MoSCoW priority) and is no longer listed under `INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE` ([#23](https://github.com/mooxphp/e-billing/issues/23)). - Three-format registry: XRechnung (pure CII XML), ZUGFeRD (hybrid PDF), Factur-X (hybrid PDF). All share one CII generator; XRechnung uses `XRECHNUNG` profile, hybrids use `EN16931`. @@ -13,7 +14,9 @@ ### Changed -- `InvoiceFieldValidator` attributes documents by buyer identifier first: unique `CustomerMatcher` hit sets `customer_id` and derived `company_id` (`db_validated` when active; soft-deleted/inactive still attributed with `customer_number` = `needs_review`). No match or missing identifier leaves `customer_id` null; name fallback via `CompanyNameMatcher` may set `company_id` only. `customer_id` is the identity / visibility gate; `company_id` is reporting-only. Name/VAT/address corroboration is not included ([#24](https://github.com/mooxphp/e-billing/issues/24)). +- `InvoiceFieldValidator` wires attribution corroboration when a customer was matched: name/VAT/country/address field statuses reflect master-data agreement or `needs_review` on divergence; VAT and country compare only when both sides are present; address/country corroboration runs only after a customer match. Name-fallback (no customer match) keeps exact company name/VAT behaviour. Fields without a persisted source (`payment_terms`, `shipping_method`) return `not_applicable`. `parsed` is included in clean validation statuses so auto-Validated remains reachable ([#25](https://github.com/mooxphp/e-billing/issues/25)). +- `CustomerMatcher::isReviewableMatch` also returns true (⇒ `needs_review` on `customer_number`) when the derived `company_id` is null (no company or multi-company assignment). Soft-deleted/inactive customers remain reviewable; attribution is still kept ([#25](https://github.com/mooxphp/e-billing/issues/25)). +- `InvoiceFieldValidator` attributes documents by buyer identifier first: unique `CustomerMatcher` hit sets `customer_id` and derived `company_id` (`db_validated` when active; soft-deleted/inactive still attributed with `customer_number` = `needs_review`). No match or missing identifier leaves `customer_id` null; name fallback via `CompanyNameMatcher` may set `company_id` only. `customer_id` is the identity / visibility gate; `company_id` is reporting-only. Name/VAT/address corroboration followed in [#25](https://github.com/mooxphp/e-billing/issues/25) ([#24](https://github.com/mooxphp/e-billing/issues/24)). - Company name matching for field validation and format resolution is unified in `CompanyNameMatcher`; both consumers match against the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact match, unique hit only). No longer filters on removed `company_type` / `is_active` columns. `EBillingFormatResolver` no longer reads `bill_data['customer_name']` ([#22](https://github.com/mooxphp/e-billing/issues/22)). - Reduced cyclomatic/NPath complexity in `ValidateArtifactJob::handle` (named stages: resolve document/inputs, run validations, persist success/failure), `UnitCodeResolver::lookupMaps`, and `DocumentTypeCodeResolver::resolveLabel`. Behaviour unchanged. - Deduplicated KOSIT/veraPDF validation persistence in `ValidateArtifactJob` via `ArtifactValidationPersister`; supplemental verdicts (veraPDF) stay as closures so the shared seam does not type-hint optional validator packages. No behaviour change. diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 20c6797b5d..0bc4f4e2a9 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -33,8 +33,8 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Scope now:** the three **CII-based** formats are live. UBL and Peppol are deferred future strategies. - **Validator stays single.** KOSIT is the only XML validator. XRechnung = KOSIT only. Hybrids = KOSIT + veraPDF. - **Format binding.** ✅ `EbillingFormatResolver` reads `companies.data.preferred_ebilling_format` → falls back to `default_format` config (default `zugferd`). Format frozen on `ebilling_documents.format` at generation time (freeze = `xml_storage_path` is set). Preference changes affect future documents only. -- **Document identity = Customer.** ✅ `EbillingDocument.customer_id` is the matched buyer (identity / visibility gate), set via buyer identifier through internal `CustomerMatcher`. `company_id` is derived from the customer's Company assignment when unique, or from `CompanyNameMatcher` name fallback when there is no identifier match — reporting only, never an access boundary. Name/VAT/address corroboration is out of scope for this change ([#24](https://github.com/mooxphp/e-billing/issues/24)). -- **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution (and the no-identifier / no-customer-match company fallback). Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. +- **Document identity = Customer.** ✅ `EbillingDocument.customer_id` is the matched buyer (identity / visibility gate), set via buyer identifier through internal `CustomerMatcher`. `company_id` is derived from the customer's Company assignment when unique, or from `CompanyNameMatcher` name fallback when there is no identifier match — reporting only, never an access boundary. Visibility must require `customer_id` **and** gateway `Validated` (do not expose on attribution alone). Soft-deleted/inactive customers, or a null derived company id (no / multi-company), still attribute but mark `customer_number` as `needs_review`. Attribution (#24) then name/VAT/country/address corroboration (#25) are both in scope and done: after a customer match, `AttributionCorroborator` checks significant name-token overlap, VAT/country only when both sides are present, and buyer-address existence among company billing addresses (`AddressFingerprint`); divergences only flag `needs_review` and never clear attribution ([#24](https://github.com/mooxphp/e-billing/issues/24), [#25](https://github.com/mooxphp/e-billing/issues/25)). +- **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution (and the no-identifier / no-customer-match company fallback). Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. After a customer match, name corroboration uses token overlap (not exact equality); the exact-match path remains for the no-customer-match name fallback. - **Validation stack (validate the real artifact).** XML conformance → **KOSIT** for *every* format (pure XRechnung XML, or the XML **extracted from the hybrid PDF** via horstoeko `ZugferdDocumentPdfReader`). PDF/A-3 conformance → **veraPDF** (licensed MPL-2.0; commercial-safe as a CLI process). A hybrid passes iff **KOSIT(xml) AND veraPDF(pdf)**; a pure XRechnung passes iff **KOSIT(xml)**. - **`moox/verapdf` = own package** (mirrors `kosit-validator`/`zugferd` boundary): config + `verapdf:install` command + `VeraPdfService::validate()` + persisted `VeraPdfValidation` model + morph pivot. Generic, no e-billing knowledge; e-billing orchestrates KOSIT+veraPDF. Note: veraPDF ships as an **installer zip** (headless IzPack install → `verapdf` launcher script), *not* a single `java -jar` standalone like KOSIT — the install command is heavier. - **Failure handling.** Artifact is generated before validation; on failure it is **retained on disk + flagged** (never auto-deleted), validator reports persisted (KositValidation + VeraPdfValidation), `gateway_status` → failed, surfaced in "needs review". **Delivery is validation-gated** — an invalid artifact is never sent even though it exists. diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index cde5053fdc..88796a880e 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -45,6 +45,7 @@ This package composes the other Moox e-billing packages. Composer requires: | Package | Role | | --- | --- | +| `moox/address` | Address fingerprints / company billing addresses for attribution corroboration | | `moox/company` | Company FK on `EbillingDocument` (reporting-only; derived from customer) | | `moox/core` | Base model, Filament resource, Moox installer | | `moox/customer` | Customer FK on `EbillingDocument` (document identity / visibility gate) | @@ -84,6 +85,7 @@ Published as `config/e-billing.php`. | `foreign_invoice` | Foreign-invoice handling (`ignored_folder_name`) | | `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`, `address_roles` | | `field_validation` | MoSCoW priority rules for invoice and line fields | | `morph_relations` | Morph pivot config for KoSIT and veraPDF validations (`kosit_validatables`, `verapdf_validatables`) | diff --git a/packages/e-billing/composer.json b/packages/e-billing/composer.json index 09a2e0161e..14693e4091 100644 --- a/packages/e-billing/composer.json +++ b/packages/e-billing/composer.json @@ -26,6 +26,7 @@ "moox/company": "dev-main", "moox/core": "dev-main", "moox/customer": "dev-main", + "moox/address": "dev-main", "moox/data": "dev-main", "moox/invoice": "dev-main", "moox/jobs": "dev-main", diff --git a/packages/e-billing/config/e-billing.php b/packages/e-billing/config/e-billing.php index f4e5221bd5..28f7fa6409 100644 --- a/packages/e-billing/config/e-billing.php +++ b/packages/e-billing/config/e-billing.php @@ -285,6 +285,68 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Attribution corroboration + |-------------------------------------------------------------------------- + | + | After a customer is attributed via buyer identifier, name / VAT / country / + | address are checked against master data. Divergences only flag needs_review; + | they never clear or rewrite the customer attribution. + | + */ + + 'corroboration' => [ + + /* + | Minimum length for a name token to count as significant (after + | lowercase + diacritic fold + punctuation strip). + */ + 'name_min_token_length' => 4, + + /* + | Legal-form words ignored during name token corroboration. + */ + 'name_legal_form_stop_words' => [ + 'gmbh', + 'ag', + 'kg', + 'ohg', + 'ug', + 'se', + 'eg', + 'ev', + 'ltd', + 'limited', + 'inc', + 'incorporated', + 'corp', + 'corporation', + 'co', + 'plc', + 'llc', + 'llp', + 'sarl', + 'sa', + 'bv', + 'nv', + 'ab', + 'oy', + 'as', + 'spa', + 'srl', + 'sas', + ], + + /* + | Address-assignment pivot flags used when checking that the parsed + | buyer address exists among the matched company's known addresses. + */ + 'address_roles' => [ + 'billing_address', + ], + ], + /* |-------------------------------------------------------------------------- | Field validation (MoSCoW) diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index 810201025b..475d639caf 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -9,10 +9,12 @@ use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Events\InvoiceValidationCompleted; use Moox\EBilling\Models\EbillingDocument; +use Moox\EBilling\Support\AttributionCorroborator; use Moox\EBilling\Support\CompanyNameMatcher; use Moox\EBilling\Support\CustomerMatcher; use Moox\EBilling\Support\HeaderChargeResolver; use Moox\EBilling\Support\LineAllowanceChargeResolver; +use Moox\EBilling\Support\VatIdNormalizer; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Models\InvoiceLine; use Moox\Invoice\Support\En16931\Address; @@ -84,6 +86,7 @@ public function fillFieldValidations(EbillingDocument $document): void $priority, $matchedCompany, $matchedCustomer, + $derivedCompanyId, ); } @@ -186,7 +189,10 @@ private function allMustAndShouldFieldsAreClean( array $invoiceFields, array $lineFields, ): bool { - $cleanStatuses = ['validated', 'db_validated', 'not_applicable']; + // `parsed` is clean: present on the document without a master-data check. + // Without it, must/should fields that only ever become `parsed` block + // automatic progression to Validated forever (#21 / #25). + $cleanStatuses = ['validated', 'db_validated', 'not_applicable', 'parsed']; $validations = is_array($document->field_validations) ? $document->field_validations : []; @@ -238,15 +244,39 @@ private function validateInvoiceField( string $priority, ?Company $matchedCompany, ?Customer $matchedCustomer = null, + ?string $derivedCompanyId = null, ): array { return match ($field) { 'customer_number' => $this->validateCustomerNumberField( $invoice, $priority, $matchedCustomer, + $derivedCompanyId, + ), + 'customer_name' => $this->validateCustomerNameField( + $invoice, + $priority, + $matchedCompany, + $matchedCustomer, + ), + 'customer_vat_id' => $this->validateCustomerVatField( + $invoice, + $priority, + $matchedCompany, + $matchedCustomer, + ), + 'country' => $this->validateCountryField( + $invoice, + $priority, + $matchedCompany, + $matchedCustomer, + ), + 'customer_address' => $this->validateCustomerAddressField( + $invoice, + $priority, + $matchedCompany, + $matchedCustomer, ), - 'customer_name' => $this->validateCustomerNameField($invoice, $priority, $matchedCompany), - 'customer_vat_id' => $this->validateCustomerVatField($invoice, $priority, $matchedCompany), 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', 'discount_amount', 'discount_percent' => $this->validateHeaderChargeField($invoice, $field, $priority), default => $this->validateGenericInvoiceField($invoice, $field, $priority), @@ -260,6 +290,7 @@ private function validateCustomerNumberField( Invoice $invoice, string $priority, ?Customer $matchedCustomer, + ?string $derivedCompanyId = null, ): array { $raw = $invoice->customer_number; if ($this->isScalarEmpty($raw)) { @@ -271,7 +302,7 @@ private function validateCustomerNumberField( } return [ - 'status' => (new CustomerMatcher)->isReviewableMatch($matchedCustomer) + 'status' => (new CustomerMatcher)->isReviewableMatch($matchedCustomer, $derivedCompanyId) ? 'needs_review' : 'db_validated', 'source' => 'auto', @@ -282,13 +313,32 @@ private function validateCustomerNumberField( /** * @return array{status: string, source?: string, matched_id?: string} */ - private function validateCustomerNameField(Invoice $invoice, string $priority, ?Company $matchedCompany): array - { + private function validateCustomerNameField( + Invoice $invoice, + string $priority, + ?Company $matchedCompany, + ?Customer $matchedCustomer = null, + ): array { $raw = $invoice->buyer?->name; if ($this->isScalarEmpty($raw)) { return $this->entryForEmptyField('customer_name', $priority, false); } + if ($matchedCustomer !== null) { + $result = (new AttributionCorroborator)->corroborateName( + is_string($raw) ? $raw : null, + $matchedCustomer, + $matchedCompany, + ); + + return [ + 'status' => $result['corroborates'] ? 'db_validated' : 'needs_review', + 'source' => 'auto', + 'matched_id' => $result['matched_id'], + ]; + } + + // Name-fallback path (no customer attribution): exact/loose company name match. if ($matchedCompany !== null) { if ($this->stringsLooselyMatch($raw, $matchedCompany->name)) { return [ @@ -307,13 +357,35 @@ private function validateCustomerNameField(Invoice $invoice, string $priority, ? /** * @return array{status: string, source?: string, matched_id?: string} */ - private function validateCustomerVatField(Invoice $invoice, string $priority, ?Company $matchedCompany): array - { + private function validateCustomerVatField( + Invoice $invoice, + string $priority, + ?Company $matchedCompany, + ?Customer $matchedCustomer = null, + ): array { $raw = $invoice->buyer?->vat_id; if ($this->isScalarEmpty($raw)) { return $this->entryForEmptyField('customer_vat_id', $priority, false); } + if ($matchedCustomer !== null) { + $comparison = (new AttributionCorroborator)->compareVat( + is_string($raw) ? $raw : null, + $matchedCompany?->vat_number, + ); + + if ($comparison === null) { + // Absent on master-data side is not a divergence (#25). + return ['status' => 'parsed']; + } + + return [ + 'status' => $comparison ? 'validated' : 'needs_review', + 'source' => 'auto', + 'matched_id' => (string) ($matchedCompany?->id ?? $matchedCustomer->id), + ]; + } + if ($matchedCompany !== null) { $expected = $matchedCompany->vat_number; if ($this->isScalarEmpty($expected)) { @@ -334,6 +406,83 @@ private function validateCustomerVatField(Invoice $invoice, string $priority, ?C return ['status' => 'parsed']; } + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateCountryField( + Invoice $invoice, + string $priority, + ?Company $matchedCompany, + ?Customer $matchedCustomer, + ): array { + $raw = $invoice->buyer?->address?->country_code; + if ($this->isScalarEmpty($raw)) { + return $this->entryForEmptyField('country', $priority, false); + } + + // Address/country corroboration runs only after a successful customer match. + if ($matchedCustomer === null) { + return ['status' => 'parsed']; + } + + $comparison = (new AttributionCorroborator)->compareCountry( + is_string($raw) ? $raw : null, + $matchedCompany, + ); + + if ($comparison === null) { + return ['status' => 'parsed']; + } + + return [ + 'status' => $comparison ? 'db_validated' : 'needs_review', + 'source' => 'auto', + 'matched_id' => (string) ($matchedCompany?->id ?? $matchedCustomer->id), + ]; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateCustomerAddressField( + Invoice $invoice, + string $priority, + ?Company $matchedCompany, + ?Customer $matchedCustomer, + ): array { + $address = $invoice->buyer?->address; + if ($this->isEn16931AddressEmpty($address)) { + return $this->entryForEmptyField('customer_address', $priority, false); + } + + // Address check runs only after a successful customer match (#25). + if ($matchedCustomer === null) { + return ['status' => 'parsed']; + } + + $result = (new AttributionCorroborator)->findMatchingAddress($address, $matchedCompany); + + if ($result === null) { + return $this->entryForEmptyField('customer_address', $priority, false); + } + + if ($result['exists']) { + return [ + 'status' => 'db_validated', + 'source' => 'auto', + 'matched_id' => $result['matched_id'], + ]; + } + + return [ + 'status' => 'needs_review', + 'source' => 'auto', + 'matched_id' => $result['matched_id'] !== '' + ? $result['matched_id'] + : (string) $matchedCustomer->id, + ]; + } + /** * @return array{status: string, source?: string, matched_id?: string} */ @@ -359,8 +508,9 @@ private function validateHeaderChargeField(Invoice $invoice, string $field, stri */ private function validateGenericInvoiceField(Invoice $invoice, string $field, string $priority): array { + // No persisted source ⇒ nothing to corroborate; never block auto-Validated. if (in_array($field, self::INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE, true)) { - return $this->entryForEmptyField($field, $priority, false); + return ['status' => 'not_applicable']; } $value = $this->getInvoiceFieldValue($invoice, $field); @@ -572,7 +722,7 @@ private function normalizeString(?string $value): string private function normalizeVat(?string $value): string { - return strtoupper(preg_replace('/\s+/', '', (string) $value) ?? ''); + return strtoupper(VatIdNormalizer::normalize($value) ?? ''); } private function isScalarEmpty(mixed $value): bool diff --git a/packages/e-billing/src/Support/AttributionCorroborator.php b/packages/e-billing/src/Support/AttributionCorroborator.php new file mode 100644 index 0000000000..d6452e7e2f --- /dev/null +++ b/packages/e-billing/src/Support/AttributionCorroborator.php @@ -0,0 +1,296 @@ +significantTokens($invoiceName ?? ''); + + $candidates = $this->nameCandidates($customer, $company); + + foreach ($candidates as $candidate) { + $candidateTokens = $this->significantTokens($candidate['name']); + if ($invoiceTokens === [] || $candidateTokens === []) { + continue; + } + + if (array_intersect($invoiceTokens, $candidateTokens) !== []) { + return [ + 'corroborates' => true, + 'matched_id' => $candidate['id'], + ]; + } + } + + return [ + 'corroborates' => false, + 'matched_id' => (string) $customer->getKey(), + ]; + } + + /** + * Compare VAT identifiers when both sides are present. + * + * @return bool|null true = agree, false = diverge, null = not comparable + */ + public function compareVat(?string $invoiceVat, ?string $companyVat): ?bool + { + $left = VatIdNormalizer::normalize($invoiceVat); + $right = VatIdNormalizer::normalize($companyVat); + + if ($left === null || $right === null) { + return null; + } + + return strcasecmp($left, $right) === 0; + } + + /** + * Compare country codes when both sides are present. + * + * Invoice country must appear on at least one role-filtered company address. + * + * @return bool|null true = agree, false = diverge, null = not comparable + */ + public function compareCountry(?string $invoiceCountry, ?Company $company): ?bool + { + $invoice = $this->normalizeCountryCode($invoiceCountry); + + if ($invoice === null || $company === null) { + return null; + } + + $addresses = $this->roleFilteredAddresses($company); + + if ($addresses->isEmpty()) { + // No master-data country to compare — not a divergence (#25). + return null; + } + + $knownCountries = []; + foreach ($addresses as $address) { + $known = $this->normalizeCountryCode( + is_string($address->country_code) ? $address->country_code : null, + ); + if ($known !== null) { + $knownCountries[$known] = true; + } + } + + if ($knownCountries === []) { + return null; + } + + return isset($knownCountries[$invoice]); + } + + /** + * Existence check of the parsed buyer address among role-filtered company addresses. + * + * @return array{exists: bool, matched_id: string}|null null when invoice address is empty + */ + public function findMatchingAddress(?InvoiceAddress $invoiceAddress, ?Company $company): ?array + { + if ($invoiceAddress === null || $this->isInvoiceAddressEmpty($invoiceAddress)) { + return null; + } + + if ($company === null) { + return [ + 'exists' => false, + 'matched_id' => '', + ]; + } + + $invoiceFingerprint = AddressFingerprint::fromArray([ + 'street' => $invoiceAddress->line1, + 'street2' => $invoiceAddress->line2, + 'postal_code' => $invoiceAddress->postal_code, + 'country_code' => $invoiceAddress->country_code, + ]); + + foreach ($this->roleFilteredAddresses($company) as $address) { + if (AddressFingerprint::fromAddress($address) === $invoiceFingerprint) { + return [ + 'exists' => true, + 'matched_id' => (string) $address->getKey(), + ]; + } + } + + return [ + 'exists' => false, + 'matched_id' => (string) $company->getKey(), + ]; + } + + /** + * @return list + */ + public function significantTokens(string $name): array + { + $folded = Str::lower(Str::ascii($name)); + $stripped = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $folded) ?? ''; + $parts = preg_split('/\s+/u', trim($stripped), -1, PREG_SPLIT_NO_EMPTY); + + if (! is_array($parts) || $parts === []) { + return []; + } + + $minLength = (int) config('e-billing.corroboration.name_min_token_length', 4); + $stopWords = $this->legalFormStopWords(); + + $tokens = []; + foreach ($parts as $part) { + if (! is_string($part)) { + continue; + } + if (mb_strlen($part) < $minLength) { + continue; + } + if (isset($stopWords[$part])) { + continue; + } + $tokens[] = $part; + } + + return array_values(array_unique($tokens)); + } + + /** + * @return list + */ + private function nameCandidates(Customer $customer, ?Company $company): array + { + $candidates = []; + + $customerName = $customer->customer_name; + if (is_string($customerName) && trim($customerName) !== '') { + $candidates[] = [ + 'name' => $customerName, + 'id' => (string) $customer->getKey(), + ]; + } + + if ($company === null) { + return $candidates; + } + + foreach (['name', 'display_name', 'legal_name'] as $attribute) { + $value = $company->getAttribute($attribute); + if (! is_string($value) || trim($value) === '') { + continue; + } + $candidates[] = [ + 'name' => $value, + 'id' => (string) $company->getKey(), + ]; + } + + return $candidates; + } + + /** + * @return Collection + */ + private function roleFilteredAddresses(Company $company): Collection + { + $roles = config('e-billing.corroboration.address_roles', ['billing_address']); + if (! is_array($roles) || $roles === []) { + $roles = ['billing_address']; + } + + try { + $company->loadMissing('addresses'); + } catch (\BadMethodCallException) { + return collect(); + } + + if (! $company->relationLoaded('addresses')) { + return collect(); + } + + /** @var Collection $addresses */ + $addresses = $company->getRelation('addresses'); + + return $addresses->filter(function (Address $address) use ($roles): bool { + foreach ($roles as $role) { + if (! is_string($role) || $role === '') { + continue; + } + if ((bool) $address->pivot?->getAttribute($role)) { + return true; + } + } + + return false; + })->values(); + } + + /** + * @return array + */ + private function legalFormStopWords(): array + { + $configured = config('e-billing.corroboration.name_legal_form_stop_words', []); + if (! is_array($configured)) { + $configured = []; + } + + $map = []; + foreach ($configured as $word) { + if (! is_string($word) || $word === '') { + continue; + } + $map[Str::lower(Str::ascii($word))] = true; + } + + return $map; + } + + private function normalizeCountryCode(?string $value): ?string + { + if ($value === null) { + return null; + } + + $trimmed = strtoupper(trim($value)); + + return $trimmed === '' ? null : $trimmed; + } + + private function isInvoiceAddressEmpty(InvoiceAddress $address): bool + { + foreach ([$address->line1, $address->line2, $address->city, $address->postal_code, $address->country_code] as $part) { + if (is_string($part) && trim($part) !== '') { + return false; + } + } + + return true; + } +} diff --git a/packages/e-billing/src/Support/CustomerMatcher.php b/packages/e-billing/src/Support/CustomerMatcher.php index dac6c0eb2c..ad7114dd66 100644 --- a/packages/e-billing/src/Support/CustomerMatcher.php +++ b/packages/e-billing/src/Support/CustomerMatcher.php @@ -48,9 +48,17 @@ public function resolveCompanyId(Customer $customer): ?string return $companyIds->count() === 1 ? (string) $companyIds->first() : null; } - public function isReviewableMatch(Customer $customer): bool + /** + * Soft-deleted, inactive, or missing/ambiguous company derivation ⇒ needs review. + * Attribution is still kept; corroboration never clears it. + */ + public function isReviewableMatch(Customer $customer, ?string $derivedCompanyId = null): bool { - return $customer->trashed() || $customer->is_active === false; + if ($customer->trashed() || $customer->is_active === false) { + return true; + } + + return $derivedCompanyId === null; } private function normalizeIdentifier(string $value): string From da785eb1a486cac261788f6e11f8528507ec5884 Mon Sep 17 00:00:00 2001 From: jbagsik Date: Thu, 6 Aug 2026 15:13:38 +0200 Subject: [PATCH 5/6] feat(e-billing): manual attribution and explicit rematch (#27) --- packages/e-billing/CHANGELOG.md | 1 + packages/e-billing/CONTEXT.md | 2 +- packages/e-billing/README.md | 2 + .../create_ebilling_documents_table.php.stub | 1 + .../e-billing/resources/lang/de/fields.php | 15 +++ .../e-billing/resources/lang/en/fields.php | 15 +++ .../src/Actions/RematchAttributionAction.php | 29 +++++ .../Actions/SetInvoiceAttributionAction.php | 60 ++++++++++ .../e-billing/src/EBillingServiceProvider.php | 4 + .../e-billing/src/Enums/AttributionSource.php | 11 ++ .../e-billing/src/Models/EbillingDocument.php | 4 + .../src/Resources/InvoiceResource.php | 37 +++++++ .../InvoiceResource/Pages/ViewInvoice.php | 104 ++++++++++++++++++ .../src/Services/InvoiceFieldValidator.php | 68 +++++++++--- 14 files changed, 335 insertions(+), 18 deletions(-) create mode 100644 packages/e-billing/src/Actions/RematchAttributionAction.php create mode 100644 packages/e-billing/src/Actions/SetInvoiceAttributionAction.php create mode 100644 packages/e-billing/src/Enums/AttributionSource.php diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md index e9fccca7ab..d796f6fecf 100644 --- a/packages/e-billing/CHANGELOG.md +++ b/packages/e-billing/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Manual attribution and explicit rematch ([#27](https://github.com/mooxphp/e-billing/issues/27)): nullable indexed `attribution_source` (`auto`|`manual`) on `ebilling_documents`; `SetInvoiceAttributionAction` and `RematchAttributionAction`; Filament `set_attribution` / `rematch` on the invoice detail (rematch also on the list). Rematch resets `review_status` to `parser_created` and re-runs `InvoiceFieldValidator::validate()`. Manual attributions are never overwritten; the automatic path still refuses confirmed/validated documents. Changing attribution after `HumanConfirmed`/`Validated` clears attestation back to `DbValidated`. No scheduled rematch. - Attribution corroboration via internal `Support\AttributionCorroborator`: after a customer match, name (significant token overlap ≥ `corroboration.name_min_token_length`, legal-form stop words ignored), VAT, country, and buyer address (existence among company billing addresses via `AddressFingerprint` and `corroboration.address_roles`) are checked against master data. Divergences only flag `needs_review`; corroboration never clears or rewrites attribution. Package now requires `moox/address`. Config keys under `e-billing.corroboration` ([#25](https://github.com/mooxphp/e-billing/issues/25)). - Customer attribution on `EbillingDocument`: nullable `customer_id` FK → `customers` (`nullOnDelete`), `customer()` BelongsTo, and internal `Support\CustomerMatcher` (normalises buyer identifier; looks up `Customer::withTrashed()` by `customer_number`; derives `company_id` from exactly one Company morph assignment). Package now requires `moox/customer` ([#24](https://github.com/mooxphp/e-billing/issues/24)). - Buyer identifier (`customer_number`, EN 16931 BT-46) now flows from the parser DTO onto the persisted invoice via `ParsedInvoiceMapper` / `InvoiceFactory`. Empty DTO values become `null`; non-empty values are stored unchanged. The field is validated from the invoice like any other field (present ⇒ `parsed`, absent ⇒ configured MoSCoW priority) and is no longer listed under `INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE` ([#23](https://github.com/mooxphp/e-billing/issues/23)). diff --git a/packages/e-billing/CONTEXT.md b/packages/e-billing/CONTEXT.md index 0bc4f4e2a9..b9742b6d08 100644 --- a/packages/e-billing/CONTEXT.md +++ b/packages/e-billing/CONTEXT.md @@ -33,7 +33,7 @@ Generate the customer-chosen artifact first (XRechnung XML / ZUGFeRD PDF / Factu - **Scope now:** the three **CII-based** formats are live. UBL and Peppol are deferred future strategies. - **Validator stays single.** KOSIT is the only XML validator. XRechnung = KOSIT only. Hybrids = KOSIT + veraPDF. - **Format binding.** ✅ `EbillingFormatResolver` reads `companies.data.preferred_ebilling_format` → falls back to `default_format` config (default `zugferd`). Format frozen on `ebilling_documents.format` at generation time (freeze = `xml_storage_path` is set). Preference changes affect future documents only. -- **Document identity = Customer.** ✅ `EbillingDocument.customer_id` is the matched buyer (identity / visibility gate), set via buyer identifier through internal `CustomerMatcher`. `company_id` is derived from the customer's Company assignment when unique, or from `CompanyNameMatcher` name fallback when there is no identifier match — reporting only, never an access boundary. Visibility must require `customer_id` **and** gateway `Validated` (do not expose on attribution alone). Soft-deleted/inactive customers, or a null derived company id (no / multi-company), still attribute but mark `customer_number` as `needs_review`. Attribution (#24) then name/VAT/country/address corroboration (#25) are both in scope and done: after a customer match, `AttributionCorroborator` checks significant name-token overlap, VAT/country only when both sides are present, and buyer-address existence among company billing addresses (`AddressFingerprint`); divergences only flag `needs_review` and never clear attribution ([#24](https://github.com/mooxphp/e-billing/issues/24), [#25](https://github.com/mooxphp/e-billing/issues/25)). +- **Document identity = Customer.** ✅ `EbillingDocument.customer_id` is the matched buyer (identity / visibility gate), set via buyer identifier through internal `CustomerMatcher`. `company_id` is derived from the customer's Company assignment when unique, or from `CompanyNameMatcher` name fallback when there is no identifier match — reporting only, never an access boundary. Visibility must require `customer_id` **and** gateway `Validated` (do not expose on attribution alone). Soft-deleted/inactive customers, or a null derived company id (no / multi-company), still attribute but mark `customer_number` as `needs_review`. Attribution (#24) then name/VAT/country/address corroboration (#25) are both in scope and done: after a customer match, `AttributionCorroborator` checks significant name-token overlap, VAT/country only when both sides are present, and buyer-address existence among company billing addresses (`AddressFingerprint`); divergences only flag `needs_review` and never clear attribution ([#24](https://github.com/mooxphp/e-billing/issues/24), [#25](https://github.com/mooxphp/e-billing/issues/25)). Manual attribution + explicit rematch (#27) are done: `attribution_source` records `auto` vs `manual`; rematch resets to `parser_created` and re-validates without overwriting a manual source ([#27](https://github.com/mooxphp/e-billing/issues/27)). - **Company name matching.** ✅ One `CompanyNameMatcher` serves field validation and format resolution (and the no-identifier / no-customer-match company fallback). Both match on the persisted invoice buyer name (whitespace-collapsed, case-insensitive exact equality, unique hit only); `bill_data['customer_name']` is not used. After a customer match, name corroboration uses token overlap (not exact equality); the exact-match path remains for the no-customer-match name fallback. - **Validation stack (validate the real artifact).** XML conformance → **KOSIT** for *every* format (pure XRechnung XML, or the XML **extracted from the hybrid PDF** via horstoeko `ZugferdDocumentPdfReader`). PDF/A-3 conformance → **veraPDF** (licensed MPL-2.0; commercial-safe as a CLI process). A hybrid passes iff **KOSIT(xml) AND veraPDF(pdf)**; a pure XRechnung passes iff **KOSIT(xml)**. - **`moox/verapdf` = own package** (mirrors `kosit-validator`/`zugferd` boundary): config + `verapdf:install` command + `VeraPdfService::validate()` + persisted `VeraPdfValidation` model + morph pivot. Generic, no e-billing knowledge; e-billing orchestrates KOSIT+veraPDF. Note: veraPDF ships as an **installer zip** (headless IzPack install → `verapdf` launcher script), *not* a single `java -jar` standalone like KOSIT — the install command is heavier. diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md index 88796a880e..8c9ac61c53 100644 --- a/packages/e-billing/README.md +++ b/packages/e-billing/README.md @@ -15,6 +15,7 @@ Moox e-billing orchestrates the Moox e-invoice pipeline: PDF ingestion through a - Foreign-invoice filtering (non-domestic invoices moved to an ignored mailbox folder) - MoSCoW field validation and validation scoring on `EbillingDocument` - Filament `InvoiceResource` for list, filter, and manual review workflows +- Manual customer attribution and explicit re-match from the invoice detail (and rematch from the list) - Host-bound invoice parser via `InvoiceParserInterface` (no parser ships with this package) @@ -164,6 +165,7 @@ Queries `EbillingDocument` rows where `field_validations` is not null and `valid | `updated_at` | `timestamp` | NOT NULL | | | `company_id` | `uuid` FK | nullable | References `companies.id` (`nullOnDelete`). Reporting only — derived from the matched customer; never an access boundary | | `customer_id` | `uuid` FK | nullable | References `customers.id` (`nullOnDelete`). Document identity (matched customer); gate visibility on this | +| `attribution_source` | `string` | nullable | `auto` (matcher) or `manual` (operator). Indexed. Manual attributions survive rematch | | `invoice_id` | `uuid` FK | nullable | References `invoices.id` (`nullOnDelete`) | | `scope` | `string` | nullable | Tenant / mailbox scope (indexed) | diff --git a/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub index 2b69b98347..5bc304a36d 100644 --- a/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub +++ b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub @@ -30,6 +30,7 @@ return new class extends Migration $table->foreignUuid('company_id')->nullable()->constrained('companies')->nullOnDelete(); $table->foreignUuid('customer_id')->nullable()->index()->constrained('customers')->nullOnDelete(); + $table->string('attribution_source')->nullable()->index(); $table->foreignUuid('invoice_id')->nullable()->constrained('invoices')->nullOnDelete(); $table->string('scope')->nullable()->index(); diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php index 1a3da971e3..ad1ea0f8a7 100644 --- a/packages/e-billing/resources/lang/de/fields.php +++ b/packages/e-billing/resources/lang/de/fields.php @@ -183,12 +183,27 @@ 'action_confirm_modal_heading' => 'Rechnung bestätigen', 'action_confirm_modal_description' => 'Hiermit bestätigen Sie, dass alle Felder dieser Rechnung korrekt sind. Dieser Schritt kann nicht rückgängig gemacht werden.', 'action_confirm_submit' => 'Ja, bestätigen', + 'action_set_attribution' => 'Zuordnung setzen', + 'action_set_attribution_modal_heading' => 'Kunden-Zuordnung setzen', + 'action_set_attribution_modal_description' => 'Wählen Sie den Kunden, dem dieses Dokument zugeordnet ist. War die Rechnung bereits bestätigt oder validiert, wird diese Bestätigung aufgehoben und muss erneut erfolgen.', + 'action_set_attribution_submit' => 'Zuordnung speichern', + 'action_rematch' => 'Erneut zuordnen', + 'action_rematch_modal_heading' => 'Zuordnung erneut ausführen', + 'action_rematch_modal_description' => 'Setzt dieses Dokument in den Zustand vor der Bestätigung zurück und bewertet die Zuordnung neu. Eine manuell gesetzte Zuordnung wird nicht überschrieben.', + 'action_rematch_submit' => 'Ja, erneut zuordnen', + 'field_customer' => 'Kunde', // Notifications 'notification_confirmed_title' => 'Rechnung bestätigt', 'notification_confirmed_body' => 'Die Rechnung wurde als manuell geprüft markiert.', 'notification_confirm_failed_title' => 'Bestätigung nicht möglich', 'notification_confirm_failed_body' => 'Die Rechnung befindet sich nicht im Status „Automatisch vorgeprüft“.', + 'notification_attribution_updated_title' => 'Zuordnung aktualisiert', + 'notification_attribution_updated_body' => 'Die Dokument-Zuordnung wurde als manuell gespeichert.', + 'notification_rematch_success_title' => 'Zuordnung erneut ausgeführt', + 'notification_rematch_success_body' => 'Das Dokument wurde zurückgesetzt und neu bewertet.', + 'notification_rematch_failed_title' => 'Erneute Zuordnung fehlgeschlagen', + 'notification_rematch_failed_body' => 'Die Zuordnung konnte für dieses Dokument nicht erneut ausgeführt werden.', // Downloads & preview 'download_zugferd_pdf' => 'ZUGFeRD-PDF', diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php index 1752d825fa..db83b8935e 100644 --- a/packages/e-billing/resources/lang/en/fields.php +++ b/packages/e-billing/resources/lang/en/fields.php @@ -183,12 +183,27 @@ 'action_confirm_modal_heading' => 'Confirm invoice', 'action_confirm_modal_description' => 'By confirming, you attest that all fields on this invoice are correct. This step cannot be undone.', 'action_confirm_submit' => 'Yes, confirm', + 'action_set_attribution' => 'Set attribution', + 'action_set_attribution_modal_heading' => 'Set customer attribution', + 'action_set_attribution_modal_description' => 'Choose the customer this document belongs to. If the invoice was already confirmed or validated, that attestation is cleared and must be repeated.', + 'action_set_attribution_submit' => 'Save attribution', + 'action_rematch' => 'Re-match', + 'action_rematch_modal_heading' => 'Re-run matching', + 'action_rematch_modal_description' => 'Reset this document to the pre-confirmation state and re-evaluate attribution. A manually set attribution will not be overwritten.', + 'action_rematch_submit' => 'Yes, re-match', + 'field_customer' => 'Customer', // Notifications 'notification_confirmed_title' => 'Invoice confirmed', 'notification_confirmed_body' => 'The invoice was marked as manually reviewed.', 'notification_confirm_failed_title' => 'Confirmation not possible', 'notification_confirm_failed_body' => 'The invoice is not in status "Automatically pre-reviewed".', + 'notification_attribution_updated_title' => 'Attribution updated', + 'notification_attribution_updated_body' => 'The document attribution was saved as a manual match.', + 'notification_rematch_success_title' => 'Matching re-run', + 'notification_rematch_success_body' => 'The document was reset and re-evaluated.', + 'notification_rematch_failed_title' => 'Re-match failed', + 'notification_rematch_failed_body' => 'Matching could not be re-run for this document.', // Downloads & preview 'download_zugferd_pdf' => 'ZUGFeRD PDF', diff --git a/packages/e-billing/src/Actions/RematchAttributionAction.php b/packages/e-billing/src/Actions/RematchAttributionAction.php new file mode 100644 index 0000000000..b1f46a3496 --- /dev/null +++ b/packages/e-billing/src/Actions/RematchAttributionAction.php @@ -0,0 +1,29 @@ +review_status = InvoiceProcessingStatus::ParserCreated; + $document->save(); + + $this->validator->validate($document); + } +} diff --git a/packages/e-billing/src/Actions/SetInvoiceAttributionAction.php b/packages/e-billing/src/Actions/SetInvoiceAttributionAction.php new file mode 100644 index 0000000000..e8ccd660c0 --- /dev/null +++ b/packages/e-billing/src/Actions/SetInvoiceAttributionAction.php @@ -0,0 +1,60 @@ +customer_id = null; + $document->company_id = null; + $document->attribution_source = null; + $this->invalidateConfirmationIfNeeded($document); + $document->save(); + + return; + } + + $customer = Customer::query()->withTrashed()->find($customerId); + + if (! $customer instanceof Customer) { + throw new InvalidArgumentException("Customer [{$customerId}] was not found."); + } + + $document->customer_id = (string) $customer->getKey(); + $document->company_id = (new CustomerMatcher)->resolveCompanyId($customer); + $document->attribution_source = AttributionSource::Manual; + $this->invalidateConfirmationIfNeeded($document); + $document->save(); + } + + /** + * Changing identity after human confirmation / validation voids the attestation + * so the document must be re-confirmed (customer_id is a visibility gate). + */ + private function invalidateConfirmationIfNeeded(EbillingDocument $document): void + { + $status = $document->review_status; + if (! $status instanceof InvoiceProcessingStatus) { + $raw = $document->getAttributes()['review_status'] ?? null; + $status = is_string($raw) ? InvoiceProcessingStatus::tryFrom($raw) : null; + } + + if (in_array($status, [InvoiceProcessingStatus::HumanConfirmed, InvoiceProcessingStatus::Validated], true)) { + $document->review_status = InvoiceProcessingStatus::DbValidated; + } + } +} diff --git a/packages/e-billing/src/EBillingServiceProvider.php b/packages/e-billing/src/EBillingServiceProvider.php index 3e61d3bfdb..f8aeb7adcb 100644 --- a/packages/e-billing/src/EBillingServiceProvider.php +++ b/packages/e-billing/src/EBillingServiceProvider.php @@ -9,6 +9,8 @@ use InvalidArgumentException; use Moox\Core\MooxServiceProvider; use Moox\EBilling\Actions\ConfirmInvoiceAction; +use Moox\EBilling\Actions\RematchAttributionAction; +use Moox\EBilling\Actions\SetInvoiceAttributionAction; use Moox\EBilling\Console\Commands\BackfillValidationScoresCommand; use Moox\EBilling\Contracts\InvoiceParserInterface; use Moox\EBilling\Formats\ArtifactKind; @@ -58,6 +60,8 @@ public function packageRegistered(): void $this->app->singleton(InvoiceFieldValidator::class); $this->app->singleton(ConfirmInvoiceAction::class); + $this->app->singleton(SetInvoiceAttributionAction::class); + $this->app->singleton(RematchAttributionAction::class); $this->app->singleton(DocumentTypeCodeResolver::class); $this->app->singleton(UnitCodeResolver::class); $this->app->singleton(ZugferdGeneratorStrategy::class); diff --git a/packages/e-billing/src/Enums/AttributionSource.php b/packages/e-billing/src/Enums/AttributionSource.php new file mode 100644 index 0000000000..8ce5535a44 --- /dev/null +++ b/packages/e-billing/src/Enums/AttributionSource.php @@ -0,0 +1,11 @@ + 'array', 'gateway_status' => EBillingAttachmentProcessingStatus::class, 'review_status' => InvoiceProcessingStatus::class, + 'attribution_source' => AttributionSource::class, 'field_validations' => 'array', 'validation_score' => 'integer', 'processed_at' => 'datetime', diff --git a/packages/e-billing/src/Resources/InvoiceResource.php b/packages/e-billing/src/Resources/InvoiceResource.php index f5d0e2cebc..6ec15b7e10 100644 --- a/packages/e-billing/src/Resources/InvoiceResource.php +++ b/packages/e-billing/src/Resources/InvoiceResource.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; +use Filament\Notifications\Notification; use Filament\Schemas\Schema; use Filament\Support\Enums\Alignment; use Filament\Support\Icons\Heroicon; @@ -23,13 +24,16 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Core\Traits\SoftDelete\SingleSoftDeleteInResource; +use Moox\EBilling\Actions\RematchAttributionAction; use Moox\EBilling\Enums\EBillingAttachmentProcessingStatus; use Moox\EBilling\Enums\InvoiceProcessingStatus; +use Moox\EBilling\Models\EbillingDocument; use Moox\EBilling\Resources\InvoiceResource\Pages\ListInvoices; use Moox\EBilling\Resources\InvoiceResource\Pages\ViewInvoice; use Moox\EBilling\Support\InvoiceFieldLabels; use Moox\Invoice\Models\Invoice; use Moox\Invoice\Support\InvoiceModels; +use Throwable; final class InvoiceResource extends BaseItemResource { @@ -412,6 +416,39 @@ private static function invoiceListTableRecordActions(): array ->label(__('e-billing::fields.action_details')) ->icon(Heroicon::OutlinedEye) ->url(fn (Invoice $record): string => self::getUrl('view', ['record' => $record])), + Action::make('rematch') + ->label(__('e-billing::fields.action_rematch')) + ->icon(Heroicon::OutlinedArrowPath) + ->color('warning') + ->requiresConfirmation() + ->modalHeading(__('e-billing::fields.action_rematch_modal_heading')) + ->modalDescription(__('e-billing::fields.action_rematch_modal_description')) + ->modalSubmitActionLabel(__('e-billing::fields.action_rematch_submit')) + ->visible(fn (Invoice $record): bool => $record->ebillingDocument instanceof EbillingDocument) + ->action(function (Invoice $record): void { + $document = $record->ebillingDocument; + if (! $document instanceof EbillingDocument) { + return; + } + + try { + app(RematchAttributionAction::class)->execute($document); + } catch (Throwable) { + Notification::make() + ->title(__('e-billing::fields.notification_rematch_failed_title')) + ->body(__('e-billing::fields.notification_rematch_failed_body')) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title(__('e-billing::fields.notification_rematch_success_title')) + ->body(__('e-billing::fields.notification_rematch_success_body')) + ->success() + ->send(); + }), Action::make('kosit_report') ->label(__('e-billing::fields.action_kosit_report')) ->icon(Heroicon::OutlinedDocumentMagnifyingGlass) diff --git a/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php b/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php index 2ecbe2748e..11d11ff81c 100644 --- a/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php +++ b/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php @@ -5,16 +5,22 @@ namespace Moox\EBilling\Resources\InvoiceResource\Pages; use Filament\Actions\Action; +use Filament\Forms\Components\Select; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; use Livewire\Attributes\Computed; +use Moox\Customer\Models\Customer; use Moox\EBilling\Actions\ConfirmInvoiceAction; +use Moox\EBilling\Actions\RematchAttributionAction; +use Moox\EBilling\Actions\SetInvoiceAttributionAction; use Moox\EBilling\Enums\InvoiceProcessingStatus; +use Moox\EBilling\Models\EbillingDocument; use Moox\EBilling\Resources\InvoiceResource; use Moox\EBilling\ViewModels\InvoiceViewModel; use Moox\Invoice\Models\Invoice; +use Throwable; final class ViewInvoice extends ViewRecord { @@ -106,6 +112,104 @@ protected function getHeaderActions(): array ->send(); } }), + Action::make('set_attribution') + ->label(__('e-billing::fields.action_set_attribution')) + ->icon(Heroicon::OutlinedUserCircle) + ->color('gray') + ->modalHeading(__('e-billing::fields.action_set_attribution_modal_heading')) + ->modalDescription(__('e-billing::fields.action_set_attribution_modal_description')) + ->modalSubmitActionLabel(__('e-billing::fields.action_set_attribution_submit')) + ->visible(fn (): bool => $document instanceof EbillingDocument) + ->fillForm(fn (): array => [ + 'customer_id' => $document?->customer_id, + ]) + ->schema([ + Select::make('customer_id') + ->label(__('e-billing::fields.field_customer')) + ->searchable() + ->nullable() + ->native(false) + ->getSearchResultsUsing(function (string $search): array { + return Customer::query() + ->where(function ($query) use ($search): void { + $query->where('customer_name', 'like', "%{$search}%") + ->orWhere('customer_number', 'like', "%{$search}%"); + }) + ->orderBy('customer_name') + ->limit(50) + ->get() + ->mapWithKeys(fn (Customer $customer): array => [ + (string) $customer->getKey() => $customer->displayLabel() + .(filled($customer->customer_number) ? " ({$customer->customer_number})" : ''), + ]) + ->all(); + }) + ->getOptionLabelUsing(function (?string $value): ?string { + if ($value === null || $value === '') { + return null; + } + + $customer = Customer::query()->withTrashed()->find($value); + + return $customer instanceof Customer + ? $customer->displayLabel() + .(filled($customer->customer_number) ? " ({$customer->customer_number})" : '') + : $value; + }), + ]) + ->action(function (array $data) use ($record, $document): void { + if (! $document instanceof EbillingDocument) { + return; + } + + $customerId = $data['customer_id'] ?? null; + app(SetInvoiceAttributionAction::class)->execute( + $document, + is_string($customerId) && $customerId !== '' ? $customerId : null, + ); + + Notification::make() + ->title(__('e-billing::fields.notification_attribution_updated_title')) + ->body(__('e-billing::fields.notification_attribution_updated_body')) + ->success() + ->send(); + + $record->load('ebillingDocument'); + }), + Action::make('rematch') + ->label(__('e-billing::fields.action_rematch')) + ->icon(Heroicon::OutlinedArrowPath) + ->color('warning') + ->requiresConfirmation() + ->modalHeading(__('e-billing::fields.action_rematch_modal_heading')) + ->modalDescription(__('e-billing::fields.action_rematch_modal_description')) + ->modalSubmitActionLabel(__('e-billing::fields.action_rematch_submit')) + ->visible(fn (): bool => $document instanceof EbillingDocument) + ->action(function () use ($record, $document): void { + if (! $document instanceof EbillingDocument) { + return; + } + + try { + app(RematchAttributionAction::class)->execute($document->fresh() ?? $document); + } catch (Throwable) { + Notification::make() + ->title(__('e-billing::fields.notification_rematch_failed_title')) + ->body(__('e-billing::fields.notification_rematch_failed_body')) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title(__('e-billing::fields.notification_rematch_success_title')) + ->body(__('e-billing::fields.notification_rematch_success_body')) + ->success() + ->send(); + + $record->load('ebillingDocument'); + }), ]; } diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php index 475d639caf..fc9e5438bb 100644 --- a/packages/e-billing/src/Services/InvoiceFieldValidator.php +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -6,6 +6,7 @@ use Moox\Company\Models\Company; use Moox\Customer\Models\Customer; +use Moox\EBilling\Enums\AttributionSource; use Moox\EBilling\Enums\InvoiceProcessingStatus; use Moox\EBilling\Events\InvoiceValidationCompleted; use Moox\EBilling\Models\EbillingDocument; @@ -57,22 +58,34 @@ public function fillFieldValidations(EbillingDocument $document): void $invoice->loadMissing(['allowanceCharges', 'lines.allowanceCharges']); - $hasIdentifier = ! $this->isScalarEmpty($invoice->customer_number); - $matchedCustomer = $hasIdentifier - ? $this->resolveCustomerMatch($invoice) - : null; + $isManualAttribution = $document->attribution_source === AttributionSource::Manual; - $derivedCompanyId = null; - $matchedCompany = null; - if ($matchedCustomer !== null) { - $derivedCompanyId = (new CustomerMatcher)->resolveCompanyId($matchedCustomer); - if ($derivedCompanyId !== null) { - $matchedCompany = Company::query()->find($derivedCompanyId); - } + if ($isManualAttribution) { + $matchedCustomer = $document->customer_id !== null + ? Customer::query()->withTrashed()->find($document->customer_id) + : null; + $derivedCompanyId = $document->company_id; + $matchedCompany = $derivedCompanyId !== null + ? Company::query()->find($derivedCompanyId) + : null; } else { - // Name fallback when no identifier, or identifier present but unmatched (#21). - $matchedCompany = $this->resolveCompanyMatch($invoice); - $derivedCompanyId = $matchedCompany?->id; + $hasIdentifier = ! $this->isScalarEmpty($invoice->customer_number); + $matchedCustomer = $hasIdentifier + ? $this->resolveCustomerMatch($invoice) + : null; + + $derivedCompanyId = null; + $matchedCompany = null; + if ($matchedCustomer !== null) { + $derivedCompanyId = (new CustomerMatcher)->resolveCompanyId($matchedCustomer); + if ($derivedCompanyId !== null) { + $matchedCompany = Company::query()->find($derivedCompanyId); + } + } else { + // Name fallback when no identifier, or identifier present but unmatched (#21). + $matchedCompany = $this->resolveCompanyMatch($invoice); + $derivedCompanyId = $matchedCompany?->id; + } } $invoiceValidations = []; @@ -87,6 +100,7 @@ public function fillFieldValidations(EbillingDocument $document): void $matchedCompany, $matchedCustomer, $derivedCompanyId, + $isManualAttribution, ); } @@ -98,8 +112,15 @@ public function fillFieldValidations(EbillingDocument $document): void $invoiceValidations['lines'] = $lineValidations; $document->field_validations = $invoiceValidations; - $document->customer_id = $matchedCustomer?->id; - $document->company_id = $derivedCompanyId; + + if (! $isManualAttribution) { + $document->customer_id = $matchedCustomer?->id; + $document->company_id = $derivedCompanyId; + $document->attribution_source = $matchedCustomer !== null + ? AttributionSource::Auto + : null; + } + $document->validation_score = $document->calculateValidationScore(); $document->save(); } @@ -245,6 +266,7 @@ private function validateInvoiceField( ?Company $matchedCompany, ?Customer $matchedCustomer = null, ?string $derivedCompanyId = null, + bool $isManualAttribution = false, ): array { return match ($field) { 'customer_number' => $this->validateCustomerNumberField( @@ -252,6 +274,7 @@ private function validateInvoiceField( $priority, $matchedCustomer, $derivedCompanyId, + $isManualAttribution, ), 'customer_name' => $this->validateCustomerNameField( $invoice, @@ -291,7 +314,18 @@ private function validateCustomerNumberField( string $priority, ?Customer $matchedCustomer, ?string $derivedCompanyId = null, + bool $isManualAttribution = false, ): array { + if ($isManualAttribution && $matchedCustomer !== null) { + return [ + 'status' => (new CustomerMatcher)->isReviewableMatch($matchedCustomer, $derivedCompanyId) + ? 'needs_review' + : 'db_validated', + 'source' => AttributionSource::Manual->value, + 'matched_id' => (string) $matchedCustomer->id, + ]; + } + $raw = $invoice->customer_number; if ($this->isScalarEmpty($raw)) { return $this->entryForEmptyField('customer_number', $priority, false); @@ -305,7 +339,7 @@ private function validateCustomerNumberField( 'status' => (new CustomerMatcher)->isReviewableMatch($matchedCustomer, $derivedCompanyId) ? 'needs_review' : 'db_validated', - 'source' => 'auto', + 'source' => AttributionSource::Auto->value, 'matched_id' => (string) $matchedCustomer->id, ]; } From 64fa04cd140903827ddc42f176d39e32d0abd3d5 Mon Sep 17 00:00:00 2001 From: jbagsik <234342240+jbagsik@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:58:34 +0000 Subject: [PATCH 6/6] Fix styling --- packages/e-billing/src/Actions/RematchAttributionAction.php | 3 ++- packages/e-billing/src/Resources/InvoiceResource.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/e-billing/src/Actions/RematchAttributionAction.php b/packages/e-billing/src/Actions/RematchAttributionAction.php index b1f46a3496..4f4093bc98 100644 --- a/packages/e-billing/src/Actions/RematchAttributionAction.php +++ b/packages/e-billing/src/Actions/RematchAttributionAction.php @@ -16,7 +16,8 @@ final class RematchAttributionAction { public function __construct( private InvoiceFieldValidator $validator, - ) {} + ) { + } public function execute(EbillingDocument $document): void { diff --git a/packages/e-billing/src/Resources/InvoiceResource.php b/packages/e-billing/src/Resources/InvoiceResource.php index 6ec15b7e10..03f5a41cf2 100644 --- a/packages/e-billing/src/Resources/InvoiceResource.php +++ b/packages/e-billing/src/Resources/InvoiceResource.php @@ -187,7 +187,7 @@ private static function invoiceListTableColumns(): array } try { return Carbon::parse($state)->format('d.m.Y'); - } catch (\Throwable) { + } catch (Throwable) { return $state; } }),