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(),
]);
}
}

// 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(),
]);
}
}

// 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