Skip to content

[Invoices]: Peppol e-invoicing — pluggable multi-provider architecture #126

Description

@nielsdrost7

Overview

Implement Peppol e-invoicing with a pluggable provider + format architecture so that country-specific access points (LetsPeppol for Belgium, PDP/Sequino for France, Storecove internationally, e-invoice.be for Belgium) and document formats (UBL, CII, Factur-X, NLCIUS, etc.) can be added without touching the core.

Related: #487 (Dutch NLCIUS) is a configuration sub-scope of this issue — not a separate implementation. See NLCIUS section below.

Branch: feature/126-implement-peppolPR #471 (draft, open)


Architecture

Two independent plug-in points:

Provider   = WHO carries the document to the Peppol network
Format     = HOW the invoice document is structured
Modules/Invoices/Peppol/
├── Contracts/
│   └── ProviderInterface.php          ← contract every provider must implement
├── Providers/
│   ├── BaseProvider.php               ← shared error classification, base URL
│   ├── EInvoiceBe/                    ← ✅ FULL
│   │   └── EInvoiceBeProvider.php
│   ├── Storecove/                     ← ⚠️ STUB
│   │   └── StorecoveProvider.php
│   ├── LetsPeppol/                    ← ❌ MISSING
│   │   └── LetsPeppolProvider.php
│   ├── Pdp/                           ← ❌ MISSING (France)
│   │   └── PdpProvider.php
│   └── Sequino/                       ← ❌ MISSING (France)
│       └── SequinoProvider.php
├── FormatHandlers/
│   ├── InvoiceFormatHandlerInterface.php ← contract
│   ├── BaseFormatHandler.php
│   ├── UblHandler.php                 ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── PeppolBisHandler.php           ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── CiiHandler.php                 ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── FacturXHandler.php             ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── ZugferdHandler.php             ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── FatturaPaHandler.php           ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── FacturaeHandler.php            ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── OioublHandler.php              ← ✅ skeleton, ⚠️ generateXml() is a stub
│   ├── EhfHandler.php                 ← ✅ skeleton, ⚠️ generateXml() is a stub
│   └── FormatHandlerFactory.php       ← ✅ strategy pattern, register() for custom formats
└── Services/
    ├── PeppolService.php              ← ✅ send, cancel, status — wired to EInvoiceBe
    ├── PeppolManagementService.php    ← ✅ integration CRUD
    └── PeppolTransformerService.php   ← ✅ data transformation

Adding a new provider is a single directory drop-inProviderFactory scans Peppol/Providers/ subdirectories for *Provider.php classes implementing ProviderInterface. No registration code needed anywhere.


✅ Built (PR #471)

Core infrastructure

  • ProviderInterface — 8-method contract (sendInvoice, validatePeppolId, testConnection, getTransmissionStatus, registerWebhookCallback, fetchAcknowledgements, cancelDocument, classifyError)
  • ProviderFactory — auto-discovers providers via directory scan; make(PeppolIntegration), makeFromName(string), getAvailableProviders(), isSupported()
  • BaseProvider — default error classification (transient/permanent/unknown), base URL resolution, getApiToken()
  • FormatHandlerFactory — strategy pattern; create(PeppolDocumentFormat), createForInvoice(Invoice), register() for runtime additions
  • InvoiceFormatHandlerInterface + BaseFormatHandler
  • PeppolDocumentFormat enum — 11 formats with recommendedForCountry(), formatsForCountry(), isMandatoryFor(), xmlNamespace()
  • PeppolEndpointScheme enum — 17 schemes (BE:CBE, NL:KVK, DE:VAT, FR:SIRENE, IT:VAT, etc.) with country auto-selection

EInvoiceBe provider (full)

  • DocumentsClientsubmitDocument(), getDocumentStatus(), cancelDocument()
  • ParticipantsClientsearchParticipant(), lookupParticipant(), checkCapability(), getServiceMetadata()
  • TrackingClientgetTransmissionHistory(), getStatus(), getDeliveryConfirmation(), listDocuments(), getErrors()
  • WebhooksClient — full CRUD + test + secret rotation
  • HealthClient — ping, status, metrics, connectivity, version, readiness, liveness

Database

  • peppol_integrations — provider_name, api_token, company_id, enabled
  • peppol_integration_config — per-integration key/value config
  • peppol_transmissions — invoice_id, external_id, status, format, provider
  • peppol_transmission_responses — raw provider responses
  • customer_peppol_validation_history
  • customer_peppol_validation_responses
  • relations table: peppol_id, peppol_format, enable_e_invoicing columns added

Models, Events, Jobs, Commands

  • PeppolIntegration, PeppolTransmission, PeppolIntegrationConfig, PeppolTransmissionResponse, CustomerPeppolValidationHistory, CustomerPeppolValidationResponse
  • Events: PeppolTransmissionCreated, PeppolTransmissionSent, PeppolTransmissionFailed, PeppolTransmissionDead, PeppolAcknowledgementReceived, PeppolIntegrationCreated, PeppolIntegrationTested, PeppolIdValidationCompleted
  • Jobs: SendInvoiceToPeppolJob (with retry logic), PeppolStatusPoller, RetryFailedTransmissions
  • Commands: PollPeppolStatusCommand, RetryFailedPeppolTransmissionsCommand, TestPeppolIntegrationCommand
  • LogsPeppolActivity trait, HttpClientExceptionHandler decorator

Tests (15 files, ~71 unit tests, all #[Group('peppol')])

  • ProviderFactoryTest — discovery, caching, instantiation
  • PeppolServiceTest — send, validate, cancel, error handling (Http::fake)
  • FormatHandlerFactoryTest + FormatHandlersTest + FatturaPaHandlerTest
  • PeppolDocumentFormatTest + PeppolEndpointSchemeTest
  • DocumentsClientTest
  • ApiClientTest + HttpClientExceptionHandlerTest
  • SendInvoiceToPeppolActionTest
  • Enum tests: PeppolConnectionStatusTest, PeppolErrorTypeTest, PeppolTransmissionStatusTest, PeppolValidationStatusTest

⚠️ Built but incomplete

Format handlers — generateXml() is a placeholder

All 9 handlers implement the correct data transformation in transform() but generateXml() currently returns json_encode($data) instead of real XML. This must be replaced with proper XML generation before any handler is usable for real transmission.

Priority order for real XML output:

  1. UblHandler (UBL 2.1 / 2.4) — covers NL, BE, most of Europe
  2. PeppolBisHandler (Peppol BIS 3.0) — default fallback
  3. CiiHandler (CII) — covers DE, FR, AT
  4. FacturXHandler — covers FR (PDF/A-3 embedding)
  5. Others: FatturaPA (IT), EHF (NO), OIOUBL (DK), ZUGFeRD (DE), Facturae (ES)

Use horstoeko/ubl (Composer), sabre/xml, or PHP's DOMDocument directly.

StorecoveProvider

All methods return 'not yet implemented'. Needs full API integration against https://api.storecove.com/api/v2.

SendInvoiceToPeppolAction

Currently a standalone action class, not a Filament Action. Needs to extend Filament\Actions\Action so it can be registered in EditInvoice::getHeaderActions() and InvoicesTable.


❌ Missing

Providers to add

Each provider is a single file drop-in:

// Modules/Invoices/Peppol/Providers/LetsPeppol/LetsPeppolProvider.php
namespace Modules\Invoices\Peppol\Providers\LetsPeppol;

use Modules\Invoices\Peppol\Providers\BaseProvider;

class LetsPeppolProvider extends BaseProvider
{
    public function getProviderName(): string { return 'lets_peppol'; }
    protected function getDefaultBaseUrl(): string { return 'https://api.letspeppol.eu'; }

    public function sendInvoice(array $transmissionData): array { /* ... */ }
    public function validatePeppolId(string $scheme, string $id): array { /* ... */ }
    public function testConnection(array $config): array { /* ... */ }
    public function getTransmissionStatus(string $externalId): array { /* ... */ }
    public function cancelDocument(string $externalId): array { /* ... */ }
}

ProviderFactory discovers it automatically. No other files need touching.

Provider Country Directory to create Status
LetsPeppol 🇧🇪 Belgium Providers/LetsPeppol/
PDP (generic) 🇫🇷 France Providers/Pdp/
Sequino 🇫🇷 France (PDP) Providers/Sequino/
Storecove 🌍 International Providers/Storecove/ ⚠️ stub

NLCIUS (Dutch e-invoicing, issue #487)

NLCIUS is not a new format handler — it is a UBL 2.1 document with a specific customizationID. The UblHandler already reads this from config:

// In UblHandler::transform():
'customization_id' => config(
    'invoices.peppol.formats.ubl.customization_id',
    'urn:cen.eu:en16931:2017'
),

Two changes needed:

  1. Fix PeppolDocumentFormat::recommendedForCountry('NL') — currently returns UBL_24 but NLCIUS mandates UBL 2.1:
// In PeppolDocumentFormat::recommendedForCountry():
'NL' => self::UBL_21,  // was UBL_24 — NLCIUS uses 2.1 specifically
  1. Set the NLCIUS customization ID per customer (or globally for NL):
// In config/invoices.php (Peppol section):
'formats' => [
    'ubl' => [
        'customization_id' => env(
            'PEPPOL_UBL_CUSTOMIZATION_ID',
            'urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0'
        ),
    ],
],

NLCIUS is fully covered once UblHandler::generateXml() produces real XML.

Filament UI

  • RelationForm — conditional Peppol section (show only for EU customers): peppol_id (TextInput), peppol_format (Select from PeppolDocumentFormat), enable_e_invoicing (Toggle)
  • Peppol integration settings page per company: select provider, enter API key, test connection button
  • SendInvoiceToPeppolAction as a Filament Action on EditInvoice header and InvoicesTable row
  • Status indicator on invoice list/edit showing Peppol transmission state

Config file

config/invoices.php (or dedicated config/peppol.php) with env-driven defaults as documented in IMPLEMENTATION_SUMMARY.md.

Feature tests

Unit tests cover isolated classes. Integration tests are needed for the full Filament/Livewire flow:

// Modules/Invoices/Tests/Feature/PeppolTransmissionTest.php
#[Test]
#[Group('peppol')]
public function it_sends_invoice_to_peppol_from_invoice_list(): void
{
    Http::fake(['https://api.e-invoice.be/*' => Http::response(['document_id' => 'DOC-001', 'status' => 'submitted'], 200)]);

    $invoice = Invoice::factory()->for($this->company)->create([
        'invoice_status' => InvoiceStatus::SENT,
        'invoice_number' => 'INV-2026-001',
    ]);

    $component = Livewire::actingAs($this->user)
        ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)])
        ->callTableAction('send-peppol', $invoice);

    $component->assertSuccessful();
    $this->assertDatabaseHas('peppol_transmissions', [
        'invoice_id' => $invoice->id,
        'status'     => 'submitted',
    ]);
}

PHPUnit test requirements for new providers

Every new provider must ship a test file:

// Modules/Invoices/Tests/Unit/Peppol/Providers/LetsPeppolProviderTest.php
#[Group('peppol')]
class LetsPeppolProviderTest extends TestCase
{
    #[Test]
    public function it_is_auto_discovered_by_provider_factory(): void
    {
        ProviderFactory::clearCache();
        $this->assertArrayHasKey('lets_peppol', ProviderFactory::getAvailableProviders());
    }

    #[Test]
    public function it_sends_invoice_successfully(): void
    {
        Http::fake(['https://api.letspeppol.eu/*' => Http::response(['id' => 'LP-001', 'status' => 'accepted'], 201)]);

        $provider = ProviderFactory::makeFromName('lets_peppol');
        $result = $provider->sendInvoice(['invoice_xml' => '<Invoice/>']);

        $this->assertTrue($result['accepted']);
        $this->assertEquals('LP-001', $result['external_id']);
    }

    #[Test]
    public function it_validates_peppol_id(): void { /* ... */ }

    #[Test]
    public function it_classifies_transient_errors(): void { /* ... */ }

    #[Test]
    public function it_classifies_permanent_errors(): void { /* ... */ }
}

Sprint 5 scope

Given the work already in PR #471, the remaining Sprint 5 deliverables are:

  1. Make UblHandler::generateXml() produce real UBL 2.1 XML — this unblocks NLCIUS ([Invoices]: NLCIUS (Dutch CIUS) e-invoicing profile for UBL 2.1 export #487), BE, NL, and most of Europe
  2. Fix recommendedForCountry('NL')UBL_21 (one line)
  3. Promote SendInvoiceToPeppolAction to a Filament Action and wire it to EditInvoice
  4. Add Peppol fields to customer form (conditional on EU country)
  5. Add LetsPeppolProvider directory + tests
  6. Add feature test for the end-to-end send flow

Storecove, PDP, and Sequino providers are Sprint 6+.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions