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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions .github/QUICKSTART.md

This file was deleted.

56 changes: 0 additions & 56 deletions .github/SETUP.md

This file was deleted.

18 changes: 17 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public function it_creates_invoice(): void

### Peppol Integration Rules

- **Peppol service follows Strategy Pattern** for format handlers (UBL, FatturaPA, ZUGFeRD, etc.).
- **Peppol service follows Strategy Pattern** for format handlers with 11 supported formats.
- **PeppolService coordinates** invoice transformation and transmission.
- **PeppolManagementService handles** integration lifecycle (create, test, validate, send).
- **Format handlers** must implement validation, transformation, and format-specific logic.
Expand All @@ -188,6 +188,22 @@ public function it_creates_invoice(): void
- **Logging** is done via LogsApiRequests and LogsPeppolActivity traits.
- **Events** are dispatched for all major Peppol operations (transmission, validation, etc.).

**All 11 Supported Peppol Format Handlers:**
1. **CII** - Cross Industry Invoice (UN/CEFACT standard for Germany/France/Austria)
2. **EHF 3.0** - Norwegian e-invoice format (Elektronisk Handelsformat)
3. **Factur-X** - French/German hybrid (PDF with embedded XML)
4. **Facturae 3.2** - Spanish format (mandatory for public administration)
5. **FatturaPA 1.2** - Italian format (mandatory for all invoices)
6. **OIOUBL** - Danish e-invoice format
7. **PEPPOL BIS 3.0** - Default Peppol format (pan-European)
8. **UBL 2.1** - Universal Business Language (most common)
9. **UBL 2.4** - Updated UBL with enhanced features
10. **ZUGFeRD 1.0** - German format (PDF with embedded XML)
11. **ZUGFeRD 2.0** - Updated German format (compatible with Factur-X)

Each handler is registered in `FormatHandlerFactory` with comprehensive PHPUnit test coverage.
The factory automatically selects handlers with fallback logic and proper logging.

### Seeding Rules

- Seed 5 default roles (`superadmin`, `admin`, `assistance`, `useradmin`, `user`).
Expand Down
28 changes: 23 additions & 5 deletions .junie/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,29 @@ Each format handler implements:
- `transform(Invoice $invoice, array $options): array` - Converts to format-specific structure
- `getFormat(): PeppolDocumentFormat` - Returns format enum

**Supported Formats:**
- UBL 2.1 (Universal Business Language)
- FatturaPA (Italian e-invoicing)
- ZUGFeRD (German hybrid PDF/XML format)
- Peppol BIS Billing 3.0
**Supported Formats (11 total):**
- **CII** (Cross Industry Invoice) - UN/CEFACT standard, common in Germany/France/Austria
- **EHF 3.0** - Norwegian e-invoice format (Elektronisk Handelsformat)
- **Factur-X** - French/German hybrid format (PDF with embedded XML)
- **Facturae 3.2** - Spanish e-invoice format (mandatory for public administration)
- **FatturaPA 1.2** - Italian e-invoice format (mandatory for all invoices in Italy)
- **OIOUBL** - Danish e-invoice format
- **PEPPOL BIS 3.0** - Default Peppol format for most European countries
- **UBL 2.1** - Universal Business Language (most common for Peppol)
- **UBL 2.4** - Updated UBL version with enhanced features
- **ZUGFeRD 1.0** - German e-invoice format (PDF with embedded XML)
- **ZUGFeRD 2.0** - Updated German format, compatible with Factur-X

All format handlers are registered in `FormatHandlerFactory` and have comprehensive PHPUnit test coverage.
The factory automatically selects the appropriate handler based on:
1. Customer's preferred format (if set)
2. Mandatory format for customer's country
3. Recommended format for customer's country
4. Fallback to PEPPOL BIS 3.0

**Format Selection Logging:**
- Info level: Customer's preferred or recommended format unavailable
- Warning level: Mandatory format for country unavailable (serious configuration issue)

### Service Layer Pattern
```php
Expand Down
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
56 changes: 46 additions & 10 deletions Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,25 @@ class FormatHandlerFactory
/**
* Registry of available handlers.
*
* Supported formats:
* - CII: Cross Industry Invoice (UN/CEFACT standard, common in Germany/France)
* - EHF 3.0: Norwegian e-invoice format
* - Factur-X: French/German hybrid format (PDF with embedded XML)
* - Facturae 3.2: Spanish e-invoice format (mandatory for public administration)
* - FatturaPA 1.2: Italian e-invoice format (mandatory for all invoices in Italy)
* - OIOUBL: Danish e-invoice format
* - UBL 2.1/2.4: Universal Business Language (most common for Peppol)
* - PEPPOL BIS 3.0: Default Peppol format for most countries
* - ZUGFeRD 1.0/2.0: German e-invoice format (PDF with embedded XML)
*
* @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,
'fatturapa_1.2' => FatturaPaHandler::class,
'oioubl' => OioublHandler::class,
'peppol_bis_3.0' => PeppolBisHandler::class,
'ubl_2.1' => UblHandler::class,
Expand All @@ -53,14 +64,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 +103,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(),
]);
}
Comment thread
nielsdrost7 marked this conversation as resolved.
}

// 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 thread
nielsdrost7 marked this conversation as resolved.
}
Comment on lines 117 to 131

Copy link
Copy Markdown
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
Loading