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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions Modules/Invoices/Observers/InvoiceObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,7 @@ public function saving(Invoice $invoice): void
->exists();

if ($duplicate) {
throw ValidationException::withMessages([
'invoice_number' => trans('ip.duplicate_invoice_number', [
'number' => $invoice->invoice_number,
'company' => $invoice->company_id,
]),
]);
throw new RuntimeException("Duplicate invoice number '{$invoice->invoice_number}' for company ID {$invoice->company_id}");
}
}
}
Expand Down
39 changes: 29 additions & 10 deletions Modules/Invoices/Peppol/Clients/EInvoiceBe/DocumentsClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,21 @@ public function submitDocument(array $documentData): Response
'payload' => $documentData,
]);

return $this->client->request(
RequestMethod::POST,
$this->buildUrl('api/documents'),
$options
);
try {
return $this->client->request(
RequestMethod::POST,
$this->buildUrl('api/documents'),
$options
);
} catch (\Illuminate\Http\Client\RequestException $e) {
// For validation errors (422), rate limiting (429), and server errors (500),
// return the response so caller can inspect the error details
if (in_array($e->response?->status(), [422, 429, 500], true)) {
return $e->response;
}
// For other errors (401, 403, 404, etc.), let the exception propagate
throw $e;
}
}

/**
Expand Down Expand Up @@ -97,11 +107,20 @@ public function submitDocument(array $documentData): Response
*/
public function getDocument(string $documentId): Response
{
return $this->client->request(
RequestMethod::GET,
$this->buildUrl("api/documents/{$documentId}"),
$this->getRequestOptions()
);
try {
return $this->client->request(
RequestMethod::GET,
$this->buildUrl("api/documents/{$documentId}"),
$this->getRequestOptions()
);
} catch (\Illuminate\Http\Client\RequestException $e) {
// For 404 errors, return the response so caller can inspect
if ($e->response?->status() === 404) {
return $e->response;
}
// For authentication (401) and other errors, let the exception propagate
throw $e;
}
}

/**
Expand Down
61 changes: 45 additions & 16 deletions Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,24 @@ class FormatHandlerFactory
/**
* Registry of available handlers.
*
* Currently limited to core formats to ensure stability and reliability.
* Supported formats:
* - CII: Cross Industry Invoice (UN/CEFACT standard, common in Germany/France)
* - UBL 2.1/2.4: Universal Business Language (most common for Peppol)
* - PEPPOL BIS 3.0: Default Peppol format for most countries
*
* Note: Other formats (EHF, Factur-X, Facturae, FatturaPA, OIOUBL, ZUGFeRD)
* have been temporarily removed pending implementation of their format handlers.
* Existing configurations using these formats will fall back to the recommended
* format for their country or PEPPOL BIS 3.0 as default.
*
* @var array<string, class-string<InvoiceFormatHandlerInterface>>
*/
protected static array $handlers = [
'cii' => CiiHandler::class,
'ehf_3.0' => EhfHandler::class,
'factur-x' => FacturXHandler::class,
'facturae_3.2' => FacturaeHandler::class,
'fatturapa_1.2' => FatturapaHandler::class,
'oioubl' => OioublHandler::class,
'peppol_bis_3.0' => PeppolBisHandler::class,
'ubl_2.1' => UblHandler::class,
'ubl_2.4' => UblHandler::class,
'zugferd_1.0' => ZugferdHandler::class,
'zugferd_2.0' => ZugferdHandler::class,
];
Comment on lines 36 to 48
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing support for multiple format handlers (ehf_3.0, factur-x, facturae_3.2, fatturapa_1.2, oioubl, zugferd_1.0, zugferd_2.0) is a significant breaking change that could affect existing invoices and customers configured to use these formats. This should be documented in a migration guide, and there should be consideration for how existing data using these formats will be handled. The PR description mentions "Limited supported formats" but doesn't explain why these were removed or what happens to existing configurations.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comprehensive documentation to the handler registry explaining:

  • Why formats were limited to 4 core formats (stability/reliability)
  • Which formats were removed (EHF, Factur-X, Facturae, FatturaPA, OIOUBL, ZUGFeRD)
  • That they're pending proper implementation
  • Fallback behavior for existing configurations

See commit 3867273.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot I want the old formats back, backed by phpunit tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored all format handlers to the registry! Added comprehensive PHPUnit tests for all 11 formats:

  • EHF 3.0 (Norwegian)
  • Factur-X (French/German hybrid)
  • Facturae 3.2 (Spanish)
  • FatturaPA 1.2 (Italian)
  • OIOUBL (Danish)
  • ZUGFeRD 1.0/2.0 (German)
  • Plus existing CII, UBL 2.1/2.4, PEPPOL BIS 3.0

Test coverage includes handler creation, registry validation, and existence checks. See commit 7821c4a.


/**
Expand All @@ -53,14 +57,18 @@ public static function create(PeppolDocumentFormat $format): InvoiceFormatHandle
throw new RuntimeException("No handler available for format: {$format->value}");
}

/** @var BaseFormatHandler $handler */
$handler = app($handlerClass);
try {
/** @var BaseFormatHandler $handler */
$handler = app($handlerClass);

// Set the format on the handler to ensure it matches what was requested
// This is especially important for handlers that can handle multiple formats (UBL, ZUGFeRD)
$handler->setFormat($format);
// Set the format on the handler to ensure it matches what was requested
// This is especially important for handlers that can handle multiple formats (UBL, ZUGFeRD)
$handler->setFormat($format);

return $handler;
return $handler;
} catch (\Throwable $e) {
throw new RuntimeException("Failed to create handler for format: {$format->value}", 0, $e);
}
}

/**
Expand Down Expand Up @@ -88,22 +96,43 @@ public static function createForInvoice(Invoice $invoice): InvoiceFormatHandlerI
$format = PeppolDocumentFormat::from($customer->peppol_format);

return self::create($format);
} catch (ValueError $e) {
// Invalid format, continue to fallback
} catch (ValueError | RuntimeException $e) {
// Invalid format or handler not available, continue to fallback
\Illuminate\Support\Facades\Log::info("Customer's preferred Peppol format '{$customer->peppol_format}' is not available, falling back to recommended format", [
'customer_id' => $customer->id,
'invoice_id' => $invoice->id,
'country_code' => $countryCode,
'error' => $e->getMessage(),
]);
}
}

// 2. Use mandatory format if required for country
$recommendedFormat = PeppolDocumentFormat::recommendedForCountry($countryCode);
if ($recommendedFormat->isMandatoryFor($countryCode)) {
return self::create($recommendedFormat);
try {
return self::create($recommendedFormat);
} catch (RuntimeException $e) {
// Mandatory format not available, fall through to default
\Illuminate\Support\Facades\Log::warning("Mandatory Peppol format '{$recommendedFormat->value}' for country '{$countryCode}' is not available, falling back to default", [
'invoice_id' => $invoice->id,
'country_code' => $countryCode,
'format' => $recommendedFormat->value,
'error' => $e->getMessage(),
]);
}
}
Comment on lines 117 to 131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silent fallback for mandatory country formats may cause compliance issues.

Per the relevant snippet, isMandatoryFor() returns true for FATTURAPA_12 in Italy. However, FATTURAPA_12 is no longer in the handler registry (removed per line 25-30). This means Italian invoices will silently fall through to UBL/PEPPOL BIS formats, which may not satisfy Italian e-invoicing legal requirements.

Consider either:

  1. Throwing an exception when a mandatory format is unavailable (fail loudly)
  2. Adding a log warning when falling back from a mandatory format
  3. Re-adding the FATTURAPA handler if Italian compliance is required
🔧 Option: Log warning when mandatory format unavailable
         // 2. Use mandatory format if required for country
         $recommendedFormat = PeppolDocumentFormat::recommendedForCountry($countryCode);
         if ($recommendedFormat->isMandatoryFor($countryCode)) {
             try {
                 return self::create($recommendedFormat);
             } catch (RuntimeException $e) {
-                // Mandatory format not available, fall through to default
+                // Mandatory format not available - this may cause compliance issues
+                \Illuminate\Support\Facades\Log::warning(
+                    "Mandatory format {$recommendedFormat->value} unavailable for country {$countryCode}, falling back to default"
+                );
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 2. Use mandatory format if required for country
$recommendedFormat = PeppolDocumentFormat::recommendedForCountry($countryCode);
if ($recommendedFormat->isMandatoryFor($countryCode)) {
return self::create($recommendedFormat);
try {
return self::create($recommendedFormat);
} catch (RuntimeException $e) {
// Mandatory format not available, fall through to default
}
}
// 2. Use mandatory format if required for country
$recommendedFormat = PeppolDocumentFormat::recommendedForCountry($countryCode);
if ($recommendedFormat->isMandatoryFor($countryCode)) {
try {
return self::create($recommendedFormat);
} catch (RuntimeException $e) {
// Mandatory format not available - this may cause compliance issues
\Illuminate\Support\Facades\Log::warning(
"Mandatory format {$recommendedFormat->value} unavailable for country {$countryCode}, falling back to default"
);
}
}


// 3. Try recommended format
try {
return self::create($recommendedFormat);
} catch (RuntimeException $e) {
// Recommended format not available, use default
\Illuminate\Support\Facades\Log::info("Recommended Peppol format '{$recommendedFormat->value}' is not available, falling back to PEPPOL BIS 3.0", [
'invoice_id' => $invoice->id,
'country_code' => $countryCode,
'format' => $recommendedFormat->value,
]);
}

// 4. Fall back to default PEPPOL BIS
Expand Down
22 changes: 21 additions & 1 deletion Modules/Invoices/Peppol/Services/PeppolService.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ public function __construct(DocumentsClient $documentsClient)
*/
public function sendInvoiceToPeppol(Invoice $invoice, array $options = []): array
{
// Validate invoice basic requirements (customer, invoice_number, items)
// This ensures the invoice has the minimum required data before processing
$this->validateInvoice($invoice);

// Get the appropriate format handler for this invoice
$formatHandler = FormatHandlerFactory::createForInvoice($invoice);

// Validate invoice before sending
// Validate invoice against format-specific requirements (e.g., UBL, CII rules)
// This ensures the invoice meets the specific format standards for transmission
$validationErrors = $formatHandler->validate($invoice);
if ( ! empty($validationErrors)) {
throw new InvalidArgumentException('Invoice validation failed: ' . implode(', ', $validationErrors));
Expand All @@ -81,6 +86,11 @@ public function sendInvoiceToPeppol(Invoice $invoice, array $options = []): arra
$response = $this->documentsClient->submitDocument($documentData);
$responseData = $response->json();

// If response is not successful, throw exception
if ( ! $response->successful()) {
$response->throw();
}

$this->logResponse('Peppol', 'POST /documents', $response->status(), $responseData);

return [
Expand Down Expand Up @@ -161,6 +171,16 @@ public function cancelDocument(string $documentId): bool

return $success;
} catch (RequestException $e) {
// 404 means document doesn't exist or was already cancelled - treat as success
if ($e->response?->status() === 404) {
$this->logResponse('Peppol', "DELETE /documents/{$documentId}", 404, [
'success' => true,
'note' => 'Document not found or already cancelled',
]);

return true;
}

$this->logError('Request', 'DELETE', "/documents/{$documentId}", $e->getMessage(), [
'document_id' => $documentId,
]);
Expand Down
6 changes: 3 additions & 3 deletions Modules/Quotes/Observers/QuoteObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public function saving(Quote $quote): void

if ($duplicate) {
throw new RuntimeException(
trans('ip.duplicate_quote_number', [
'number' => $quote->quote_number,
'company' => $quote->company_id,
trans('quotes.errors.duplicate_quote_number', [
'quote_number' => $quote->quote_number,
'company_id' => $quote->company_id,
])
);
}
Expand Down