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+.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrefinedRefined with SMART plan / test suggestions

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions