; do
- ahead=$(git rev-list origin/develop..origin/$branch --count)
- behind=$(git rev-list origin/$branch..origin/develop --count)
- echo "$branch → ahead=$ahead behind=$behind"
-done
-```
-
-Expected: all cleaned branches show `ahead=0 behind=0`.
-
----
-
-## Notes
-
-- Only force-push to branches that are NOT open PRs unless the PR is yours and you
- intend to update it.
-- GitHub Copilot branches (`copilot/*`) are AI-generated; resetting them is safe —
- Copilot will recreate them if needed.
-- The `--diff-filter=A` flag catches files the branch **adds** that develop lacks.
- Files the branch **modifies** relative to develop but which also exist in develop
- are not "unique" — develop's version is preferred.
-- Run `git fetch --prune` first so local remote-tracking refs are current.
diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md
deleted file mode 100644
index 5fd2f26cf..000000000
--- a/.claude/skills/tailwindcss-development/SKILL.md
+++ /dev/null
@@ -1,129 +0,0 @@
----
-name: tailwindcss-development
-description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
-license: MIT
-metadata:
- author: laravel
----
-
-# Tailwind CSS Development
-
-## When to Apply
-
-Activate this skill when:
-
-- Adding styles to components or pages
-- Working with responsive design
-- Implementing dark mode
-- Extracting repeated patterns into components
-- Debugging spacing or layout issues
-
-## Documentation
-
-Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
-
-## Basic Usage
-
-- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
-- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
-- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
-
-## Tailwind CSS v4 Specifics
-
-- Always use Tailwind CSS v4 and avoid deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-
-### CSS-First Configuration
-
-In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
-
-
-```css
-@theme {
- --color-brand: oklch(0.72 0.11 178);
-}
-```
-
-### Import Syntax
-
-In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
-
-
-```diff
-- @tailwind base;
-- @tailwind components;
-- @tailwind utilities;
-+ @import "tailwindcss";
-```
-
-### Replaced Utilities
-
-Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
-
-| Deprecated | Replacement |
-|------------|-------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-## Spacing
-
-Use `gap` utilities instead of margins for spacing between siblings:
-
-
-```html
-
-```
-
-## Dark Mode
-
-If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
-
-
-```html
-
- Content adapts to color scheme
-
-```
-
-## Common Patterns
-
-### Flexbox Layout
-
-
-```html
-
-
Left content
-
Right content
-
-```
-
-### Grid Layout
-
-
-```html
-
-
Card 1
-
Card 2
-
Card 3
-
-```
-
-## Common Pitfalls
-
-- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
-- Using `@tailwind` directives instead of `@import "tailwindcss"`
-- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
-- Using margins for spacing between siblings instead of gap utilities
-- Forgetting to add dark mode variants when the project uses dark mode
diff --git a/.claude/skills/tenant-middleware/SKILL.md b/.claude/skills/tenant-middleware/SKILL.md
deleted file mode 100644
index cea871f52..000000000
--- a/.claude/skills/tenant-middleware/SKILL.md
+++ /dev/null
@@ -1,96 +0,0 @@
----
-name: tenant-middleware
-description: "Understands and modifies the tenant resolution middleware chain. Activates when debugging tenant switching, company access, session-based tenant resolution, URL-based tenant identification, or when the user mentions ConfigureTenant, EnsureUserCanAccessCompany, SetTenantFromQueryString, search_code, or company switching."
-license: MIT
-metadata:
- author: project
----
-
-# Tenant Middleware Chain
-
-Three middlewares run in order on every company panel request. They are registered
-as persistent tenant middleware in `CompanyPanelProvider`.
-
-## 1. SetTenantFromQueryString
-
-**Purpose:** Handle explicit `?tenant=` in the URL (used when switching company).
-
-- Reads the `tenant` query parameter (expects a `search_code` string)
-- Looks up the company by `search_code`
-- Checks the user has access (elevated role OR company membership)
-- Sets Filament tenant and writes `company_id` to session
-- Updates the `tenant` route parameter to the lowercase `search_code`
-
-## 2. ConfigureTenant
-
-**Purpose:** Resolve the active tenant from multiple sources and persist it.
-
-Resolution order:
-1. Route parameter (`{tenant}`)
-2. Query string `?tenant=`
-3. Session `current_company_id`
-4. User's first company (fallback)
-
-Writes resolved company to session and shares it with views.
-
-## 3. EnsureUserCanAccessCompany
-
-**Purpose:** Enforce that the resolved tenant is accessible to the authenticated user.
-
-- Elevated roles (`super_admin`, `admin`, `assist`) bypass — they can access all companies.
-- Regular users must have the company in their `companies()` pivot relationship.
-- Aborts 403 if the user has no access.
-
-## Company Identification
-
-Tenants are identified in URLs by `search_code` (a short alphanumeric string),
-not by numeric `id`. The session stores the numeric `id` (`current_company_id`).
-
-```php
-// URL: /company/invoices?tenant=ivplv2
-// Session: current_company_id = 22
-// Model: Company::where('search_code', 'ivplv2')->first() → id=22
-```
-
-## Switching Companies
-
-The "Switch Company" user menu action redirects with `?tenant=`:
-
-```php
-Action::make('switch-company')
- ->modalContent(fn () => view('filament.company.widgets.switch-company-table'))
-```
-
-The Livewire component inside that modal dispatches a redirect to the new tenant's URL.
-
-## Testing Tenant Switching
-
-```php
-Livewire::actingAs($this->user)
- ->test(SwitchCompanyComponent::class)
- ->callAction('switch', ['company_id' => $otherCompany->id])
- ->assertRedirect(route('filament.company.home', ['tenant' => $otherCompany->search_code]));
-```
-
-
-## Single Source of Truth
-
-Tenant resolution belongs exclusively in the tenant middleware chain.
-
-Controllers, Resources, Pages, Services, and Models must never independently
-resolve the active tenant from the request, session, or URL.
-
-They must rely on:
-
-- Filament::getTenant()
-- injected Company model
-- resolved route parameter
-
-Duplicating tenant resolution logic is an architectural defect.
-
-## Fix-One-Fix-All
-
-If one middleware requires modification due to a tenant resolution bug,
-review all three tenant middlewares for equivalent logic and consistency.
-
-Tenant resolution behavior must remain uniform across the entire middleware chain.
diff --git a/.claude/skills/test-honesty/SKILL.md b/.claude/skills/test-honesty/SKILL.md
deleted file mode 100644
index 954eb8d6a..000000000
--- a/.claude/skills/test-honesty/SKILL.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-name: test-honesty
-description: Ensures factory, seeder, and schema alignment with production database reality
----
-
-# Purpose
-
-Prevents schema drift between migrations, factories, and seeders.
-
----
-
-# 1. Schema Contract
-
-Every NOT NULL column defined in migrations must be supported by:
-
-- factory definition
-- or seeder definition (only for seed data)
-- or explicit DB default in migration
-
-This is a **schema-only rule**, not a validation rule.
-
----
-
-# 2. Factory Rule
-
-Factories MUST produce valid database rows for the schema.
-
-Factories are schema-aligned, not business-logic aware.
-
----
-
-# 3. Seeder Rule
-
-Seeders MUST only insert schema-valid data.
-
-No reliance on implicit database defaults.
-
----
-
-# 4. Database Parity Rule
-
-MySQL / MariaDB is the canonical database.
-
-SQLite differences are invalid for schema validation assumptions.
-
----
-
-# 5. Drift Triggers
-
-The following indicate schema drift:
-
-- migration changes
-- factory mismatch
-- seeder mismatch
-- SQLSTATE constraint violations
-- CI vs local DB mismatch
-
----
-
-# 6. Identity Rule
-
-Primary keys are non-deterministic.
-
-Tests MUST NOT rely on hardcoded IDs.
-
----
-
-# 7. Execution Rule (CI boundary)
-
-Schema validation requires:
-
-- migrate:fresh
-- seed
-
-before running test suites.
-
-This ensures schema correctness before test execution.
diff --git a/.claude/skills/user-auth-fields/SKILL.md b/.claude/skills/user-auth-fields/SKILL.md
deleted file mode 100644
index 365b68a84..000000000
--- a/.claude/skills/user-auth-fields/SKILL.md
+++ /dev/null
@@ -1,115 +0,0 @@
----
-name: user-auth-fields
-description: "Works with the User model's non-standard authentication fields. Activates when writing queries, factories, tests, or seeders that reference the user's email, name, or password; or when the user mentions user_email, user_name, user_password, authentication, login, or the User model."
-license: MIT
-metadata:
- author: project
----
-
-# User Authentication Fields
-
-This app's `users` table does NOT use Laravel's default `name`, `email`, and
-`password` column names. All three are prefixed with `user_`:
-
-| Laravel default | This app |
-|-----------------|----------|
-| `name` | `user_name` |
-| `email` | `user_email` |
-| `password` | `user_password` |
-
-## Model Overrides
-
-The `User` model overrides the auth contract methods:
-
-```php
-public function getAuthIdentifierName(): string
-{
- return 'user_name';
-}
-
-public function getAuthPassword(): string
-{
- return 'user_password';
-}
-```
-
-## Never Use the Default Column Names
-
-```php
-// ✗ WRONG — will cause "Column not found" on MySQL
-User::factory()->create(['name' => 'Test', 'email' => 'test@example.com']);
-
-// ✓ CORRECT
-User::factory()->create(['user_name' => 'Test', 'user_email' => 'test@example.com']);
-```
-
-This includes seeders, tests, and any `User::create()` call.
-
-## Factory Definition
-
-```php
-public function definition(): array
-{
- return [
- 'user_name' => fake()->name(),
- 'user_email' => fake()->unique()->safeEmail(),
- 'user_password' => Hash::make('password'),
- 'user_active' => fake()->boolean(90),
- 'user_all_clients' => fake()->boolean(90),
- 'user_date_created' => now(),
- 'user_date_modified' => now(),
- ];
-}
-```
-
-## Additional Non-Standard Fields
-
-| Standard concept | This app's column |
-|------------------|-------------------|
-| Timestamps | Manual: `user_date_created`, `user_date_modified` |
-| Active flag | `user_active` (boolean) |
-| `$timestamps` | `false` — managed manually |
-
-## Filament Name Display
-
-Filament uses `getFilamentName()` not `name`:
-
-```php
-public function getFilamentName(): string
-{
- return $this->user_name ?? $this->user_email ?? 'User';
-}
-```
-
----
-
-## Authentication Queries
-
-Never query using:
-
-email
-name
-password
-
-Always use:
-
-user_email
-user_name
-user_password
-
-including:
-
-- validation rules
-- login logic
-- factories
-- tests
-- seeders
-- authentication providers
-
----
-
-## Fix-One-Fix-All
-
-If one occurrence of `email`, `name`, or `password` is corrected to the
-application's custom fields, search for equivalent usages throughout the
-repository and update them consistently.
diff --git a/CLAUDE.md b/CLAUDE.md
index 841429cb5..d081e4189 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -203,12 +203,19 @@ own `docker-compose.yml` `app`/`cli` services are unused pre-release, do not use
overrides pointing at MariaDB instead of `.env.testing`'s SQLite default:
```
docker exec -e XDEBUG_MODE=off -e APP_ENV=testing -e DB_CONNECTION=mariadb -e DB_HOST=mariadb -e DB_DATABASE=invoiceplane_test \
- ivpldock-workspace-1 sh -c "cd /var/www/projects/invoiceplane-2/ivplv2 && php artisan test --exclude-group failing,troubleshooting"
+ ivpldock-workspace-1 sh -c "cd /var/www/projects/invoiceplane-2/ivplv2 && php artisan test"
```
Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to
behave differently for this app's Livewire form tests; `artisan test` is the reliable one.
`XDEBUG_MODE=off` is a confirmed ~2-3x speedup for normal runs (the container's php.ini defaults
to `xdebug.mode=debug`); switch to `-e XDEBUG_MODE=coverage` only when running with `--coverage`.
+
+**Do not pass `--exclude-group` on the CLI.** PHPUnit 13's `--exclude-group` was found to
+*override* (not merge with) phpunit.xml's own `` config, and separately fails to
+split a comma-separated value (`failing,troubleshooting`) into multiple groups at all — either way,
+tests tagged `failing`/`flaky`/`troubleshooting`/`slow` end up running instead of being skipped.
+phpunit.xml already excludes those groups by default, so a bare `php artisan test` is correct and
+sufficient — see `.github/workflows/phpunit.yml`'s "Run PHPUnit" step for the same finding.
**Known issue:** a rebuilt/changed environment has reproduced false Livewire-form failures at
scale even under `artisan test`, for reasons not yet isolated — see
[#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689) and sanity-check with
diff --git a/Modules/Clients/Database/Migrations/2025_10_01_002042_add_peppol_fields_to_relations_table.php b/Modules/Clients/Database/Migrations/2025_10_01_002042_add_peppol_fields_to_relations_table.php
new file mode 100644
index 000000000..9b460847b
--- /dev/null
+++ b/Modules/Clients/Database/Migrations/2025_10_01_002042_add_peppol_fields_to_relations_table.php
@@ -0,0 +1,34 @@
+string('peppol_id', 100)->nullable()->after('vat_number')
+ ->comment('Peppol participant identifier (e.g., BE:0123456789)');
+
+ $table->string('peppol_format', 50)->nullable()->after('peppol_id')
+ ->comment('Preferred Peppol document format (matches PeppolDocumentFormat values)');
+
+ $table->boolean('enable_e_invoicing')->default(false)->after('peppol_format')
+ ->comment('Whether e-invoicing via Peppol is enabled for this customer');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('relations', function (Blueprint $table) {
+ $table->dropColumn(['peppol_id', 'peppol_format', 'enable_e_invoicing']);
+ });
+ }
+};
diff --git a/Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php b/Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php
new file mode 100644
index 000000000..29fb34b6d
--- /dev/null
+++ b/Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php
@@ -0,0 +1,44 @@
+string('peppol_scheme', 50)->nullable()->after('peppol_id')
+ ->comment('Peppol endpoint scheme (e.g., BE:CBE, DE:VAT)');
+
+ $table->string('peppol_validation_status', 20)->nullable()->after('enable_e_invoicing')
+ ->comment('Quick lookup: valid, invalid, not_found, error, null');
+
+ $table->text('peppol_validation_message')->nullable()->after('peppol_validation_status')
+ ->comment('Last validation result message');
+
+ $table->timestamp('peppol_validated_at')->nullable()->after('peppol_validation_message')
+ ->comment('When was the Peppol ID last validated');
+ });
+ }
+
+ /**
+ * Removes Peppol-related columns from the `relations` table.
+ *
+ * Drops the columns: `peppol_scheme`, `peppol_validation_status`, `peppol_validation_message`, and `peppol_validated_at`.
+ */
+ public function down(): void
+ {
+ Schema::table('relations', function (Blueprint $table): void {
+ $table->dropColumn(['peppol_scheme', 'peppol_validation_status', 'peppol_validation_message', 'peppol_validated_at']);
+ });
+ }
+};
diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php
index f4af3d69c..0e7b8a49c 100644
--- a/Modules/Clients/Models/Relation.php
+++ b/Modules/Clients/Models/Relation.php
@@ -20,6 +20,7 @@
use Modules\Core\Models\User;
use Modules\Core\Traits\BelongsToCompany;
use Modules\Expenses\Models\Expense;
+use Modules\Invoices\Enums\PeppolValidationStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Payments\Models\Payment;
use Modules\Projects\Models\Project;
@@ -69,9 +70,11 @@ class Relation extends Model
protected $table = 'relations';
protected $casts = [
- 'relation_type' => RelationType::class,
- 'relation_status' => RelationStatus::class,
- 'enable_e_invoicing' => 'boolean',
+ 'relation_type' => RelationType::class,
+ 'relation_status' => RelationStatus::class,
+ 'enable_e_invoicing' => 'boolean',
+ 'peppol_validation_status' => PeppolValidationStatus::class,
+ 'peppol_validated_at' => 'datetime',
];
protected $guarded = [];
@@ -121,7 +124,7 @@ public function communications(): MorphMany
/** @return MorphMany */
public function ccEmailCommunications(): MorphMany
{
- // @phpstan-ignore return.type (larastan narrows MorphMany::whereIn() to a bare Query\Builder; the runtime object is still the relation)
+ // @phpstan-ignore return.type (whereIn() on a typed relation resolves to a bare Query\Builder under PHPStan; the runtime object is still the relation)
return $this->communications()->whereIn('communication_type', CommunicationType::ccTypes());
}
@@ -189,6 +192,16 @@ public function hasLinkedRecords(): bool
|| $this->projects()->withoutGlobalScopes()->exists();
}
+ /**
+ * Whether this customer's Peppol participant ID has been validated against the network.
+ * Set by PeppolManagementService::validatePeppolId() — see peppol_validation_status's
+ * PeppolValidationStatus cast for the other possible states (INVALID/NOT_FOUND/ERROR).
+ */
+ public function hasPeppolIdValidated(): bool
+ {
+ return $this->peppol_validation_status === PeppolValidationStatus::VALID;
+ }
+
/**
* Define a one-to-many relationship to User models.
*
diff --git a/Modules/Core/Database/Migrations/2026_08_18_000001_finish_merchant_clients_table.php b/Modules/Core/Database/Migrations/2026_08_18_000001_finish_merchant_clients_table.php
new file mode 100644
index 000000000..1800ff1a0
--- /dev/null
+++ b/Modules/Core/Database/Migrations/2026_08_18_000001_finish_merchant_clients_table.php
@@ -0,0 +1,46 @@
+unsignedBigInteger('company_id')->nullable()->after('id');
+
+ // Add label for human-friendly identification
+ $table->string('label')->nullable()->after('driver');
+
+ // Add unique constraint: (company_id, driver, merchant_key) — ensures no duplicate credential keys per provider per company
+ $table->unique(['company_id', 'driver', 'merchant_key'], 'unique_merchant_credential_key');
+
+ // Add foreign key on company_id (cascade delete)
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+
+ // client_id is a legacy column from the original Payments-only schema; nothing in
+ // Payments or Peppol reads/writes it (Payments' actual FK is payments.merchant_client_id,
+ // a different column). It has no meaning for the new company/driver/key/value rows this
+ // migration enables, so relax its NOT NULL constraint rather than fabricating a value.
+ $table->integer('client_id')->nullable()->change();
+ });
+ }
+
+ /**
+ * Revert the upgrades.
+ */
+ public function down(): void
+ {
+ Schema::table('merchant_clients', function (Blueprint $table): void {
+ $table->dropForeign('merchant_clients_company_id_foreign');
+ $table->dropUnique('unique_merchant_credential_key');
+ $table->dropColumn(['company_id', 'label']);
+ });
+ }
+};
diff --git a/Modules/Core/Enums/Permission.php b/Modules/Core/Enums/Permission.php
index 3b185f667..c72231d1b 100644
--- a/Modules/Core/Enums/Permission.php
+++ b/Modules/Core/Enums/Permission.php
@@ -98,6 +98,16 @@ enum Permission: string implements LabeledEnum
case EDIT_EMAIL_TEMPLATES = 'edit-email-templates';
case DELETE_EMAIL_TEMPLATES = 'delete-email-templates';
+ case VIEW_MERCHANT_CLIENTS = 'view-merchant-clients';
+ case CREATE_MERCHANT_CLIENTS = 'create-merchant-clients';
+ case EDIT_MERCHANT_CLIENTS = 'edit-merchant-clients';
+ case DELETE_MERCHANT_CLIENTS = 'delete-merchant-clients';
+
+ case VIEW_PEPPOL_INTEGRATIONS = 'view-peppol-integrations';
+ case CREATE_PEPPOL_INTEGRATIONS = 'create-peppol-integrations';
+ case EDIT_PEPPOL_INTEGRATIONS = 'edit-peppol-integrations';
+ case DELETE_PEPPOL_INTEGRATIONS = 'delete-peppol-integrations';
+
// Special Permissions
case MANAGE_CUSTOMERS = 'manage-customers';
diff --git a/Modules/Core/Filament/Admin/Resources/MerchantClients/MerchantClientResource.php b/Modules/Core/Filament/Admin/Resources/MerchantClients/MerchantClientResource.php
index 5a8ba1c6c..0d18159b0 100644
--- a/Modules/Core/Filament/Admin/Resources/MerchantClients/MerchantClientResource.php
+++ b/Modules/Core/Filament/Admin/Resources/MerchantClients/MerchantClientResource.php
@@ -7,10 +7,12 @@
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Admin\Resources\MerchantClients\Pages\ListMerchantClients;
use Modules\Core\Filament\Admin\Resources\MerchantClients\Schemas\MerchantClientForm;
use Modules\Core\Filament\Admin\Resources\MerchantClients\Tables\MerchantClientsTable;
-use Modules\Payments\Models\MerchantClient;
+use Modules\Core\Models\MerchantClient;
class MerchantClientResource extends Resource
{
@@ -40,4 +42,29 @@ public static function getPages(): array
'index' => ListMerchantClients::route('/'),
];
}
+
+ public static function canViewAny(): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_MERCHANT_CLIENTS->value) ?? false;
+ }
+
+ public static function canCreate(): bool
+ {
+ return auth()->user()?->can(Permission::CREATE_MERCHANT_CLIENTS->value) ?? false;
+ }
+
+ public static function canView(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_MERCHANT_CLIENTS->value) ?? false;
+ }
+
+ public static function canEdit(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::EDIT_MERCHANT_CLIENTS->value) ?? false;
+ }
+
+ public static function canDelete(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::DELETE_MERCHANT_CLIENTS->value) ?? false;
+ }
}
diff --git a/Modules/Core/Filament/Admin/Resources/MerchantClients/Schemas/MerchantClientForm.php b/Modules/Core/Filament/Admin/Resources/MerchantClients/Schemas/MerchantClientForm.php
index 41bfd333e..02f6af434 100644
--- a/Modules/Core/Filament/Admin/Resources/MerchantClients/Schemas/MerchantClientForm.php
+++ b/Modules/Core/Filament/Admin/Resources/MerchantClients/Schemas/MerchantClientForm.php
@@ -2,7 +2,14 @@
namespace Modules\Core\Filament\Admin\Resources\MerchantClients\Schemas;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TextInput;
+use Filament\Schemas\Components\Grid;
+use Filament\Schemas\Components\Section;
+use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Illuminate\Validation\Rules\Unique;
+use Modules\Core\Models\Company;
class MerchantClientForm
{
@@ -10,6 +17,50 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
+ Section::make('Credential Details')
+ ->schema([
+ Grid::make(2)
+ ->schema([
+ Select::make('company_id')
+ ->label('Company')
+ ->options(Company::all()->pluck('name', 'id'))
+ ->required()
+ ->searchable()
+ ->preload()
+ ->columnSpan(1),
+
+ TextInput::make('driver')
+ ->label('Driver')
+ ->placeholder('e.g., lets_peppol, storecove')
+ ->required()
+ ->columnSpan(1),
+
+ TextInput::make('label')
+ ->label('Label (optional)')
+ ->placeholder('e.g., Production, Staging')
+ ->columnSpan(2),
+
+ TextInput::make('merchant_key')
+ ->label('Key')
+ ->required()
+ ->unique(
+ table: 'merchant_clients',
+ ignoreRecord: true,
+ modifyRuleUsing: fn (Unique $rule, Get $get) => $rule
+ ->where('company_id', $get('company_id'))
+ ->where('driver', $get('driver')),
+ )
+ ->columnSpan(1),
+
+ TextInput::make('merchant_value')
+ ->label('Value')
+ ->password()
+ ->revealable()
+ ->required()
+ ->columnSpan(1),
+ ]),
+ ])
+ ->columnSpanFull(),
]);
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/MerchantClients/Tables/MerchantClientsTable.php b/Modules/Core/Filament/Admin/Resources/MerchantClients/Tables/MerchantClientsTable.php
index b8e680ae3..418737696 100644
--- a/Modules/Core/Filament/Admin/Resources/MerchantClients/Tables/MerchantClientsTable.php
+++ b/Modules/Core/Filament/Admin/Resources/MerchantClients/Tables/MerchantClientsTable.php
@@ -6,6 +6,7 @@
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
+use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class MerchantClientsTable
@@ -14,6 +15,25 @@ public static function configure(Table $table): Table
{
return $table
->columns([
+ TextColumn::make('company.name')
+ ->label('Company')
+ ->sortable()
+ ->searchable(),
+
+ TextColumn::make('driver')
+ ->label('Driver')
+ ->sortable()
+ ->searchable(),
+
+ TextColumn::make('merchant_key')
+ ->label('Key')
+ ->sortable()
+ ->searchable(),
+
+ TextColumn::make('label')
+ ->label('Label')
+ ->sortable()
+ ->searchable(),
])
->filters([
])
diff --git a/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php b/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php
index 4e3943d5a..fc1af8201 100644
--- a/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php
+++ b/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php
@@ -37,21 +37,6 @@ class CompanyUserResource extends Resource
// query below scopes manually to the current tenant.
protected static bool $isScopedToTenant = false;
- /** The company this list and its actions are scoped to; null → fail closed. */
- private static function currentCompany(): ?Company
- {
- return Filament::getTenant();
- }
-
- /** Single source of truth for who may see and manage the team roster. */
- private static function userMayManageTeam(): bool
- {
- return auth()->user()?->hasRole([
- ...UserRole::elevated(),
- UserRole::CUSTOMER_ADMIN->value,
- ]) ?? false;
- }
-
public static function form(Schema $schema): Schema
{
return $schema->schema([
@@ -69,7 +54,7 @@ public static function table(Table $table): Table
// Fail closed: without a company there is nothing to scope
// this list to, so it must show nothing — falling back to
// User::query() would leak every user across every company.
- return static::currentCompany()?->users() ?? User::query()->whereRaw('1 = 0');
+ return self::currentCompany()?->users() ?? User::query()->whereRaw('1 = 0');
})
->columns([
TextColumn::make('name')
@@ -87,7 +72,7 @@ public static function table(Table $table): Table
->label(trans('ip.remove'))
->icon('heroicon-m-trash')
->color('danger')
- ->action(fn (User $record) => static::currentCompany()?->users()->detach($record->id))
+ ->action(fn (User $record) => self::currentCompany()?->users()->detach($record->id))
->requiresConfirmation(),
])
->bulkActions([
@@ -95,7 +80,7 @@ public static function table(Table $table): Table
DeleteBulkAction::make()
->label(trans('ip.remove'))
->action(function (EloquentCollection|Collection|LazyCollection $records): void {
- $company = static::currentCompany();
+ $company = self::currentCompany();
foreach ($records as $record) {
$company?->users()->detach($record->id);
}
@@ -150,4 +135,21 @@ public static function canDelete(Model $record): bool
return auth()->user()?->hasRole($roles) ?? false;
}
+
+ /** The company this list and its actions are scoped to; null → fail closed. */
+ private static function currentCompany(): ?Company
+ {
+ $tenant = Filament::getTenant();
+
+ return $tenant instanceof Company ? $tenant : null;
+ }
+
+ /** Single source of truth for who may see and manage the team roster. */
+ private static function userMayManageTeam(): bool
+ {
+ return auth()->user()?->hasRole([
+ ...UserRole::elevated(),
+ UserRole::CUSTOMER_ADMIN->value,
+ ]) ?? false;
+ }
}
diff --git a/Modules/Core/Models/MerchantClient.php b/Modules/Core/Models/MerchantClient.php
new file mode 100644
index 000000000..03e2f682c
--- /dev/null
+++ b/Modules/Core/Models/MerchantClient.php
@@ -0,0 +1,41 @@
+ 'encrypted',
+ ];
+
+ public function company()
+ {
+ return $this->belongsTo(Company::class, 'company_id');
+ }
+}
diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php
index 5fca3927e..0f07a8a04 100644
--- a/Modules/Core/Providers/AdminPanelProvider.php
+++ b/Modules/Core/Providers/AdminPanelProvider.php
@@ -23,14 +23,17 @@
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Modules\Core\Filament\Admin\Pages\Dashboard;
use Modules\Core\Filament\Admin\Pages\ImportV1Page;
+//use Modules\Core\Filament\Admin\Pages\ReportTemplates;
use Modules\Core\Filament\Admin\Pages\RolePermissionsPage;
use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\EmailTemplateResource;
+use Modules\Core\Filament\Admin\Resources\MerchantClients\MerchantClientResource;
use Modules\Core\Filament\Admin\Resources\Numberings\NumberingResource;
use Modules\Core\Filament\Admin\Resources\TaxRates\TaxRateResource;
use Modules\Core\Filament\Admin\Resources\Users\UserResource;
use Modules\Core\Filament\Pages\Auth\EditProfile;
use Modules\Core\Filament\Pages\Auth\Login;
+use Modules\Invoices\Filament\Admin\Resources\PeppolIntegrations\PeppolIntegrationResource;
class AdminPanelProvider extends PanelProvider
{
@@ -134,6 +137,10 @@ public function panel(Panel $panel): Panel
->items([
...TaxRateResource::getNavigationItems(),
]),
+ /*NavigationGroup::make(trans('ip.report_templates'))
+ ->items([
+ ...ReportTemplates::getNavigationItems(),
+ ]),*/
/*NavigationGroup::make('System Settings')
->icon('heroicon-o-cog-8-tooth')
@@ -165,6 +172,8 @@ public function panel(Panel $panel): Panel
EmailTemplateResource::class,
TaxRateResource::class,
UserResource::class,
+ MerchantClientResource::class,
+ PeppolIntegrationResource::class,
])
->discoverPages(in: base_path('Modules/Core/Filament/Admin/Pages'), for: 'Modules\Core\Filament\Admin\Pages')
->discoverWidgets(in: base_path('Modules/Core/Filament/Admin/Widgets'), for: 'Modules\Core\Filament\Admin\Widgets')
diff --git a/Modules/Core/Tests/E2E/admin-companies.spec.js b/Modules/Core/Tests/E2E/admin-companies.spec.js
index fa51fee08..cc7b82491 100644
--- a/Modules/Core/Tests/E2E/admin-companies.spec.js
+++ b/Modules/Core/Tests/E2E/admin-companies.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from './test.js';
import { assertRealListContent } from './list-assertions.js';
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
test.describe('Admin: Companies', () => {
test('list page shows real companies', async ({ page }) => {
@@ -81,3 +82,12 @@ test.describe('Admin: Companies', () => {
expect(errors, `unexpected error(s) submitting a duplicate search code:\n${errors.join('\n')}`).toHaveLength(0);
});
});
+
+// mind-the-gap-again: real frontend counterpart to CompanyResource's
+// PHPUnit "it_fails_to_create_X_without_required_Y" tests. See
+// required-field-helpers.js.
+//
+// Left off deliberately: 'slug' — read-only, auto-derived from name.
+registerRequiredFieldOmissionTests('Core', {
+ 'admin/companies': ['search_code', 'name'],
+});
diff --git a/Modules/Core/Tests/E2E/admin-email-templates.spec.js b/Modules/Core/Tests/E2E/admin-email-templates.spec.js
index b506191a6..1a4fcb92d 100644
--- a/Modules/Core/Tests/E2E/admin-email-templates.spec.js
+++ b/Modules/Core/Tests/E2E/admin-email-templates.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from './test.js';
import { assertRealListContent } from './list-assertions.js';
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
test.describe('Admin: Email Templates', () => {
test('list page shows real email templates', async ({ page }) => {
@@ -51,3 +52,10 @@ test.describe('Admin: Email Templates', () => {
await expect(page.locator('table tbody tr').first()).toContainText(title.slice(0, 10), { timeout: 10000 });
});
});
+
+// mind-the-gap-again: real frontend counterpart to EmailTemplateResource's
+// PHPUnit "it_fails_to_create_X_without_required_Y" tests. See
+// required-field-helpers.js.
+registerRequiredFieldOmissionTests('Core', {
+ 'admin/email-templates': ['body'],
+});
diff --git a/Modules/Core/Tests/E2E/admin-numberings.spec.js b/Modules/Core/Tests/E2E/admin-numberings.spec.js
index 48031778d..d3e6c1421 100644
--- a/Modules/Core/Tests/E2E/admin-numberings.spec.js
+++ b/Modules/Core/Tests/E2E/admin-numberings.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from './test.js';
import { assertRealListContent } from './list-assertions.js';
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
test.describe('Admin: Numberings', () => {
test('list page shows real numbering schemes', async ({ page }) => {
@@ -60,3 +61,14 @@ test.describe('Admin: Numberings', () => {
await expect(page.locator('table tbody tr').first()).toContainText(name.slice(0, 10), { timeout: 15000 });
});
});
+
+// mind-the-gap-again: real frontend counterpart to NumberingResource's
+// PHPUnit "it_fails_to_create_X_without_required_Y" tests. See
+// required-field-helpers.js.
+//
+// Left off deliberately: 'next_id' — real user field, but after a
+// missing-field submit the driver can't re-resolve its control; not worth
+// a bespoke path.
+registerRequiredFieldOmissionTests('Core', {
+ 'admin/numberings': ['type', 'name'],
+});
diff --git a/Modules/Core/Tests/E2E/admin-tax-rates.spec.js b/Modules/Core/Tests/E2E/admin-tax-rates.spec.js
index 6df6a4751..19e2d9d26 100644
--- a/Modules/Core/Tests/E2E/admin-tax-rates.spec.js
+++ b/Modules/Core/Tests/E2E/admin-tax-rates.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from './test.js';
import { assertRealListContent } from './list-assertions.js';
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
test.describe('Admin: Tax Rates', () => {
test('list page shows real tax rates', async ({ page }) => {
@@ -85,3 +86,13 @@ test.describe('Admin: Tax Rates', () => {
expect(errors, `unexpected error(s) submitting without a code:\n${errors.join('\n')}`).toHaveLength(0);
});
});
+
+// mind-the-gap-again: real frontend counterpart to TaxRateResource's
+// PHPUnit "it_fails_to_create_X_without_required_Y" tests. See
+// required-field-helpers.js. ('code' is also covered above by a named
+// regression test for the specific historical bug — both are kept:
+// this one is the systematic per-field check, that one documents the
+// incident.)
+registerRequiredFieldOmissionTests('Core', {
+ 'admin/tax-rates': ['tax_rate_type', 'code', 'name'],
+});
diff --git a/Modules/Core/Tests/E2E/admin-users.spec.js b/Modules/Core/Tests/E2E/admin-users.spec.js
index d6be3d2c6..adc5de03d 100644
--- a/Modules/Core/Tests/E2E/admin-users.spec.js
+++ b/Modules/Core/Tests/E2E/admin-users.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from './test.js';
import { assertRealListContent } from './list-assertions.js';
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
test.describe('Admin: Users', () => {
test('list page shows real users', async ({ page }) => {
@@ -40,3 +41,10 @@ test.describe('Admin: Users', () => {
await expect(resultRow).toContainText(userName.slice(0, 10));
});
});
+
+// mind-the-gap-again: real frontend counterpart to UserResource's PHPUnit
+// "it_fails_to_create_X_without_required_Y" tests. See
+// required-field-helpers.js.
+registerRequiredFieldOmissionTests('Core', {
+ 'admin/users': ['name', 'email', 'password'],
+});
diff --git a/Modules/Core/Tests/E2E/company-email-templates.spec.js b/Modules/Core/Tests/E2E/company-email-templates.spec.js
new file mode 100644
index 000000000..f322d02fe
--- /dev/null
+++ b/Modules/Core/Tests/E2E/company-email-templates.spec.js
@@ -0,0 +1,13 @@
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
+
+// mind-the-gap-again: real frontend counterpart to EmailTemplateResource's
+// (company panel) PHPUnit "it_fails_to_create_X_without_required_Y" tests.
+// See required-field-helpers.js.
+//
+// No hand-written list/create tests exist yet for this resource on the
+// company panel — only the required-field-omission check below. Add them
+// here, alongside this one, when that coverage is written; don't split
+// required-field checks back out into a separate file.
+registerRequiredFieldOmissionTests('Core', {
+ 'company/email-templates': ['body'],
+});
diff --git a/Modules/Core/Tests/E2E/company-note-templates.spec.js b/Modules/Core/Tests/E2E/company-note-templates.spec.js
new file mode 100644
index 000000000..e5abcc040
--- /dev/null
+++ b/Modules/Core/Tests/E2E/company-note-templates.spec.js
@@ -0,0 +1,15 @@
+import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
+
+// mind-the-gap-again: real frontend counterpart to the company panel's
+// NoteTemplate resource PHPUnit "it_fails_to_create_X_without_required_Y"
+// tests. See required-field-helpers.js.
+//
+// Left off deliberately: 'template_body' — RichEditor, no native control.
+//
+// No hand-written list/create tests exist yet for this resource — only the
+// required-field-omission check below. Add them here, alongside this one,
+// when that coverage is written; don't split required-field checks back
+// out into a separate file.
+registerRequiredFieldOmissionTests('Core', {
+ 'company/note-templates': ['template_title'],
+});
diff --git a/Modules/Core/Tests/E2E/required-fields.spec.js b/Modules/Core/Tests/E2E/required-fields.spec.js
deleted file mode 100644
index 81a0b6a86..000000000
--- a/Modules/Core/Tests/E2E/required-fields.spec.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { registerRequiredFieldOmissionTests } from './required-field-helpers.js';
-
-/**
- * mind-the-gap-again: real frontend counterpart to this module's PHPUnit
- * "it_fails_to_create_X_without_required_Y" tests — for each field listed,
- * fills a valid create form except that one field and asserts the browser
- * rejects it. See required-field-helpers.js.
- *
- * Left off deliberately:
- * - company_id on every company-panel / tenant-scoped resource (injected).
- * - admin/companies:slug — read-only, auto-derived from name.
- * - admin/numberings:next_id — real user field, but after a missing-field
- * submit the driver can't re-resolve its control; not worth a bespoke path.
- * - company/note-templates:template_body — RichEditor, no native control.
- * - company/company-users — its only typed field is email, and the "Add Team
- * Member" modal's submit isn't reachable by the generic driver;
- * name/password come from the looked-up user. company-users.spec.js covers it.
- */
-registerRequiredFieldOmissionTests('Core', {
- 'admin/companies': ['search_code', 'name'],
- 'admin/numberings': ['type', 'name'],
- 'admin/tax-rates': ['tax_rate_type', 'code', 'name'],
- 'admin/users': ['name', 'email', 'password'],
- 'admin/email-templates': ['body'],
- 'company/email-templates': ['body'],
- 'company/note-templates': ['template_title'],
-});
diff --git a/Modules/Core/Tests/Unit/ToolchainMatchesCiTest.php b/Modules/Core/Tests/Unit/ToolchainMatchesCiTest.php
index dba66c35c..9d1f57ec8 100644
--- a/Modules/Core/Tests/Unit/ToolchainMatchesCiTest.php
+++ b/Modules/Core/Tests/Unit/ToolchainMatchesCiTest.php
@@ -42,7 +42,11 @@ public function yarn_lock_is_in_sync_with_package_json(): void
// clean lock (exit 0, writes nothing) and exits 1 without touching
// yarn.lock on a stale one — so the exit code IS the signal. Also
// guard the string in case a different yarn major changes the code.
- [$out, $exit] = $this->shell('yarn install --frozen-lockfile --non-interactive');
+ // Match every CI workflow's exact command (see .github/workflows/*.yml)
+ // — do not add --non-interactive: Yarn 4 (Berry) rejects that flag
+ // outright with exit 1 regardless of lockfile state, which would make
+ // this check fail even on a perfectly in-sync lockfile.
+ [$out, $exit] = $this->shell('yarn install --frozen-lockfile');
self::assertTrue(
$exit === 0 && ! str_contains($out, 'lockfile needs to be updated'),
diff --git a/Modules/Invoices/Actions/SendInvoiceToPeppolAction.php b/Modules/Invoices/Actions/SendInvoiceToPeppolAction.php
new file mode 100644
index 000000000..1e22a3d18
--- /dev/null
+++ b/Modules/Invoices/Actions/SendInvoiceToPeppolAction.php
@@ -0,0 +1,125 @@
+peppolService = $peppolService;
+ }
+
+ /**
+ * Execute the action to send an invoice to Peppol.
+ *
+ * This method gathers all necessary information from the invoice and
+ * submits it to the Peppol network. It returns the result of the operation.
+ *
+ * @param Invoice $invoice The invoice to send
+ * @param array $additionalData Optional additional data (e.g., Peppol ID)
+ * @param User|null $user Optional user for authorization check; if null, uses current auth user
+ *
+ * @return array The result of the operation
+ *
+ * @throws RequestException If the Peppol API request fails
+ * @throws InvalidArgumentException If the invoice data is invalid
+ * @throws AuthorizationException If user is not authorized to edit the invoice
+ */
+ public function execute(Invoice $invoice, array $additionalData = [], ?User $user = null): array
+ {
+ $user ??= auth()->user();
+
+ // Verify user can edit this invoice
+ if ($user && ! $user->can('update', $invoice)) {
+ throw new AuthorizationException('You are not authorized to send this invoice to Peppol');
+ }
+
+ // Load necessary relationships
+ $invoice->load(['customer', 'invoiceItems']);
+
+ // Validate that invoice is in a state that can be sent
+ $this->validateInvoiceState($invoice);
+
+ // Send to Peppol
+ $result = $this->peppolService->sendInvoiceToPeppol($invoice, $additionalData);
+
+ // Optionally, you could update the invoice record here
+ // to track that it was sent to Peppol (e.g., add a peppol_document_id field)
+ // $invoice->update(['peppol_document_id' => $result['document_id']]);
+
+ return $result;
+ }
+
+ /**
+ * Get the status of a previously sent invoice from Peppol.
+ *
+ * @param string $documentId The Peppol document ID
+ *
+ * @return array Status information
+ *
+ * @throws RequestException If the API request fails
+ */
+ public function getStatus(string $documentId): array
+ {
+ return $this->peppolService->getDocumentStatus($documentId);
+ }
+
+ /**
+ * Cancel a Peppol document transmission.
+ *
+ * @param string $documentId The Peppol document ID
+ *
+ * @return bool True if cancellation was successful
+ *
+ * @throws RequestException If the API request fails
+ */
+ public function cancel(string $documentId): bool
+ {
+ return $this->peppolService->cancelDocument($documentId);
+ }
+
+ /**
+ * Validate that the invoice is in a valid state for Peppol transmission.
+ *
+ * @param Invoice $invoice The invoice to validate
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException If validation fails
+ */
+ protected function validateInvoiceState(Invoice $invoice): void
+ {
+ /* Check if invoice is in draft status - drafts should not be sent */
+ if ($invoice->invoice_status?->value === 'draft' || $invoice->invoice_status === \Modules\Invoices\Enums\InvoiceStatus::DRAFT) {
+ throw new InvalidArgumentException('Cannot send draft invoices to Peppol');
+ }
+
+ /* Additional business logic validation can be added here */
+ }
+}
diff --git a/Modules/Invoices/Config/config.php b/Modules/Invoices/Config/config.php
index 911e51fd9..92f631488 100644
--- a/Modules/Invoices/Config/config.php
+++ b/Modules/Invoices/Config/config.php
@@ -22,6 +22,7 @@
|
*/
'default_provider' => env('PEPPOL_PROVIDER', 'e_invoice_be'),
+ /* Supported: "e_invoice_be", "storecove", "lets_peppol", "super_pdp", "qonto" */
/*
|--------------------------------------------------------------------------
@@ -39,6 +40,22 @@
'timeout' => env('PEPPOL_E_INVOICE_BE_TIMEOUT', 30),
],
+ /*
+ |--------------------------------------------------------------------------
+ | Storecove Configuration
+ |--------------------------------------------------------------------------
+ |
+ | Configuration for the Storecove Peppol access point.
+ | See: https://www.storecove.com/documentation/api
+ |
+ */
+ 'storecove' => [
+ 'api_key' => env('PEPPOL_STORECOVE_API_KEY', ''),
+ 'legal_entity_id' => env('PEPPOL_STORECOVE_LEGAL_ENTITY_ID', ''),
+ 'base_url' => env('PEPPOL_STORECOVE_BASE_URL', 'https://api.storecove.com/api/v2'),
+ 'timeout' => env('PEPPOL_STORECOVE_TIMEOUT', 30),
+ ],
+
/*
|--------------------------------------------------------------------------
| Peppol Document Settings
diff --git a/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php b/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php
new file mode 100644
index 000000000..59a32f937
--- /dev/null
+++ b/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php
@@ -0,0 +1,42 @@
+command('peppol:poll-status')->everyFifteenMinutes();
+ */
+class PollPeppolStatusCommand extends Command
+{
+ protected $signature = 'peppol:poll-status';
+
+ protected $description = 'Poll Peppol provider for transmission status updates';
+
+ /**
+ * Triggers a background job to poll Peppol transmission statuses and reports the result.
+ *
+ * @return int exit code: `self::SUCCESS` if the polling job was dispatched successfully, `self::FAILURE` if dispatch failed
+ */
+ public function handle(): int
+ {
+ $this->info('Starting Peppol status polling...');
+
+ try {
+ PeppolStatusPoller::dispatch();
+
+ $this->info('Peppol status polling job dispatched successfully.');
+
+ return self::SUCCESS;
+ } catch (Exception $e) {
+ $this->error('Failed to dispatch status polling job: ' . $e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php b/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php
new file mode 100644
index 000000000..b018ec9b3
--- /dev/null
+++ b/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php
@@ -0,0 +1,44 @@
+command('peppol:retry-failed')->everyMinute();
+ */
+class RetryFailedPeppolTransmissionsCommand extends Command
+{
+ protected $signature = 'peppol:retry-failed';
+
+ protected $description = 'Retry failed Peppol transmissions that are ready for retry';
+
+ /**
+ * Dispatches a job to retry failed Peppol transmissions and reports the outcome.
+ *
+ * Dispatches the RetryFailedTransmissions job; on success it emits informational output and returns a success exit code, on failure it emits an error message and returns a failure exit code.
+ *
+ * @return int self::SUCCESS if the job was dispatched successfully, self::FAILURE if an exception occurred while dispatching
+ */
+ public function handle(): int
+ {
+ $this->info('Starting retry of failed Peppol transmissions...');
+
+ try {
+ RetryFailedTransmissions::dispatch();
+
+ $this->info('Retry job dispatched successfully.');
+
+ return self::SUCCESS;
+ } catch (Exception $e) {
+ $this->error('Failed to dispatch retry job: ' . $e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php b/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php
new file mode 100644
index 000000000..082df2c5e
--- /dev/null
+++ b/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php
@@ -0,0 +1,57 @@
+argument('integration_id');
+
+ $integration = PeppolIntegration::query()->find($integrationId);
+
+ if ( ! $integration) {
+ $this->error("Integration {$integrationId} not found.");
+
+ return self::FAILURE;
+ }
+
+ $this->info("Testing connection for integration: {$integration->provider_name}...");
+
+ $result = $service->testConnection($integration);
+
+ if ($result['ok']) {
+ $this->info('✓ Connection test successful!');
+ $this->line($result['message']);
+
+ return self::SUCCESS;
+ }
+ $this->error('✗ Connection test failed.');
+ $this->error($result['message']);
+
+ return self::FAILURE;
+ }
+}
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php
new file mode 100644
index 000000000..cadd68b2e
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->unsignedBigInteger('company_id');
+ $table->string('provider_name', 50)->comment('e.g., e_invoice_be, storecove');
+ $table->text('encrypted_api_token')->nullable()->comment('Encrypted API credentials');
+ $table->string('test_connection_status', 20)->default('untested')->comment('untested, success, failed');
+ $table->text('test_connection_message')->nullable()->comment('Last test connection result message');
+ $table->timestamp('test_connection_at')->nullable();
+ $table->boolean('enabled')->default(false)->comment('Whether integration is active');
+
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+ $table->index(['company_id', 'enabled']);
+ $table->index('provider_name');
+ });
+ }
+
+ /**
+ * Drop the `peppol_integrations` table if it exists.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('peppol_integrations');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php
new file mode 100644
index 000000000..469a8ee4a
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->unsignedBigInteger('integration_id');
+ $table->string('config_key', 100);
+ $table->text('config_value');
+
+ $table->foreign('integration_id')->references('id')->on('peppol_integrations')->onDelete('cascade');
+ $table->index(['integration_id', 'config_key']);
+ });
+ }
+
+ /**
+ * Drop the `peppol_integration_config` table if it exists.
+ *
+ * Removes the database table created for storing Peppol integration configuration entries.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('peppol_integration_config');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php
new file mode 100644
index 000000000..942b197b9
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php
@@ -0,0 +1,58 @@
+id();
+ $table->unsignedBigInteger('company_id');
+ $table->unsignedBigInteger('invoice_id');
+ $table->unsignedBigInteger('customer_id');
+ $table->unsignedBigInteger('integration_id');
+ $table->string('format', 50)->comment('Document format used (e.g., peppol_bis_3.0, ubl_2.1)');
+ $table->string('status', 20)->default('pending')->comment('pending, queued, processing, sent, accepted, rejected, failed, retrying, dead');
+ $table->unsignedInteger('attempts')->default(0);
+ $table->string('idempotency_key', 64)->unique()->comment('Hash to prevent duplicate transmissions');
+ $table->string('external_id')->nullable()->comment('Provider transaction/document ID');
+ $table->string('stored_xml_path')->nullable()->comment('Path to stored XML file');
+ $table->string('stored_pdf_path')->nullable()->comment('Path to stored PDF file');
+ $table->text('last_error')->nullable()->comment('Last error message if failed');
+ $table->string('error_type', 20)->nullable()->comment('TRANSIENT, PERMANENT, UNKNOWN');
+ $table->timestamp('sent_at')->nullable();
+ $table->timestamp('acknowledged_at')->nullable();
+ $table->timestamp('next_retry_at')->nullable();
+ $table->timestamp('created_at')->nullable();
+ $table->timestamp('updated_at')->nullable();
+
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+ $table->foreign('invoice_id')->references('id')->on('invoices')->onDelete('cascade');
+ $table->foreign('customer_id')->references('id')->on('relations')->onDelete('cascade');
+ $table->foreign('integration_id')->references('id')->on('peppol_integrations')->onDelete('cascade');
+
+ $table->index(['company_id', 'status']);
+ $table->index(['invoice_id', 'integration_id']);
+ $table->index('status');
+ $table->index('external_id');
+ $table->index('next_retry_at');
+ });
+ }
+
+ /**
+ * Reverses the migration by dropping the `peppol_transmissions` table if it exists.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('peppol_transmissions');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php
new file mode 100644
index 000000000..5b153139f
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php
@@ -0,0 +1,39 @@
+id();
+ $table->unsignedBigInteger('company_id');
+ $table->unsignedBigInteger('transmission_id');
+ $table->string('response_key', 100);
+ $table->text('response_value');
+
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+ $table->foreign('transmission_id')->references('id')->on('peppol_transmissions')->onDelete('cascade');
+ $table->index(['company_id', 'transmission_id']);
+ $table->index(['transmission_id', 'response_key']);
+ });
+ }
+
+ /**
+ * Reverts the migration by dropping the `peppol_transmission_responses` table if it exists.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('peppol_transmission_responses');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php
new file mode 100644
index 000000000..d155eb312
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php
@@ -0,0 +1,48 @@
+id();
+ $table->unsignedBigInteger('company_id');
+ $table->unsignedBigInteger('customer_id');
+ $table->unsignedBigInteger('integration_id')->nullable()->comment('Which integration was used for validation');
+ $table->unsignedBigInteger('validated_by')->nullable()->comment('User who triggered validation');
+ $table->string('peppol_scheme', 50);
+ $table->string('peppol_id', 100);
+ $table->string('validation_status', 20)->comment('valid, invalid, not_found, error');
+ $table->text('validation_message')->nullable();
+ $table->timestamp('created_at')->nullable();
+ $table->timestamp('updated_at')->nullable();
+
+ $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
+ $table->foreign('customer_id')->references('id')->on('relations')->onDelete('cascade');
+ $table->foreign('integration_id')->references('id')->on('peppol_integrations')->onDelete('set null');
+ $table->foreign('validated_by')->references('id')->on('users')->onDelete('set null');
+
+ $table->index(['company_id', 'customer_id']);
+ $table->index(['customer_id', 'created_at']);
+ $table->index('validation_status');
+ });
+ }
+
+ /**
+ * Reverts the migration by removing the customer_peppol_validation_history table.
+ *
+ * Drops the table if it exists.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('customer_peppol_validation_history');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php b/Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php
new file mode 100644
index 000000000..63b1ccca9
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php
@@ -0,0 +1,39 @@
+id();
+ $table->unsignedBigInteger('validation_history_id');
+ $table->string('response_key', 100);
+ $table->text('response_value');
+
+ $table->foreign('validation_history_id', 'fk_peppol_validation_responses')
+ ->references('id')->on('customer_peppol_validation_history')->onDelete('cascade');
+ $table->index(['validation_history_id', 'response_key'], 'idx_validation_responses');
+ });
+ }
+
+ /**
+ * Remove the customer_peppol_validation_responses table from the database.
+ *
+ * Drops the table if it exists.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('customer_peppol_validation_responses');
+ }
+};
diff --git a/Modules/Invoices/Database/Migrations/2026_08_18_000002_drop_peppol_integration_config_table.php b/Modules/Invoices/Database/Migrations/2026_08_18_000002_drop_peppol_integration_config_table.php
new file mode 100644
index 000000000..17984823a
--- /dev/null
+++ b/Modules/Invoices/Database/Migrations/2026_08_18_000002_drop_peppol_integration_config_table.php
@@ -0,0 +1,61 @@
+get();
+
+ foreach ($integrations as $integration) {
+ // Fetch config entries for this integration
+ $configs = DB::table('peppol_integration_config')
+ ->where('integration_id', $integration->id)
+ ->get();
+
+ foreach ($configs as $config) {
+ DB::table('merchant_clients')->insertOrIgnore([
+ 'company_id' => $integration->company_id,
+ 'driver' => $integration->provider_name,
+ 'merchant_key' => $config->config_key,
+ 'merchant_value' => $config->config_value,
+ 'label' => null,
+ ]);
+ }
+ }
+ });
+ }
+
+ // Drop the table
+ Schema::dropIfExists('peppol_integration_config');
+ }
+
+ /**
+ * Revert: recreate the table (but don't migrate data back — that's a one-way upgrade).
+ */
+ public function down(): void
+ {
+ Schema::create('peppol_integration_config', function ($table): void {
+ $table->id();
+ $table->unsignedBigInteger('integration_id');
+ $table->string('config_key', 100);
+ $table->text('config_value');
+
+ $table->foreign('integration_id')->references('id')->on('peppol_integrations')->onDelete('cascade');
+ $table->index(['integration_id', 'config_key']);
+ });
+ }
+};
diff --git a/Modules/Invoices/Enums/PeppolConnectionStatus.php b/Modules/Invoices/Enums/PeppolConnectionStatus.php
new file mode 100644
index 000000000..40a513e0e
--- /dev/null
+++ b/Modules/Invoices/Enums/PeppolConnectionStatus.php
@@ -0,0 +1,57 @@
+ 'Untested',
+ self::SUCCESS => 'Success',
+ self::FAILED => 'Failed',
+ };
+ }
+
+ /**
+ * The display color name for the Peppol connection status.
+ *
+ * @return string the color name for the status: 'gray' for UNTESTED, 'green' for SUCCESS, 'red' for FAILED
+ */
+ public function color(): string
+ {
+ return match ($this) {
+ self::UNTESTED => 'gray',
+ self::SUCCESS => 'green',
+ self::FAILED => 'red',
+ };
+ }
+
+ /**
+ * Get the icon identifier associated with the current status.
+ *
+ * @return string the icon identifier corresponding to the enum case
+ */
+ public function icon(): string
+ {
+ return match ($this) {
+ self::UNTESTED => 'heroicon-o-question-mark-circle',
+ self::SUCCESS => 'heroicon-o-check-circle',
+ self::FAILED => 'heroicon-o-x-circle',
+ };
+ }
+}
diff --git a/Modules/Invoices/Enums/PeppolErrorType.php b/Modules/Invoices/Enums/PeppolErrorType.php
new file mode 100644
index 000000000..2a2b1adb3
--- /dev/null
+++ b/Modules/Invoices/Enums/PeppolErrorType.php
@@ -0,0 +1,57 @@
+ 'Transient Error',
+ self::PERMANENT => 'Permanent Error',
+ self::UNKNOWN => 'Unknown Error',
+ };
+ }
+
+ /**
+ * Gets the UI color identifier associated with this Peppol error type.
+ *
+ * @return string the color identifier: 'yellow' for TRANSIENT, 'red' for PERMANENT, 'gray' for UNKNOWN
+ */
+ public function color(): string
+ {
+ return match ($this) {
+ self::TRANSIENT => 'yellow',
+ self::PERMANENT => 'red',
+ self::UNKNOWN => 'gray',
+ };
+ }
+
+ /**
+ * Get the icon identifier corresponding to this error type.
+ *
+ * @return string the icon identifier for the enum case
+ */
+ public function icon(): string
+ {
+ return match ($this) {
+ self::TRANSIENT => 'heroicon-o-arrow-path',
+ self::PERMANENT => 'heroicon-o-x-circle',
+ self::UNKNOWN => 'heroicon-o-question-mark-circle',
+ };
+ }
+}
diff --git a/Modules/Invoices/Enums/PeppolTransmissionStatus.php b/Modules/Invoices/Enums/PeppolTransmissionStatus.php
new file mode 100644
index 000000000..4367d8fd3
--- /dev/null
+++ b/Modules/Invoices/Enums/PeppolTransmissionStatus.php
@@ -0,0 +1,118 @@
+ 'Pending',
+ self::QUEUED => 'Queued',
+ self::PROCESSING => 'Processing',
+ self::SENT => 'Sent',
+ self::ACCEPTED => 'Accepted',
+ self::REJECTED => 'Rejected',
+ self::FAILED => 'Failed',
+ self::RETRYING => 'Retrying',
+ self::DEAD => 'Dead',
+ };
+ }
+
+ /**
+ * Get the UI color name associated with the transmission status.
+ *
+ * @return string The color name (CSS/tailwind-style) representing this status, e.g. 'gray', 'blue', 'green', 'red'.
+ */
+ public function color(): string
+ {
+ return match ($this) {
+ self::PENDING => 'gray',
+ self::QUEUED => 'blue',
+ self::PROCESSING => 'yellow',
+ self::SENT => 'indigo',
+ self::ACCEPTED => 'green',
+ self::REJECTED => 'red',
+ self::FAILED => 'orange',
+ self::RETRYING => 'purple',
+ self::DEAD => 'red',
+ };
+ }
+
+ /**
+ * Get the Heroicon identifier representing the transmission status.
+ *
+ * @return string the Heroicon identifier corresponding to the enum case
+ */
+ public function icon(): string
+ {
+ return match ($this) {
+ self::PENDING => 'heroicon-o-clock',
+ self::QUEUED => 'heroicon-o-queue-list',
+ self::PROCESSING => 'heroicon-o-arrow-path',
+ self::SENT => 'heroicon-o-paper-airplane',
+ self::ACCEPTED => 'heroicon-o-check-circle',
+ self::REJECTED => 'heroicon-o-x-circle',
+ self::FAILED => 'heroicon-o-exclamation-triangle',
+ self::RETRYING => 'heroicon-o-arrow-path',
+ self::DEAD => 'heroicon-o-no-symbol',
+ };
+ }
+
+ /**
+ * Determine whether the transmission status is final.
+ *
+ * @return bool `true` if the status is `ACCEPTED`, `REJECTED`, or `DEAD`, `false` otherwise
+ */
+ public function isFinal(): bool
+ {
+ return in_array($this, [
+ self::ACCEPTED,
+ self::REJECTED,
+ self::DEAD,
+ ]);
+ }
+
+ /**
+ * Determines whether the transmission status permits a retry.
+ *
+ * @return bool `true` if the status is FAILED or RETRYING, `false` otherwise
+ */
+ public function canRetry(): bool
+ {
+ return in_array($this, [
+ self::FAILED,
+ self::RETRYING,
+ ]);
+ }
+
+ /**
+ * Indicates the status is awaiting acknowledgment.
+ *
+ * @return bool `true` if the status is awaiting acknowledgment (SENT), `false` otherwise
+ */
+ public function isAwaitingAck(): bool
+ {
+ return $this === self::SENT;
+ }
+}
diff --git a/Modules/Invoices/Enums/PeppolValidationStatus.php b/Modules/Invoices/Enums/PeppolValidationStatus.php
new file mode 100644
index 000000000..2c9b401c6
--- /dev/null
+++ b/Modules/Invoices/Enums/PeppolValidationStatus.php
@@ -0,0 +1,61 @@
+ 'Valid',
+ self::INVALID => 'Invalid',
+ self::NOT_FOUND => 'Not Found',
+ self::ERROR => 'Error',
+ };
+ }
+
+ /**
+ * Get the UI color name associated with the Peppol validation status.
+ *
+ * @return string the color name: `'green'` for `VALID`, `'red'` for `INVALID` and `ERROR`, and `'orange'` for `NOT_FOUND`
+ */
+ public function color(): string
+ {
+ return match ($this) {
+ self::VALID => 'green',
+ self::INVALID => 'red',
+ self::NOT_FOUND => 'orange',
+ self::ERROR => 'red',
+ };
+ }
+
+ /**
+ * Get the UI icon identifier for this Peppol validation status.
+ *
+ * @return string The icon identifier corresponding to the status (e.g. "heroicon-o-check-circle").
+ */
+ public function icon(): string
+ {
+ return match ($this) {
+ self::VALID => 'heroicon-o-check-circle',
+ self::INVALID => 'heroicon-o-x-circle',
+ self::NOT_FOUND => 'heroicon-o-question-mark-circle',
+ self::ERROR => 'heroicon-o-exclamation-triangle',
+ };
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php b/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php
new file mode 100644
index 000000000..60861fc26
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php
@@ -0,0 +1,41 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'external_id' => $transmission->external_id,
+ 'status' => $transmission->status,
+ 'ack_payload' => $ackPayload,
+ ]);
+ }
+
+ /**
+ * Event name for a received Peppol acknowledgement.
+ *
+ * @return string The event name "peppol.acknowledgement.received".
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.acknowledgement.received';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolEvent.php b/Modules/Invoices/Events/Peppol/PeppolEvent.php
new file mode 100644
index 000000000..4a9a6af94
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolEvent.php
@@ -0,0 +1,51 @@
+payload = $payload;
+ $this->occurredAt = now();
+ }
+
+ /**
+ * Provide the event name used for audit logging.
+ *
+ * @return string the event name to include in the audit payload
+ */
+ abstract public function getEventName(): string;
+
+ /**
+ * Build a payload suitable for audit logging by merging the event payload with metadata.
+ *
+ * @return array the original payload merged with `event` (event name) and `occurred_at` (ISO 8601 timestamp)
+ */
+ public function getAuditPayload(): array
+ {
+ return array_merge($this->payload, [
+ 'event' => $this->getEventName(),
+ 'occurred_at' => $this->occurredAt->toIso8601String(),
+ ]);
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php b/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php
new file mode 100644
index 000000000..9a48a30a2
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php
@@ -0,0 +1,45 @@
+customer = $customer;
+ $this->validationStatus = $validationStatus;
+
+ parent::__construct(array_merge([
+ 'customer_id' => $customer->id,
+ 'peppol_id' => $customer->peppol_id,
+ 'peppol_scheme' => $customer->peppol_scheme,
+ 'validation_status' => $validationStatus,
+ ], $details));
+ }
+
+ /**
+ * Get the event's canonical name.
+ *
+ * @return string The event name 'peppol.id_validation.completed'.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.id_validation.completed';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php b/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php
new file mode 100644
index 000000000..db050ab8c
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php
@@ -0,0 +1,38 @@
+integration = $integration;
+ parent::__construct([
+ 'integration_id' => $integration->id,
+ 'provider_name' => $integration->provider_name,
+ 'company_id' => $integration->company_id,
+ ]);
+ }
+
+ /**
+ * Get the event name for a created Peppol integration.
+ *
+ * @return string The event name "peppol.integration.created".
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.integration.created';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php b/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php
new file mode 100644
index 000000000..44183d819
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php
@@ -0,0 +1,45 @@
+integration = $integration;
+ $this->success = $success;
+
+ parent::__construct([
+ 'integration_id' => $integration->id,
+ 'provider_name' => $integration->provider_name,
+ 'success' => $success,
+ 'message' => $message,
+ ]);
+ }
+
+ /**
+ * Returns the canonical name of this event.
+ *
+ * @return string The event name "peppol.integration.tested".
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.integration.tested';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php
new file mode 100644
index 000000000..f5e894e88
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php
@@ -0,0 +1,43 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'customer_id' => $transmission->customer_id,
+ 'integration_id' => $transmission->integration_id,
+ 'format' => $transmission->format,
+ 'status' => $transmission->status,
+ ]);
+ }
+
+ /**
+ * Get the event name for a created Peppol transmission.
+ *
+ * @return string The event name `peppol.transmission.created`.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.transmission.created';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php
new file mode 100644
index 000000000..1eecac958
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php
@@ -0,0 +1,39 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'attempts' => $transmission->attempts,
+ 'last_error' => $transmission->last_error,
+ 'reason' => $reason,
+ ]);
+ }
+
+ /**
+ * Event name for a Peppol transmission that has reached the dead state.
+ *
+ * @return string The event name 'peppol.transmission.dead'.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.transmission.dead';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php
new file mode 100644
index 000000000..cf7d2f3a8
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php
@@ -0,0 +1,44 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'status' => $transmission->status,
+ 'error' => $error ?? $transmission->last_error,
+ 'error_type' => $transmission->error_type,
+ 'attempts' => $transmission->attempts,
+ ]);
+ }
+
+ /**
+ * Retrieve the canonical event name for a failed Peppol transmission.
+ *
+ * @return string The event name 'peppol.transmission.failed'.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.transmission.failed';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php
new file mode 100644
index 000000000..c86d517fb
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php
@@ -0,0 +1,38 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'format' => $transmission->format,
+ 'xml_path' => $transmission->stored_xml_path,
+ 'pdf_path' => $transmission->stored_pdf_path,
+ ]);
+ }
+
+ /**
+ * Event name for a prepared Peppol transmission.
+ *
+ * @return string The event name 'peppol.transmission.prepared'.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.transmission.prepared';
+ }
+}
diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php
new file mode 100644
index 000000000..37d836141
--- /dev/null
+++ b/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php
@@ -0,0 +1,40 @@
+transmission = $transmission;
+
+ parent::__construct([
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $transmission->invoice_id,
+ 'external_id' => $transmission->external_id,
+ 'status' => $transmission->status,
+ ]);
+ }
+
+ /**
+ * Return the canonical name of this event.
+ *
+ * @return string The event name 'peppol.transmission.sent'.
+ */
+ public function getEventName(): string
+ {
+ return 'peppol.transmission.sent';
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/CreatePeppolIntegration.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/CreatePeppolIntegration.php
new file mode 100644
index 000000000..94dd1c6b8
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/CreatePeppolIntegration.php
@@ -0,0 +1,23 @@
+createIntegration(
+ $data['company_id'],
+ $data['provider_name'],
+ $data
+ );
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/EditPeppolIntegration.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/EditPeppolIntegration.php
new file mode 100644
index 000000000..fc30ab45b
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/EditPeppolIntegration.php
@@ -0,0 +1,53 @@
+getRecord();
+
+ $providerClass = ProviderFactory::getProviderClass($record->provider_name);
+ $keys = array_diff($providerClass::settings(), $providerClass::managedSettingsKeys());
+
+ foreach ($keys as $key) {
+ $data[$key] = $record->getConfigValue($key);
+ }
+
+ return $data;
+ }
+
+ protected function handleRecordUpdate(\Illuminate\Database\Eloquent\Model $record, array $data): \Illuminate\Database\Eloquent\Model
+ {
+ $service = app(PeppolManagementService::class);
+
+ return $service->updateIntegration(
+ $record,
+ $data,
+ $data['enabled'] ?? null
+ );
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/ListPeppolIntegrations.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/ListPeppolIntegrations.php
new file mode 100644
index 000000000..b3eda6277
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Pages/ListPeppolIntegrations.php
@@ -0,0 +1,25 @@
+table);
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/PeppolIntegrationResource.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/PeppolIntegrationResource.php
new file mode 100644
index 000000000..372f31a4c
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/PeppolIntegrationResource.php
@@ -0,0 +1,73 @@
+ ListPeppolIntegrations::route('/'),
+ 'create' => CreatePeppolIntegration::route('/create'),
+ 'edit' => EditPeppolIntegration::route('/{record}/edit'),
+ ];
+ }
+
+ public static function form(Schema $schema): Schema
+ {
+ return PeppolIntegrationForm::configure($schema);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return PeppolIntegrationsTable::configure($table);
+ }
+
+ public static function canViewAny(): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_PEPPOL_INTEGRATIONS->value) ?? false;
+ }
+
+ public static function canCreate(): bool
+ {
+ return auth()->user()?->can(Permission::CREATE_PEPPOL_INTEGRATIONS->value) ?? false;
+ }
+
+ public static function canView(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_PEPPOL_INTEGRATIONS->value) ?? false;
+ }
+
+ public static function canEdit(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::EDIT_PEPPOL_INTEGRATIONS->value) ?? false;
+ }
+
+ public static function canDelete(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::DELETE_PEPPOL_INTEGRATIONS->value) ?? false;
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Schemas/PeppolIntegrationForm.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Schemas/PeppolIntegrationForm.php
new file mode 100644
index 000000000..a3b60a603
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Schemas/PeppolIntegrationForm.php
@@ -0,0 +1,122 @@
+components([
+ // Basic settings
+ Section::make('Integration Setup')
+ ->schema([
+ Grid::make(2)
+ ->schema([
+ Select::make('company_id')
+ ->label('Company')
+ ->options(Company::all()->pluck('name', 'id'))
+ ->required()
+ ->searchable()
+ ->preload()
+ ->columnSpan(1),
+
+ Select::make('provider_name')
+ ->label('Provider')
+ ->options(self::getProviderOptions())
+ ->required()
+ ->live()
+ ->columnSpan(1),
+
+ Toggle::make('enabled')
+ ->label('Enabled')
+ ->default(false)
+ ->columnSpan(2),
+ ]),
+ ])
+ ->columnSpanFull(),
+
+ // Dynamic provider-specific settings
+ Section::make('Provider Configuration')
+ ->schema(fn (Get $get): array => self::getDynamicProviderFields($get('provider_name')))
+ ->columnSpanFull()
+ ->visible(fn (Get $get) => ! empty($get('provider_name'))),
+ ]);
+ }
+
+ /**
+ * Get available providers from ProviderFactory.
+ *
+ * @return array
+ */
+ private static function getProviderOptions(): array
+ {
+ try {
+ $providers = ProviderFactory::getAvailableProviders();
+
+ return collect($providers)
+ ->mapWithKeys(fn (string $class, string $name): array => [$name => ucfirst(str_replace('_', ' ', $name))])
+ ->all();
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ /**
+ * Build one credential input per key the selected provider declares in settings(),
+ * excluding managedSettingsKeys() (system-managed state like an OAuth2 access_token
+ * that an admin should never type in — see ProviderInterface::managedSettingsKeys()).
+ *
+ * @return array
+ */
+ private static function getDynamicProviderFields(?string $providerName): array
+ {
+ if (empty($providerName)) {
+ return [];
+ }
+
+ try {
+ $providerClass = ProviderFactory::getProviderClass($providerName);
+ } catch (Throwable $e) {
+ return [];
+ }
+
+ $keys = array_diff($providerClass::settings(), $providerClass::managedSettingsKeys());
+
+ return collect($keys)
+ ->map(function (string $key) use ($providerName) {
+ // Not a real model column — reaches PeppolManagementService::createIntegration()/
+ // updateIntegration() through the same $data array as company_id/provider_name,
+ // which filters it down to just the provider's declared credential keys before
+ // persisting via setConfig().
+ $field = TextInput::make($key)
+ ->label(ucfirst(str_replace('_', ' ', $key)))
+ ->default(function (?PeppolIntegration $record) use ($key, $providerName) {
+ if ( ! $record || $record->provider_name !== $providerName) {
+ return null;
+ }
+
+ return $record->getConfigValue($key);
+ });
+
+ if (str_contains($key, 'key') || str_contains($key, 'secret') || str_contains($key, 'token')) {
+ $field = $field->password()->revealable();
+ }
+
+ return $field;
+ })
+ ->all();
+ }
+}
diff --git a/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Tables/PeppolIntegrationsTable.php b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Tables/PeppolIntegrationsTable.php
new file mode 100644
index 000000000..625e03ab9
--- /dev/null
+++ b/Modules/Invoices/Filament/Admin/Resources/PeppolIntegrations/Tables/PeppolIntegrationsTable.php
@@ -0,0 +1,65 @@
+columns([
+ TextColumn::make('company.name')
+ ->label('Company')
+ ->sortable()
+ ->searchable(),
+
+ TextColumn::make('provider_name')
+ ->label('Provider')
+ ->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))
+ ->sortable()
+ ->searchable(),
+
+ IconColumn::make('enabled')
+ ->label('Enabled')
+ ->boolean()
+ ->sortable(),
+
+ TextColumn::make('test_connection_status')
+ ->label('Connection Status')
+ ->badge()
+ ->formatStateUsing(fn (?PeppolConnectionStatus $state): ?string => $state?->label())
+ ->color(fn (?PeppolConnectionStatus $state): ?string => match ($state) {
+ PeppolConnectionStatus::SUCCESS => 'success',
+ PeppolConnectionStatus::FAILED => 'danger',
+ null => 'gray',
+ })
+ ->sortable(),
+
+ TextColumn::make('test_connection_at')
+ ->label('Last Tested')
+ ->dateTime()
+ ->sortable(),
+ ])
+ ->filters([
+ ])
+ ->recordActions([
+ ActionGroup::make([
+ EditAction::make()->modalWidth('full'),
+ ]),
+ ])
+ ->toolbarActions([
+ BulkActionGroup::make([
+ DeleteBulkAction::make(),
+ ]),
+ ]);
+ }
+}
diff --git a/Modules/Invoices/Http/Clients/ApiClient.php b/Modules/Invoices/Http/Clients/ApiClient.php
new file mode 100644
index 000000000..f0463271e
--- /dev/null
+++ b/Modules/Invoices/Http/Clients/ApiClient.php
@@ -0,0 +1,68 @@
+ $options Request options (timeout, payload, auth, bearer, digest, headers, etc.)
+ *
+ * @return Response
+ */
+ public function request(RequestMethod|string $method, string $uri, array $options = []): Response
+ {
+ $methodEnum = $method instanceof RequestMethod ? $method : RequestMethod::from(mb_strtolower($method));
+
+ $client = Http::timeout($options['timeout'] ?? 30);
+
+ $client = $this->applyAuth($client, $options);
+
+ // Apply custom headers if provided
+ if (isset($options['headers'])) {
+ $client = $client->withHeaders($options['headers']);
+ }
+
+ return $client
+ ->{$methodEnum->value}($uri, $options['payload'] ?? [])
+ ->throw();
+ }
+
+ /**
+ * Apply authentication to the HTTP client.
+ *
+ * @param PendingRequest $client The HTTP client
+ * @param array $options Request options
+ *
+ * @return PendingRequest
+ */
+ private function applyAuth(PendingRequest $client, array $options): PendingRequest
+ {
+ $authType = match (true) {
+ isset($options['bearer']) => 'bearer',
+ isset($options['auth']) && is_array($options['auth']) && count($options['auth']) >= 2 => 'basic',
+ default => null
+ };
+
+ return match ($authType) {
+ 'bearer' => $client->withToken($options['bearer']),
+ 'basic' => $client->withBasicAuth($options['auth'][0], $options['auth'][1]),
+ default => $client
+ };
+ }
+}
diff --git a/Modules/Invoices/Http/Contracts/HttpClientInterface.php b/Modules/Invoices/Http/Contracts/HttpClientInterface.php
new file mode 100644
index 000000000..c175f9ca7
--- /dev/null
+++ b/Modules/Invoices/Http/Contracts/HttpClientInterface.php
@@ -0,0 +1,26 @@
+ $options Request options (timeout, payload, auth, bearer, digest, headers, etc.)
+ *
+ * @return Response
+ */
+ public function request(RequestMethod|string $method, string $uri, array $options = []): Response;
+}
diff --git a/Modules/Invoices/Http/Decorators/HttpClientExceptionHandler.php b/Modules/Invoices/Http/Decorators/HttpClientExceptionHandler.php
new file mode 100644
index 000000000..32df0f1db
--- /dev/null
+++ b/Modules/Invoices/Http/Decorators/HttpClientExceptionHandler.php
@@ -0,0 +1,90 @@
+client = $client;
+ }
+
+ /**
+ * Make an HTTP request with exception handling and status-code transformation.
+ *
+ * Catches HTTP exceptions, maps them to status-code-specific exceptions,
+ * and re-throws for caller handling. Logging is handled by RequestLogger if configured.
+ *
+ * @param RequestMethod|string $method The HTTP method
+ * @param string $uri The URI to request
+ * @param array $options Request options
+ *
+ * @return Response
+ *
+ * @throws RequestException When the request fails with a client or server error
+ * @throws ConnectionException When there's a connection issue
+ * @throws Throwable For any other unexpected errors
+ */
+ public function request(RequestMethod|string $method, string $uri, array $options = []): Response
+ {
+ try {
+ return $this->client->request($method, $uri, $options);
+ } catch (RequestException $e) {
+ $statusCode = $e->response?->status() ?? $e->getCode();
+
+ $this->mapStatusCodeToException($statusCode, $e);
+
+ throw $e;
+ } catch (ConnectionException $e) {
+ throw new ConnectionException('Connection error: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ /**
+ * Transform exceptions based on HTTP status code.
+ *
+ * Can be overridden in subclasses for custom exception mapping per client family.
+ *
+ * @param int $statusCode The HTTP status code
+ * @param RequestException $original The original exception
+ *
+ * @return void Transforms the exception in-place or re-throws original
+ */
+ protected function mapStatusCodeToException(int $statusCode, RequestException $original): void
+ {
+ match($statusCode) {
+ 400, 422 => null, // Validation error — pass through
+ 401 => null, // Unauthorized
+ 403 => null, // Forbidden
+ 404 => null, // Not Found
+ 429 => null, // Too Many Requests — caller can implement backoff
+ 500, 502, 503, 504 => null, // Server errors
+ default => null,
+ };
+ }
+}
diff --git a/Modules/Invoices/Http/Decorators/RateLimiter.php b/Modules/Invoices/Http/Decorators/RateLimiter.php
new file mode 100644
index 000000000..d8ce42e4c
--- /dev/null
+++ b/Modules/Invoices/Http/Decorators/RateLimiter.php
@@ -0,0 +1,26 @@
+client->request($method, $uri, $options);
+ }
+}
diff --git a/Modules/Invoices/Http/Decorators/RequestLogger.php b/Modules/Invoices/Http/Decorators/RequestLogger.php
new file mode 100644
index 000000000..873d507c8
--- /dev/null
+++ b/Modules/Invoices/Http/Decorators/RequestLogger.php
@@ -0,0 +1,42 @@
+value : $method;
+
+ $this->logRequest($methodStr, $uri, $options);
+
+ try {
+ $response = $this->client->request($method, $uri, $options);
+
+ $this->logResponse($methodStr, $uri, $response->status(), $response->json() ?? $response->body());
+
+ return $response;
+ } catch (Throwable $e) {
+ $this->logError('Request', $methodStr, $uri, $e->getMessage());
+ throw $e;
+ }
+ }
+}
diff --git a/Modules/Invoices/Http/RequestMethod.php b/Modules/Invoices/Http/RequestMethod.php
new file mode 100644
index 000000000..e0f1e346e
--- /dev/null
+++ b/Modules/Invoices/Http/RequestMethod.php
@@ -0,0 +1,19 @@
+loggingEnabled = true;
+
+ return $this;
+ }
+
+ /**
+ * Disable request logging.
+ *
+ * @return $this
+ */
+ public function disableLogging(): self
+ {
+ $this->loggingEnabled = false;
+
+ return $this;
+ }
+
+ /**
+ * Log an API request.
+ *
+ * @param string $method
+ * @param string $uri
+ * @param array $options
+ *
+ * @return void
+ */
+ protected function logRequest(string $method, string $uri, array $options): void
+ {
+ if ( ! $this->loggingEnabled) {
+ return;
+ }
+
+ Log::info('HTTP Request', [
+ 'method' => $method,
+ 'uri' => $uri,
+ 'options' => $this->sanitizeForLogging($options),
+ ]);
+ }
+
+ /**
+ * Log an API response.
+ *
+ * @param string $method
+ * @param string $uri
+ * @param int $status
+ * @param mixed $body
+ *
+ * @return void
+ */
+ protected function logResponse(string $method, string $uri, int $status, mixed $body): void
+ {
+ if ( ! $this->loggingEnabled) {
+ return;
+ }
+
+ Log::info('HTTP Response', [
+ 'method' => $method,
+ 'uri' => $uri,
+ 'status' => $status,
+ 'body' => $body,
+ ]);
+ }
+
+ /**
+ * Log an API error.
+ *
+ * @param string $type Error type (Connection, Request, Unexpected)
+ * @param string $method
+ * @param string $uri
+ * @param string $message
+ * @param array $context Additional context
+ *
+ * @return void
+ */
+ protected function logError(string $type, string $method, string $uri, string $message, array $context = []): void
+ {
+ Log::error("HTTP {$type} Error", array_merge([
+ 'method' => $method,
+ 'uri' => $uri,
+ 'message' => $message,
+ ], $context));
+ }
+
+ /**
+ * Sanitize data for logging by redacting sensitive information.
+ *
+ * @param array $data
+ *
+ * @return array
+ */
+ protected function sanitizeForLogging(array $data): array
+ {
+ $sanitized = $data;
+
+ // Redact sensitive headers
+ if (isset($sanitized['headers'])) {
+ $sensitiveHeaders = ['Authorization', 'X-API-Key', 'X-Auth-Token'];
+ foreach ($sensitiveHeaders as $header) {
+ if (isset($sanitized['headers'][$header])) {
+ $sanitized['headers'][$header] = '***REDACTED***';
+ }
+ }
+ }
+
+ // Redact auth credentials
+ if (isset($sanitized['auth'])) {
+ $sanitized['auth'] = ['***REDACTED***', '***REDACTED***'];
+ }
+
+ if (isset($sanitized['bearer'])) {
+ $sanitized['bearer'] = '***REDACTED***';
+ }
+
+ if (isset($sanitized['digest'])) {
+ $sanitized['digest'] = ['***REDACTED***', '***REDACTED***'];
+ }
+
+ return $sanitized;
+ }
+}
diff --git a/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php b/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php
new file mode 100644
index 000000000..2062809fb
--- /dev/null
+++ b/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php
@@ -0,0 +1,111 @@
+logPeppolInfo('Starting Peppol status polling job');
+
+ // Get all transmissions awaiting acknowledgement (without global scope since this is a system job)
+ $transmissions = PeppolTransmission::withoutGlobalScopes()
+ ->with('integration')
+ ->where('status', PeppolTransmissionStatus::SENT)
+ ->whereNotNull('external_id')
+ ->whereNull('acknowledged_at')
+ ->where('sent_at', '<', now()->subMinutes(5)) // Allow 5 min grace period
+ ->limit(100) // Process in batches
+ ->get();
+
+ foreach ($transmissions as $transmission) {
+ try {
+ $this->checkStatus($transmission);
+ } catch (Exception $e) {
+ $this->logPeppolError('Failed to check transmission status', [
+ 'transmission_id' => $transmission->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $this->logPeppolInfo('Completed Peppol status polling', [
+ 'checked' => $transmissions->count(),
+ ]);
+ }
+
+ /**
+ * Polls the external provider for a transmission's delivery status and updates the local record accordingly.
+ *
+ * Marks the transmission as accepted or rejected based on the provider status, fires a PeppolAcknowledgementReceived
+ * event when an acknowledgement payload exists, and persists any provider acknowledgement payload to the transmission.
+ *
+ * @param PeppolTransmission $transmission the transmission to check and update
+ */
+ protected function checkStatus(PeppolTransmission $transmission): void
+ {
+ $provider = ProviderFactory::make($transmission->integration);
+
+ $result = $provider->getTransmissionStatus($transmission->external_id);
+
+ // Update based on status
+ $status = mb_strtolower($result['status'] ?? 'unknown');
+
+ if (in_array($status, ['delivered', 'accepted', 'success'])) {
+ $transmission->markAsAccepted();
+ event(new PeppolAcknowledgementReceived($transmission, $result['ack_payload'] ?? []));
+
+ $this->logPeppolInfo('Transmission accepted', [
+ 'transmission_id' => $transmission->id,
+ 'external_id' => $transmission->external_id,
+ ]);
+ } elseif (in_array($status, ['rejected', 'failed'])) {
+ $transmission->markAsRejected($result['ack_payload']['message'] ?? 'Rejected by recipient');
+
+ $this->logPeppolWarning('Transmission rejected', [
+ 'transmission_id' => $transmission->id,
+ 'external_id' => $transmission->external_id,
+ ]);
+ }
+
+ // Update provider response
+ if (isset($result['ack_payload'])) {
+ $transmission->setProviderResponse($result['ack_payload']);
+ }
+ }
+}
diff --git a/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php b/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php
new file mode 100644
index 000000000..6a277acbd
--- /dev/null
+++ b/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php
@@ -0,0 +1,113 @@
+logPeppolInfo('Starting retry failed transmissions job');
+
+ // Get transmissions ready for retry (without global scope since this is a system job)
+ $transmissions = PeppolTransmission::withoutGlobalScopes()
+ ->with(['integration', 'invoice'])
+ ->where('status', PeppolTransmissionStatus::RETRYING)
+ ->where('next_retry_at', '<=', now())
+ ->limit(50) // Process in batches
+ ->get();
+
+ foreach ($transmissions as $transmission) {
+ try {
+ $this->retryTransmission($transmission);
+ } catch (Exception $e) {
+ $this->logPeppolError('Failed to retry transmission', [
+ 'transmission_id' => $transmission->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $this->logPeppolInfo('Completed retry failed transmissions', [
+ 'retried' => $transmissions->count(),
+ ]);
+ }
+
+ /**
+ * Process a Peppol transmission scheduled for retry, re-dispatching its send job or marking it dead when the retry limit is reached.
+ *
+ * @param PeppolTransmission $transmission The transmission to evaluate and retry; if its attempts are greater than or equal to the configured `invoices.peppol.max_retry_attempts` it will be marked as dead and a PeppolTransmissionDead event will be fired.
+ */
+ protected function retryTransmission(PeppolTransmission $transmission): void
+ {
+ $maxAttempts = config('invoices.peppol.max_retry_attempts', 5);
+
+ if ($transmission->attempts >= $maxAttempts) {
+ $transmission->markAsDead('Maximum retry attempts exceeded');
+ event(new PeppolTransmissionDead($transmission, 'Maximum retry attempts exceeded'));
+
+ $this->logPeppolWarning('Transmission marked as dead', [
+ 'transmission_id' => $transmission->id,
+ 'attempts' => $transmission->attempts,
+ ]);
+
+ return;
+ }
+
+ // Atomically claim the transmission before dispatching, so an overlapping run of this
+ // job (e.g. a slow previous run still in flight when the next schedule tick fires)
+ // can't dispatch the same transmission twice.
+ $claimed = PeppolTransmission::withoutGlobalScopes()
+ ->where('id', $transmission->id)
+ ->where('status', PeppolTransmissionStatus::RETRYING)
+ ->update(['status' => PeppolTransmissionStatus::PROCESSING]);
+
+ if ($claimed !== 1) {
+ return;
+ }
+
+ // Dispatch the send job again
+ SendInvoiceToPeppolJob::dispatch(
+ $transmission->invoice,
+ $transmission->integration,
+ false, // don't force
+ $transmission->id
+ );
+
+ $this->logPeppolInfo('Retrying transmission', [
+ 'transmission_id' => $transmission->id,
+ 'attempt' => $transmission->attempts + 1,
+ ]);
+ }
+}
diff --git a/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php b/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php
new file mode 100644
index 000000000..c050c08f0
--- /dev/null
+++ b/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php
@@ -0,0 +1,509 @@
+invoice = $invoice;
+ $this->integration = $integration;
+ $this->force = $force;
+ $this->transmissionId = $transmissionId;
+ }
+
+ /**
+ * Coordinates sending the invoice to the Peppol network as a queued job.
+ *
+ * Validates the invoice, obtains or creates a PeppolTransmission, updates its status
+ * to processing, generates and stores XML/PDF artifacts, fires a prepared event,
+ * and submits the transmission to the configured provider. On error, logs the failure
+ * and delegates failure handling (including marking the transmission and scheduling retries).
+ */
+ public function handle(): void
+ {
+ try {
+ $this->logPeppolInfo('Starting Peppol invoice sending job', [
+ 'invoice_id' => $this->invoice->id,
+ 'integration_id' => $this->integration->id,
+ ]);
+
+ // Step 1: Pre-send validation
+ $this->validateInvoice();
+
+ // Step 2: Create or retrieve transmission record
+ $transmission = $this->getOrCreateTransmission();
+
+ // If transmission is already in a final state and not forcing, skip
+ if ( ! $this->force && $transmission->isFinal()) {
+ $this->logPeppolInfo('Transmission already in final state, skipping', [
+ 'transmission_id' => $transmission->id,
+ 'status' => $transmission->status->value,
+ ]);
+
+ return;
+ }
+
+ // Step 3: Mark as processing
+ $transmission->update(['status' => PeppolTransmissionStatus::PROCESSING]);
+
+ // Step 4: Transform and generate files
+ $this->prepareArtifacts($transmission);
+ event(new PeppolTransmissionPrepared($transmission));
+
+ // Step 5: Send to provider
+ $this->sendToProvider($transmission);
+ } catch (Throwable $e) {
+ $this->logPeppolError('Peppol sending job failed', [
+ 'invoice_id' => $this->invoice->id,
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ if (isset($transmission)) {
+ $this->handleFailure($transmission, $e);
+ }
+ }
+ }
+
+ /**
+ * Laravel's queue-worker failed-job hook — reached only when the job fails outside its own
+ * internal try/catch (e.g. model deserialization errors), since handle() otherwise catches
+ * and handles every Throwable itself.
+ */
+ public function failed(Throwable $e): void
+ {
+ $this->logPeppolError('Peppol send job failed permanently (queue-level failure)', [
+ 'invoice_id' => $this->invoice->id ?? null,
+ 'integration_id' => $this->integration->id ?? null,
+ 'transmission_id' => $this->transmissionId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ if ( ! $this->transmissionId) {
+ return;
+ }
+
+ PeppolTransmission::withoutGlobalScopes()
+ ->find($this->transmissionId)
+ ?->markAsDead('Job failed permanently: ' . $e->getMessage());
+ }
+
+ /**
+ * Ensure the invoice meets all prerequisites for Peppol transmission.
+ *
+ * Validations:
+ * - Invoice must belong to a customer.
+ * - Customer must have e-invoicing enabled.
+ * - Customer's Peppol ID must be validated.
+ * - Invoice must have an invoice number.
+ * - Invoice must contain at least one line item.
+ *
+ * @throws InvalidArgumentException if any validation fails
+ */
+ protected function validateInvoice(): void
+ {
+ if ( ! $this->invoice->customer) {
+ throw new InvalidArgumentException('Invoice must have a customer');
+ }
+
+ if ( ! $this->invoice->customer->enable_e_invoicing) {
+ throw new InvalidArgumentException('Customer does not have e-invoicing enabled');
+ }
+
+ if ( ! $this->invoice->customer->hasPeppolIdValidated()) {
+ throw new InvalidArgumentException('Customer Peppol ID has not been validated');
+ }
+
+ if ( ! $this->invoice->invoice_number) {
+ throw new InvalidArgumentException('Invoice must have an invoice number');
+ }
+
+ if ($this->invoice->invoiceItems->count() === 0) {
+ throw new InvalidArgumentException('Invoice must have at least one line item');
+ }
+ }
+
+ /**
+ * Retrieve an existing PeppolTransmission by idempotency key or transmission ID, or create and persist a new pending transmission.
+ *
+ * When a new transmission is created this method persists the record and emits a PeppolTransmissionCreated event.
+ *
+ * @return PeppolTransmission the existing or newly created transmission
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException if a specific transmission ID was provided but no record is found
+ */
+ protected function getOrCreateTransmission(): PeppolTransmission
+ {
+ // If transmission ID provided, use that (scoped to company for safety)
+ if ($this->transmissionId) {
+ return PeppolTransmission::withoutGlobalScopes()
+ ->where('company_id', $this->invoice->company_id)
+ ->findOrFail($this->transmissionId);
+ }
+
+ // Calculate idempotency key
+ $idempotencyKey = $this->calculateIdempotencyKey();
+
+ // Try to find existing transmission (scoped to company)
+ $transmission = PeppolTransmission::withoutGlobalScopes()
+ ->where('company_id', $this->invoice->company_id)
+ ->where('idempotency_key', $idempotencyKey)
+ ->first();
+
+ if ($transmission) {
+ $this->logPeppolInfo('Found existing transmission', ['transmission_id' => $transmission->id]);
+
+ return $transmission;
+ }
+
+ // Create new transmission
+ $transmission = PeppolTransmission::create([
+ 'company_id' => $this->invoice->company_id,
+ 'invoice_id' => $this->invoice->id,
+ 'customer_id' => $this->invoice->customer_id,
+ 'integration_id' => $this->integration->id,
+ 'format' => $this->determineFormat(),
+ 'status' => PeppolTransmissionStatus::PENDING,
+ 'idempotency_key' => $idempotencyKey,
+ 'attempts' => 0,
+ ]);
+
+ event(new PeppolTransmissionCreated($transmission));
+
+ return $transmission;
+ }
+
+ /**
+ * Produce an idempotency key for the invoice transmission.
+ *
+ * The key is derived from the invoice ID, the customer's Peppol ID, the
+ * integration ID, and the invoice's updated-at timestamp to uniquely
+ * identify a transmission attempt.
+ *
+ * @return string a SHA-256 hash string computed from the invoice ID, customer Peppol ID, integration ID, and invoice updated timestamp
+ */
+ protected function calculateIdempotencyKey(): string
+ {
+ // Invoice has no updated_at (Invoice::$timestamps is false and the invoices table has no
+ // timestamp columns at all) — key on identity only. This means re-sending after editing an
+ // invoice reuses the same transmission record rather than starting a fresh one; if that
+ // needs to change, it requires a real last-modified signal on Invoice, not just this key.
+ return hash('sha256', implode('|', [
+ $this->invoice->id,
+ $this->invoice->customer->peppol_id,
+ $this->integration->id,
+ ]));
+ }
+
+ /**
+ * Selects the Peppol document format to use for this invoice transmission.
+ *
+ * Prefers the customer's configured `peppol_format`; if absent, falls back to the application default (configured `invoices.peppol.default_format` or `'peppol_bis_3.0'`).
+ *
+ * @return string the Peppol format identifier to use for the transmission
+ */
+ protected function determineFormat(): string
+ {
+ return $this->invoice->customer->peppol_format ?? config('invoices.peppol.default_format', 'peppol_bis_3.0');
+ }
+
+ /**
+ * Prepare and persist Peppol XML and PDF artifacts for the given transmission.
+ *
+ * Generates and validates the XML for the job's invoice, stores the XML and a PDF to storage,
+ * and updates the transmission with the resulting storage paths.
+ *
+ * @param PeppolTransmission $transmission the transmission to associate the stored artifact paths with
+ *
+ * @throws RuntimeException if invoice validation fails; the exception message contains the validation errors
+ */
+ protected function prepareArtifacts(PeppolTransmission $transmission): void
+ {
+ // Get format handler
+ $handler = FormatHandlerFactory::make($transmission->format);
+
+ // Generate XML directly from invoice using handler
+ $xml = $handler->generateXml($this->invoice);
+
+ // Validate the generated document itself (well-formedness/schema) — catches
+ // generation bugs before anything is stored or sent, rather than after the fact.
+ $xmlErrors = (new PeppolXmlValidator())->validate($xml, $transmission->format);
+ if ( ! empty($xmlErrors)) {
+ throw new RuntimeException('Generated Peppol XML is invalid: ' . implode(', ', $xmlErrors));
+ }
+
+ // Validate business rules (handler's validate method checks the invoice)
+ $errors = $handler->validate($this->invoice);
+ if ( ! empty($errors)) {
+ throw new RuntimeException('Invoice validation failed: ' . implode(', ', $errors));
+ }
+
+ // Store XML
+ $xmlPath = $this->storeXml($transmission, $xml);
+
+ // Generate/get PDF
+ $pdfPath = $this->storePdf($transmission);
+
+ // Update transmission with paths
+ $transmission->update([
+ 'stored_xml_path' => $xmlPath,
+ 'stored_pdf_path' => $pdfPath,
+ ]);
+ }
+
+ /**
+ * Persist the generated Peppol XML for a transmission to storage.
+ *
+ * @param PeppolTransmission $transmission the transmission record used to construct the storage path
+ * @param string $xml the XML content to store
+ *
+ * @return string the storage path where the XML was saved
+ */
+ protected function storeXml(PeppolTransmission $transmission, string $xml): string
+ {
+ $path = sprintf(
+ 'peppol/%d/%d/%d/%s/invoice.xml',
+ $this->integration->id,
+ now()->year,
+ now()->month,
+ $transmission->id
+ );
+
+ Storage::put($path, $xml);
+
+ return $path;
+ }
+
+ /**
+ * Persist a PDF representation of the invoice for the given Peppol transmission and return its storage path.
+ *
+ * @param PeppolTransmission $transmission the transmission used to build the storage path
+ *
+ * @return string the storage path where the PDF was saved
+ */
+ protected function storePdf(PeppolTransmission $transmission): string
+ {
+ $path = sprintf(
+ 'peppol/%d/%d/%d/%s/invoice.pdf',
+ $this->integration->id,
+ now()->year,
+ now()->month,
+ $transmission->id
+ );
+
+ $html = app(\Modules\Invoices\Services\InvoiceService::class)->renderHtml($this->invoice);
+ $pdfContent = \Modules\Core\Support\PDF\PDFFactory::create()->getOutput($html);
+
+ Storage::put($path, $pdfContent);
+
+ return $path;
+ }
+
+ /**
+ * Submits the prepared invoice XML to the configured Peppol provider and updates the transmission state.
+ *
+ * On success, marks the transmission as sent, stores the provider response, and emits PeppolTransmissionSent.
+ * On failure, marks the transmission as failed, stores the provider response, emits PeppolTransmissionFailed, and schedules a retry when the error is classified as transient.
+ *
+ * @param PeppolTransmission $transmission the transmission record representing this send attempt
+ */
+ protected function sendToProvider(PeppolTransmission $transmission): void
+ {
+ $provider = ProviderFactory::make($this->integration);
+
+ // Get XML content
+ $xml = Storage::get($transmission->stored_xml_path);
+
+ // Prepare transmission data. This is a superset covering every provider's actual
+ // sendInvoice() needs — xml-based providers (LetsPeppol, Storecove) read 'xml' plus
+ // 'recipient_id'/'recipient_scheme'; PDF-based providers (SuperPdp, Qonto) read the
+ // 'invoice' model directly; EInvoiceBeProvider builds its own structured document from
+ // 'invoice'. Key names match what the providers' sendInvoice() implementations already
+ // read (see StorecoveProviderTest for the recipient_id/recipient_scheme contract).
+ $transmissionData = [
+ 'transmission_id' => $transmission->id,
+ 'invoice_id' => $this->invoice->id,
+ 'invoice' => $this->invoice,
+ 'recipient_id' => $this->invoice->customer->peppol_id,
+ 'recipient_scheme' => $this->invoice->customer->peppol_scheme,
+ 'format' => $transmission->format,
+ 'xml' => $xml,
+ 'idempotency_key' => $transmission->idempotency_key,
+ ];
+
+ // Send to provider
+ $result = $provider->sendInvoice($transmissionData);
+
+ // Handle result
+ if ($result['accepted']) {
+ $transmission->markAsSent($result['external_id']);
+ $transmission->setProviderResponse($result['response'] ?? []);
+
+ event(new PeppolTransmissionSent($transmission));
+
+ $this->logPeppolInfo('Invoice sent to Peppol successfully', [
+ 'transmission_id' => $transmission->id,
+ 'external_id' => $result['external_id'],
+ ]);
+ } else {
+ // Provider rejected the submission
+ $errorType = $this->classifyError($result['status_code'], $result['response']);
+
+ $transmission->markAsFailed($result['message'], $errorType);
+ $transmission->setProviderResponse($result['response'] ?? []);
+
+ event(new PeppolTransmissionFailed($transmission, $result['message']));
+
+ // Schedule retry if transient error
+ if ($errorType === PeppolErrorType::TRANSIENT) {
+ $this->scheduleRetry($transmission);
+ }
+ }
+ }
+
+ /**
+ * Determine the Peppol error type corresponding to an HTTP status code.
+ *
+ * @param int $statusCode HTTP status code from the provider response
+ * @param array|null $responseBody optional response body returned by the provider; currently not used for classification
+ *
+ * @return peppolErrorType `TRANSIENT` for 5xx, 429 or 408 status codes; `PERMANENT` for 401, 403, 404, 400 or 422; `UNKNOWN` otherwise
+ */
+ protected function classifyError(int $statusCode, ?array $responseBody = null): PeppolErrorType
+ {
+ return match(true) {
+ $statusCode >= 500 => PeppolErrorType::TRANSIENT,
+ $statusCode === 429 => PeppolErrorType::TRANSIENT,
+ $statusCode === 408 => PeppolErrorType::TRANSIENT,
+ $statusCode === 401 || $statusCode === 403 => PeppolErrorType::PERMANENT,
+ $statusCode === 404 => PeppolErrorType::PERMANENT,
+ $statusCode === 400 || $statusCode === 422 => PeppolErrorType::PERMANENT,
+ default => PeppolErrorType::UNKNOWN,
+ };
+ }
+
+ /**
+ * Mark the given transmission as failed because of an exception, emit a failure event, and schedule a retry if appropriate.
+ *
+ * @param PeppolTransmission $transmission the transmission to mark as failed
+ * @param Throwable $e the exception that caused the failure; its message is recorded on the transmission
+ */
+ protected function handleFailure(PeppolTransmission $transmission, Throwable $e): void
+ {
+ $transmission->markAsFailed(
+ $e->getMessage(),
+ PeppolErrorType::UNKNOWN
+ );
+
+ event(new PeppolTransmissionFailed($transmission, $e->getMessage()));
+
+ // Schedule retry for unknown errors
+ $this->scheduleRetry($transmission);
+ }
+
+ /**
+ * Schedule the transmission for a retry using exponential backoff.
+ *
+ * If the transmission has reached the maximum configured attempts, marks it as dead.
+ * Otherwise computes the next retry time using increasing delays, updates the transmission's
+ * retry schedule, re-dispatches this job with the computed delay, and logs the scheduling.
+ *
+ * @param PeppolTransmission $transmission the transmission to schedule a retry for
+ */
+ protected function scheduleRetry(PeppolTransmission $transmission): void
+ {
+ $maxAttempts = config('invoices.peppol.max_retry_attempts', 5);
+
+ if ($transmission->attempts >= $maxAttempts) {
+ $transmission->markAsDead('Maximum retry attempts exceeded');
+
+ return;
+ }
+
+ // Exponential backoff: 1min, 5min, 30min, 2h, 6h.
+ // $transmission->attempts was already incremented by markAsFailed() for this failure,
+ // so this is a 1-based count — index with attempts - 1 to actually reach the first (60s) tier.
+ $delays = [60, 300, 1800, 7200, 21600];
+ $delay = $delays[max(0, $transmission->attempts - 1)] ?? 21600;
+
+ $nextRetryAt = now()->addSeconds($delay);
+ $transmission->scheduleRetry($nextRetryAt);
+
+ // Re-dispatch the job
+ static::dispatch($this->invoice, $this->integration, false, $transmission->id)
+ ->delay($nextRetryAt);
+
+ $this->logPeppolInfo('Scheduled retry for Peppol transmission', [
+ 'transmission_id' => $transmission->id,
+ 'attempt' => $transmission->attempts,
+ 'next_retry_at' => $nextRetryAt,
+ ]);
+ }
+}
diff --git a/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php b/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php
new file mode 100644
index 000000000..520663089
--- /dev/null
+++ b/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php
@@ -0,0 +1,99 @@
+getAuditId($event);
+ $auditType = $this->getAuditType($event);
+
+ // Create audit log entry
+ AuditLog::create([
+ 'audit_id' => $auditId,
+ 'audit_type' => $auditType,
+ 'activity' => $event->getEventName(),
+ 'info' => json_encode($event->getAuditPayload()),
+ ]);
+
+ Log::debug('Peppol event logged to audit', [
+ 'event' => $event->getEventName(),
+ 'audit_id' => $auditId,
+ 'audit_type' => $auditType,
+ ]);
+ } catch (Exception $e) {
+ // Don't let audit logging failures break the application
+ Log::error('Failed to log Peppol event to audit', [
+ 'event' => $event->getEventName(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * Extracts an audit identifier from the given Peppol event payload.
+ *
+ * Checks the payload for `transmission_id`, `integration_id`, then `customer_id`
+ * and returns the first value found.
+ *
+ * @param PeppolEvent $event event whose payload is inspected for an audit id
+ *
+ * @return int|null the audit identifier if present, otherwise `null`
+ */
+ protected function getAuditId(PeppolEvent $event): ?int
+ {
+ // Try common payload keys
+ return $event->payload['transmission_id']
+ ?? $event->payload['integration_id']
+ ?? $event->payload['customer_id']
+ ?? null;
+ }
+
+ /**
+ * Derives an audit type string based on the event's name.
+ *
+ * @param PeppolEvent $event event whose name is inspected to determine the audit type
+ *
+ * @return string `'peppol_transmission'` if the event name contains "transmission", `'peppol_integration'` if it contains "integration", `'peppol_validation'` if it contains "validation", otherwise `'peppol_event'`
+ */
+ protected function getAuditType(PeppolEvent $event): string
+ {
+ $eventName = $event->getEventName();
+
+ if (str_contains($eventName, 'transmission')) {
+ return 'peppol_transmission';
+ }
+ if (str_contains($eventName, 'integration')) {
+ return 'peppol_integration';
+ }
+ if (str_contains($eventName, 'validation')) {
+ return 'peppol_validation';
+ }
+
+ return 'peppol_event';
+ }
+}
diff --git a/Modules/Invoices/Models/CustomerPeppolValidationHistory.php b/Modules/Invoices/Models/CustomerPeppolValidationHistory.php
new file mode 100644
index 000000000..9f13b6878
--- /dev/null
+++ b/Modules/Invoices/Models/CustomerPeppolValidationHistory.php
@@ -0,0 +1,140 @@
+ PeppolValidationStatus::class,
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+
+ /**
+ * Get the customer associated with this validation history.
+ *
+ * @return BelongsTo the relation linking this record to a Relation model using the `customer_id` foreign key
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Relation::class, 'customer_id');
+ }
+
+ /**
+ * Get the PeppolIntegration associated with this validation history.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo the related PeppolIntegration model
+ */
+ public function integration(): BelongsTo
+ {
+ return $this->belongsTo(PeppolIntegration::class, 'integration_id');
+ }
+
+ /**
+ * Get the user who performed the validation.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo the user that validated this record
+ */
+ public function validator(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'validated_by');
+ }
+
+ /**
+ * Get the provider responses associated with this validation history.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany related CustomerPeppolValidationResponse models
+ */
+ public function responses(): HasMany
+ {
+ return $this->hasMany(CustomerPeppolValidationResponse::class, 'validation_history_id');
+ }
+
+ /**
+ * Returns provider responses as an associative array keyed by response key.
+ *
+ * Each value will be the decoded JSON value when the stored response is valid JSON; otherwise the raw string value is returned.
+ *
+ * @return array Map of response_key => response_value (decoded or raw)
+ */
+ public function getProviderResponseAttribute(): array
+ {
+ return collect($this->responses)
+ ->mapWithKeys(function (CustomerPeppolValidationResponse $response) {
+ $value = $response->response_value;
+ $decoded = json_decode($value, true);
+
+ return [
+ $response->response_key => json_last_error() === JSON_ERROR_NONE
+ ? $decoded
+ : $value,
+ ];
+ })
+ ->toArray();
+ }
+
+ /**
+ * Store or update provider response entries from a key-value array.
+ *
+ * For each entry, creates a new response record when the key does not exist or updates the existing one
+ * matching the response key. If a value is an array it will be JSON-encoded before storage.
+ *
+ * @param array $response Associative array of response_key => response_value pairs. Array values will be serialized to JSON.
+ */
+ public function setProviderResponse(array $response): void
+ {
+ foreach ($response as $key => $value) {
+ $this->responses()->updateOrCreate(
+ ['response_key' => $key],
+ [
+ 'response_value' => is_array($value)
+ ? json_encode($value, JSON_THROW_ON_ERROR)
+ : $value,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Determine whether this validation record represents a successful Peppol validation.
+ *
+ * @return bool `true` if the record's `validation_status` equals `PeppolValidationStatus::VALID`, `false` otherwise
+ */
+ public function isValid(): bool
+ {
+ return $this->validation_status === PeppolValidationStatus::VALID;
+ }
+}
diff --git a/Modules/Invoices/Models/CustomerPeppolValidationResponse.php b/Modules/Invoices/Models/CustomerPeppolValidationResponse.php
new file mode 100644
index 000000000..be0b9833f
--- /dev/null
+++ b/Modules/Invoices/Models/CustomerPeppolValidationResponse.php
@@ -0,0 +1,32 @@
+belongsTo(CustomerPeppolValidationHistory::class, 'validation_history_id');
+ }
+}
diff --git a/Modules/Invoices/Models/PeppolIntegration.php b/Modules/Invoices/Models/PeppolIntegration.php
new file mode 100644
index 000000000..c34c948dd
--- /dev/null
+++ b/Modules/Invoices/Models/PeppolIntegration.php
@@ -0,0 +1,152 @@
+ PeppolConnectionStatus::class,
+ 'enabled' => 'boolean',
+ 'test_connection_at' => 'datetime',
+ ];
+
+ /**
+ * Get the transmissions associated with this integration.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany a has-many relation for PeppolTransmission models keyed by `integration_id`
+ */
+ public function transmissions(): HasMany
+ {
+ return $this->hasMany(PeppolTransmission::class, 'integration_id');
+ }
+
+ /**
+ * Get the Eloquent relation for this integration's configuration entries.
+ *
+ * Credentials are now stored in the shared merchant_clients table, scoped by company and provider.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany relation to MerchantClient models
+ */
+ public function configurations(): HasMany
+ {
+ /** @var HasMany $relation */
+ $relation = $this->hasMany(MerchantClient::class, 'company_id', 'company_id')
+ ->where('driver', $this->provider_name);
+
+ return $relation;
+ }
+
+ /**
+ * Provide integration configurations as an associative array keyed by configuration keys.
+ *
+ * Maps merchant_clients rows (from the shared credential table) to merchant_key/merchant_value pairs.
+ *
+ * @return array associative array mapping credential keys (merchant_key) to their values (merchant_value)
+ */
+ public function getConfigAttribute(): array
+ {
+ return collect($this->configurations)->pluck('merchant_value', 'merchant_key')->toArray();
+ }
+
+ /**
+ * Upserts integration configuration entries from an associative array.
+ *
+ * Each array key is saved as `merchant_key` and its corresponding value as `merchant_value`
+ * on the related merchant_clients rows; existing entries are updated and missing ones created.
+ *
+ * @param array $config associative array of configuration entries where keys are configuration keys and values are configuration values
+ */
+ public function setConfig(array $config): void
+ {
+ foreach ($config as $key => $value) {
+ MerchantClient::updateOrCreate(
+ [
+ 'company_id' => $this->company_id,
+ 'driver' => $this->provider_name,
+ 'merchant_key' => $key,
+ ],
+ ['merchant_value' => $value]
+ );
+ }
+ }
+
+ /**
+ * Retrieve a configuration value for the given key from this integration's configurations.
+ *
+ * @param string $key the configuration key to look up
+ * @param mixed $default value to return if the configuration key does not exist
+ *
+ * @return mixed the configuration value if found, otherwise the provided default
+ */
+ public function getConfigValue(string $key, $default = null)
+ {
+ $config = MerchantClient::where('company_id', $this->company_id)
+ ->where('driver', $this->provider_name)
+ ->where('merchant_key', $key)
+ ->first();
+
+ return $config ? $config->merchant_value : $default;
+ }
+
+ /**
+ * Determine whether the last connection test succeeded.
+ *
+ * @return bool `true` if `test_connection_status` equals PeppolConnectionStatus::SUCCESS, `false` otherwise
+ */
+ public function isConnectionSuccessful(): bool
+ {
+ return $this->test_connection_status === PeppolConnectionStatus::SUCCESS;
+ }
+
+ /**
+ * Determine whether the integration is ready for use.
+ *
+ * Integration is considered ready when it is enabled and the connection check is successful.
+ *
+ * @return bool `true` if the integration is enabled and the connection is successful, `false` otherwise
+ */
+ public function isReady(): bool
+ {
+ return $this->enabled && $this->isConnectionSuccessful();
+ }
+
+ protected static function booted(): void
+ {
+ // Do not apply global company scope to this model during Filament admin access,
+ // where an admin managing integrations across companies should see all of them.
+ // This is a shared cross-company registry, not company-scoped data.
+ if (app()->runningInConsole()) {
+ static::addGlobalScope('skip_company_scope', function ($query): void {
+ // In console (migrations, commands), skip the global scope entirely
+ });
+ }
+ }
+}
diff --git a/Modules/Invoices/Models/PeppolTransmission.php b/Modules/Invoices/Models/PeppolTransmission.php
new file mode 100644
index 000000000..9bb4ed6fb
--- /dev/null
+++ b/Modules/Invoices/Models/PeppolTransmission.php
@@ -0,0 +1,256 @@
+ PeppolTransmissionStatus::class,
+ 'error_type' => PeppolErrorType::class,
+ 'attempts' => 'integer',
+ 'sent_at' => 'datetime',
+ 'acknowledged_at' => 'datetime',
+ 'next_retry_at' => 'datetime',
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+
+ /**
+ * Get the invoice associated with the transmission.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo the relation to the Invoice model
+ */
+ public function invoice(): BelongsTo
+ {
+ return $this->belongsTo(Invoice::class);
+ }
+
+ /**
+ * Defines the customer relationship for this transmission.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo the relation linking the transmission to its customer Relation via the `customer_id` foreign key
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Relation::class, 'customer_id');
+ }
+
+ /**
+ * Get the Peppol integration associated with this transmission.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo the relationship to the PeppolIntegration model using the `integration_id` foreign key
+ */
+ public function integration(): BelongsTo
+ {
+ return $this->belongsTo(PeppolIntegration::class, 'integration_id');
+ }
+
+ /**
+ * Get the HasMany relation for provider responses associated with this transmission.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany relation of PeppolTransmissionResponse models keyed by `transmission_id`
+ */
+ public function responses(): HasMany
+ {
+ return $this->hasMany(PeppolTransmissionResponse::class, 'transmission_id');
+ }
+
+ /**
+ * Return provider response entries indexed by response key.
+ *
+ * @return array associative array where keys are response keys and values are the corresponding response values
+ */
+ public function getProviderResponseAttribute(): array
+ {
+ return collect($this->responses)->pluck('response_value', 'response_key')->toArray();
+ }
+
+ /**
+ * Persist provider response key-value pairs to the transmission's related responses.
+ *
+ * For each entry in the provided associative array, creates or updates a related
+ * PeppolTransmissionResponse record. If a value is an array, it is JSON-encoded
+ * before being stored.
+ *
+ * @param array $response associative array of response keys to values; array values will be JSON-encoded
+ */
+ public function setProviderResponse(array $response): void
+ {
+ foreach ($response as $key => $value) {
+ // company_id is set explicitly rather than left to BelongsToCompany's auto-injection —
+ // this runs from queued jobs with no Filament tenant/session to infer it from.
+ $this->responses()->updateOrCreate(
+ ['response_key' => $key],
+ [
+ 'response_value' => is_array($value) ? json_encode($value) : $value,
+ 'company_id' => $this->company_id,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Determine whether the transmission's status represents a final state.
+ *
+ * @return bool `true` if the status indicates a final state, `false` otherwise
+ */
+ public function isFinal(): bool
+ {
+ return $this->status->isFinal();
+ }
+
+ /**
+ * Determine whether the transmission is eligible for a retry.
+ *
+ * @return bool `true` if the transmission's status allows retry and its error type is `PeppolErrorType::TRANSIENT`, `false` otherwise
+ */
+ public function canRetry(): bool
+ {
+ return $this->status->canRetry() && $this->error_type === PeppolErrorType::TRANSIENT;
+ }
+
+ /**
+ * Determine whether the transmission is awaiting acknowledgement.
+ *
+ * @return bool `true` if the transmission's status indicates awaiting acknowledgement and `acknowledged_at` is null, `false` otherwise
+ */
+ public function isAwaitingAck(): bool
+ {
+ return $this->status->isAwaitingAck() && ! $this->acknowledged_at;
+ }
+
+ /**
+ * Mark the transmission as sent and record the send timestamp.
+ *
+ * @param string|null $externalId the provider-assigned external identifier to store, or null to leave empty
+ */
+ public function markAsSent(?string $externalId = null): void
+ {
+ $this->update([
+ 'status' => PeppolTransmissionStatus::SENT,
+ 'external_id' => $externalId,
+ 'sent_at' => now(),
+ ]);
+ }
+
+ /**
+ * Mark the transmission as accepted and record the acknowledgement time.
+ *
+ * Updates the model's status to PeppolTransmissionStatus::ACCEPTED and sets `acknowledged_at` to the current time.
+ */
+ public function markAsAccepted(): void
+ {
+ $this->update([
+ 'status' => PeppolTransmissionStatus::ACCEPTED,
+ 'acknowledged_at' => now(),
+ ]);
+ }
+
+ /**
+ * Mark the transmission as rejected and record the acknowledgement time.
+ *
+ * Sets the transmission status to REJECTED, records the current acknowledgement timestamp, and stores an optional rejection reason.
+ *
+ * @param string|null $reason optional human-readable rejection reason to store in `last_error`
+ */
+ public function markAsRejected(?string $reason = null): void
+ {
+ $this->update([
+ 'status' => PeppolTransmissionStatus::REJECTED,
+ 'acknowledged_at' => now(),
+ 'last_error' => $reason,
+ ]);
+ }
+
+ /**
+ * Mark the transmission as failed and record the error and error type.
+ *
+ * Increments the attempt counter, sets the transmission status to FAILED,
+ * stores the provided error message as `last_error`, and sets `error_type`
+ * (defaults to `PeppolErrorType::UNKNOWN` when not provided).
+ *
+ * @param string $error human-readable error message describing the failure
+ * @param PeppolErrorType|null $errorType classification of the error; when omitted `PeppolErrorType::UNKNOWN` is used
+ */
+ public function markAsFailed(string $error, ?PeppolErrorType $errorType = null): void
+ {
+ $this->increment('attempts');
+ $this->update([
+ 'status' => PeppolTransmissionStatus::FAILED,
+ 'last_error' => $error,
+ 'error_type' => $errorType ?? PeppolErrorType::UNKNOWN,
+ ]);
+ }
+
+ /**
+ * Set the transmission to retrying and schedule the next retry time.
+ *
+ * @param \Carbon\Carbon $nextRetryAt the timestamp when the next retry should be attempted
+ */
+ public function scheduleRetry(\Carbon\Carbon $nextRetryAt): void
+ {
+ $this->update([
+ 'status' => PeppolTransmissionStatus::RETRYING,
+ 'next_retry_at' => $nextRetryAt,
+ ]);
+ }
+
+ /**
+ * Mark the transmission as dead and record a final error reason.
+ *
+ * Sets the transmission status to DEAD and updates `last_error` with the provided
+ * reason. If no reason is supplied, the existing `last_error` is preserved.
+ *
+ * @param string|null $reason optional final error message to store
+ */
+ public function markAsDead(?string $reason = null): void
+ {
+ $this->update([
+ 'status' => PeppolTransmissionStatus::DEAD,
+ 'last_error' => $reason ?? $this->last_error,
+ ]);
+ }
+}
diff --git a/Modules/Invoices/Models/PeppolTransmissionResponse.php b/Modules/Invoices/Models/PeppolTransmissionResponse.php
new file mode 100644
index 000000000..194577f0b
--- /dev/null
+++ b/Modules/Invoices/Models/PeppolTransmissionResponse.php
@@ -0,0 +1,36 @@
+belongsTo(PeppolTransmission::class, 'transmission_id');
+ }
+}
diff --git a/Modules/Invoices/Observers/PeppolIntegrationObserver.php b/Modules/Invoices/Observers/PeppolIntegrationObserver.php
new file mode 100644
index 000000000..d3151970d
--- /dev/null
+++ b/Modules/Invoices/Observers/PeppolIntegrationObserver.php
@@ -0,0 +1,24 @@
+where('company_id', $integration->company_id)
+ ->where('driver', $integration->provider_name)
+ ->delete();
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/BasePeppolClient.php b/Modules/Invoices/Peppol/Clients/BasePeppolClient.php
new file mode 100644
index 000000000..91b7e9792
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/BasePeppolClient.php
@@ -0,0 +1,230 @@
+client = $client;
+ $this->apiKey = $apiKey;
+ $this->baseUrl = mb_rtrim($baseUrl, '/');
+ }
+
+ /**
+ * Get authentication headers for the API.
+ *
+ * This method must be implemented by each provider client to return
+ * the appropriate authentication headers for that provider's API.
+ *
+ * @return array Authentication headers
+ */
+ abstract protected function getAuthenticationHeaders(): array;
+
+ /**
+ * Get the HTTP client instance.
+ *
+ * @return HttpClientInterface
+ */
+ public function getClient(): HttpClientInterface
+ {
+ return $this->client;
+ }
+
+ /**
+ * Get request options for the HTTP client.
+ *
+ * @param array $options
+ *
+ * @return array
+ */
+ public function getRequestOptions(array $options = []): array
+ {
+ // Merge authentication headers with any existing headers
+ // Auth headers are merged AFTER existing headers to ensure they take precedence
+ // and cannot be overridden by caller-provided headers for security
+ $authHeaders = $this->getAuthenticationHeaders();
+ $existingHeaders = $options['headers'] ?? [];
+
+ $options['headers'] = array_merge($existingHeaders, $authHeaders);
+ $options['timeout'] ??= $this->getTimeout();
+
+ return $options;
+ }
+
+ /**
+ * Set the OAuth2 access token.
+ *
+ * @param string $token the access token to set
+ */
+ public function setAccessToken(string $token): void
+ {
+ $this->accessToken = $token;
+ }
+
+ /**
+ * Authenticate the client using provided credentials.
+ *
+ * Default behavior:
+ * - If tokenUrl() is null (static credentials), validates that apiKey is present
+ * - If tokenUrl() is set (OAuth2), exchanges client_id/client_secret for access_token
+ *
+ * Subclasses may override this method for custom authentication logic.
+ *
+ * @param array $credentials array of credentials; typically contains 'client_id' and 'client_secret' for OAuth2
+ *
+ * @return bool true if authentication succeeded, false otherwise
+ */
+ public function authenticate(array $credentials = []): bool
+ {
+ $url = $this->tokenUrl();
+
+ // Static credential authentication: just validate API key is present
+ if ($url === null) {
+ return ! empty($this->apiKey);
+ }
+
+ // OAuth2 client-credentials flow
+ if (empty($credentials['client_id']) || empty($credentials['client_secret'])) {
+ return false;
+ }
+
+ try {
+ $options = [
+ 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+ 'payload' => [
+ 'grant_type' => 'client_credentials',
+ 'client_id' => $credentials['client_id'],
+ 'client_secret' => $credentials['client_secret'],
+ ],
+ ];
+
+ $response = $this->client->request(RequestMethod::POST, $url, $options);
+
+ if ($response->successful()) {
+ $data = $response->json();
+ $this->accessToken = $data['access_token'] ?? '';
+ $this->lastAuthResponse = $data;
+
+ return true;
+ }
+
+ return false;
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the response from the most recent token endpoint call.
+ *
+ * @return array|null the decoded response, or null if no authentication has been attempted
+ */
+ public function getLastAuthResponse(): ?array
+ {
+ return $this->lastAuthResponse;
+ }
+
+ /**
+ * Build the full URL from the base URL and path.
+ *
+ * @param string $path The API path
+ *
+ * @return string The full URL
+ */
+ protected function buildUrl(string $path): string
+ {
+ return $this->baseUrl . '/' . mb_ltrim($path, '/');
+ }
+
+ /**
+ * Get the request timeout in seconds.
+ *
+ * Override this method in child classes to set a different timeout.
+ *
+ * @return int Timeout in seconds
+ */
+ protected function getTimeout(): int
+ {
+ return $this->timeout;
+ }
+
+ /**
+ * Get the OAuth2 token endpoint URL for this provider.
+ *
+ * Override this method to provide an OAuth2 token endpoint URL. Return null (default)
+ * to indicate this provider uses static credential authentication (e.g., API key).
+ *
+ * @return string|null the OAuth2 token endpoint URL, or null if not using OAuth2
+ */
+ protected function tokenUrl(): ?string
+ {
+ return null;
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/EInvoiceBe/DocumentsClient.php b/Modules/Invoices/Peppol/Clients/EInvoiceBe/DocumentsClient.php
new file mode 100644
index 000000000..3170a7940
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/EInvoiceBe/DocumentsClient.php
@@ -0,0 +1,207 @@
+ $documentData The document data to submit
+ *
+ * @return Response The API response
+ *
+ * @throws \Illuminate\Http\Client\RequestException If the request fails
+ * @throws \Illuminate\Http\Client\ConnectionException If there's a connection issue
+ */
+ public function submitDocument(array $documentData): Response
+ {
+ $options = array_merge($this->getRequestOptions(), [
+ 'payload' => $documentData,
+ ]);
+
+ /* The client.request() will throw RequestException for unsuccessful responses */
+ return $this->client->request(
+ RequestMethod::POST,
+ $this->buildUrl('api/documents'),
+ $options
+ );
+ }
+
+ /**
+ * Get a document by its ID.
+ *
+ * Retrieves the details and status of a previously submitted document.
+ *
+ * Example response JSON:
+ * ```json
+ * {
+ * "document_id": "DOC-123456",
+ * "status": "delivered",
+ * "invoice_number": "INV-2024-001",
+ * "created_at": "2024-01-15T10:30:00Z",
+ * "delivered_at": "2024-01-15T11:45:00Z"
+ * }
+ * ```
+ *
+ * @param string $documentId The unique identifier of the document
+ *
+ * @return Response The API response containing document details
+ *
+ * @throws \Illuminate\Http\Client\RequestException If the request fails
+ * @throws \Illuminate\Http\Client\ConnectionException If there's a connection issue
+ */
+ public function getDocument(string $documentId): Response
+ {
+ 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;
+ }
+ }
+
+ /**
+ * Get the status of a document.
+ *
+ * Checks the current transmission status of a document in the Peppol network.
+ *
+ * Example response JSON:
+ * ```json
+ * {
+ * "status": "delivered",
+ * "timestamp": "2024-01-15T11:45:00Z",
+ * "message": "Document successfully delivered to recipient"
+ * }
+ * ```
+ *
+ * @param string $documentId The unique identifier of the document
+ *
+ * @return Response The API response containing status information
+ *
+ * @throws \Illuminate\Http\Client\RequestException If the request fails
+ * @throws \Illuminate\Http\Client\ConnectionException If there's a connection issue
+ */
+ public function getDocumentStatus(string $documentId): Response
+ {
+ /* The client.request() will throw RequestException for unsuccessful responses */
+ return $this->client->request(
+ RequestMethod::GET,
+ $this->buildUrl("api/documents/{$documentId}/status"),
+ $this->getRequestOptions()
+ );
+ }
+
+ /**
+ * List all documents with optional filters.
+ *
+ * Retrieves a paginated list of documents submitted through the API.
+ *
+ * Example response JSON:
+ * ```json
+ * {
+ * "documents": [
+ * {"document_id": "DOC-1", "status": "delivered"},
+ * {"document_id": "DOC-2", "status": "pending"}
+ * ],
+ * "total": 25,
+ * "page": 1,
+ * "per_page": 10
+ * }
+ * ```
+ *
+ * @param array $filters Optional filters (e.g., status, date range)
+ *
+ * @return Response The API response containing list of documents
+ *
+ * @throws \Illuminate\Http\Client\RequestException If the request fails
+ * @throws \Illuminate\Http\Client\ConnectionException If there's a connection issue
+ */
+ public function listDocuments(array $filters = []): Response
+ {
+ $options = array_merge($this->getRequestOptions(), [
+ 'payload' => $filters,
+ ]);
+
+ return $this->client->request(
+ RequestMethod::GET,
+ $this->buildUrl('api/documents'),
+ $options
+ );
+ }
+
+ /**
+ * Cancel a document submission.
+ *
+ * Attempts to cancel a document that has been submitted but not yet delivered.
+ *
+ * @param string $documentId The unique identifier of the document to cancel
+ *
+ * @return Response The API response
+ *
+ * @throws \Illuminate\Http\Client\RequestException If the request fails
+ * @throws \Illuminate\Http\Client\ConnectionException If there's a connection issue
+ */
+ public function cancelDocument(string $documentId): Response
+ {
+ /* The client.request() will throw RequestException for unsuccessful responses */
+ return $this->client->request(
+ RequestMethod::DELETE,
+ $this->buildUrl("api/documents/{$documentId}"),
+ $this->getRequestOptions()
+ );
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/EInvoiceBe/EInvoiceBeClient.php b/Modules/Invoices/Peppol/Clients/EInvoiceBe/EInvoiceBeClient.php
new file mode 100644
index 000000000..e3cdc540c
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/EInvoiceBe/EInvoiceBeClient.php
@@ -0,0 +1,71 @@
+
+ */
+ public static function settings(): array
+ {
+ return ['api_key'];
+ }
+
+ /**
+ * Authenticate with e-invoice.be using API key.
+ *
+ * e-invoice.be uses simple API key authentication — no token exchange required.
+ * This method validates the API key is present.
+ *
+ * @param array $credentials Must contain 'api_key'
+ *
+ * @return bool True if API key is present and valid
+ */
+ public function authenticate(array $credentials = []): bool
+ {
+ return ! empty($credentials['api_key']);
+ }
+
+ /**
+ * Get authentication headers for e-invoice.be API.
+ *
+ * e-invoice.be uses API key authentication via the X-API-Key header.
+ *
+ * @return array Authentication headers
+ */
+ protected function getAuthenticationHeaders(): array
+ {
+ return [
+ 'X-API-Key' => $this->apiKey,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ ];
+ }
+
+ /**
+ * Get the request timeout for e-invoice.be operations.
+ */
+ protected function getTimeout(): int
+ {
+ return (int) config('invoices.peppol.e_invoice_be.timeout', 90);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/EInvoiceBe/HealthClient.php b/Modules/Invoices/Peppol/Clients/EInvoiceBe/HealthClient.php
new file mode 100644
index 000000000..aaec9eb24
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/EInvoiceBe/HealthClient.php
@@ -0,0 +1,226 @@
+buildUrl('/health/ping');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Get comprehensive health status of the API.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "status": "healthy",
+ * "timestamp": "2025-01-15T10:00:00Z",
+ * "version": "2.0.1",
+ * "components": {
+ * "database": {
+ * "status": "up",
+ * "response_time_ms": 15
+ * },
+ * "peppol_network": {
+ * "status": "up",
+ * "sml_accessible": true,
+ * "smp_queries": "operational"
+ * },
+ * "document_processing": {
+ * "status": "up",
+ * "queue_length": 42,
+ * "average_processing_time_ms": 350
+ * }
+ * },
+ * "uptime_seconds": 2592000,
+ * "last_restart": "2025-01-01T00:00:00Z"
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function getStatus(): Response
+ {
+ $url = $this->buildUrl('/health/status');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Get detailed system metrics.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "metrics": {
+ * "requests_per_minute": 125,
+ * "active_connections": 42,
+ * "documents_processed_today": 1543,
+ * "documents_in_queue": 12,
+ * "average_response_time_ms": 245,
+ * "error_rate_percent": 0.02
+ * },
+ * "resource_usage": {
+ * "cpu_percent": 35,
+ * "memory_used_mb": 2048,
+ * "memory_total_mb": 8192,
+ * "disk_used_percent": 45
+ * },
+ * "timestamp": "2025-01-15T10:00:00Z"
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function getMetrics(): Response
+ {
+ $url = $this->buildUrl('/health/metrics');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Check connectivity to Peppol network components.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "peppol_connectivity": {
+ * "sml_status": "reachable",
+ * "sml_response_time_ms": 125,
+ * "smp_queries_operational": true,
+ * "access_points_reachable": 245,
+ * "network_issues": []
+ * },
+ * "last_check": "2025-01-15T09:59:30Z",
+ * "next_check": "2025-01-15T10:04:30Z"
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function checkPeppolConnectivity(): Response
+ {
+ $url = $this->buildUrl('/health/peppol');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Get API version information.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "version": "2.0.1",
+ * "build_date": "2025-01-10",
+ * "environment": "production",
+ * "api_endpoints": {
+ * "documents": "/api/documents",
+ * "participants": "/api/participants",
+ * "tracking": "/api/tracking",
+ * "webhooks": "/api/webhooks"
+ * },
+ * "supported_formats": [
+ * "PEPPOL_BIS_3.0",
+ * "UBL_2.1",
+ * "UBL_2.4",
+ * "CII"
+ * ]
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function getVersion(): Response
+ {
+ $url = $this->buildUrl('/health/version');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Perform a readiness check (for load balancers).
+ *
+ * Returns 200 OK only if the service is ready to accept requests.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "ready": true,
+ * "checks": {
+ * "database": "ready",
+ * "peppol_network": "ready",
+ * "queue_processor": "ready"
+ * }
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function checkReadiness(): Response
+ {
+ $url = $this->buildUrl('/health/ready');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Perform a liveness check (for orchestrators like Kubernetes).
+ *
+ * Returns 200 OK if the service is alive (even if not ready).
+ *
+ * Example response:
+ * ```json
+ * {
+ * "alive": true
+ * }
+ * ```
+ *
+ * @return Response
+ */
+ public function checkLiveness(): Response
+ {
+ $url = $this->buildUrl('/health/live');
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/EInvoiceBe/ParticipantsClient.php b/Modules/Invoices/Peppol/Clients/EInvoiceBe/ParticipantsClient.php
new file mode 100644
index 000000000..1e0067faa
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/EInvoiceBe/ParticipantsClient.php
@@ -0,0 +1,154 @@
+buildUrl('/participants/search');
+ $options = $this->getRequestOptions([
+ 'payload' => array_filter([
+ 'participant_id' => $participantId,
+ 'scheme' => $scheme,
+ ]),
+ ]);
+
+ return $this->client->request(RequestMethod::POST->value, $url, $options);
+ }
+
+ /**
+ * Lookup participant by identifier (alternative endpoint).
+ *
+ * Example response:
+ * ```json
+ * {
+ * "id": "BE:0123456789",
+ * "scheme": "BE:CBE",
+ * "name": "Example Company",
+ * "country": "BE",
+ * "capabilities": {
+ * "receives_invoices": true,
+ * "receives_credit_notes": true,
+ * "receives_orders": false
+ * }
+ * }
+ * ```
+ *
+ * @param string $participantId The participant identifier (format: scheme:id)
+ *
+ * @return Response
+ */
+ public function lookupParticipant(string $participantId): Response
+ {
+ $url = $this->buildUrl("/participants/{$participantId}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+
+ /**
+ * Check if a participant can receive a specific document type.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "participant_id": "BE:0123456789",
+ * "document_type": "invoice",
+ * "can_receive": true,
+ * "endpoint": "https://access-point.example.com/receive"
+ * }
+ * ```
+ *
+ * @param string $participantId The participant identifier
+ * @param string $documentType The document type (e.g., 'invoice', 'credit_note')
+ *
+ * @return Response
+ */
+ public function checkCapability(string $participantId, string $documentType): Response
+ {
+ $url = $this->buildUrl("/participants/{$participantId}/capabilities");
+ $options = $this->getRequestOptions([
+ 'payload' => [
+ 'document_type' => $documentType,
+ ],
+ ]);
+
+ return $this->client->request(RequestMethod::POST->value, $url, $options);
+ }
+
+ /**
+ * Get service metadata for a participant.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "participant_id": "BE:0123456789",
+ * "service_metadata": {
+ * "endpoint_url": "https://access-point.example.com",
+ * "certificate_info": {
+ * "subject": "CN=Example Company",
+ * "issuer": "CN=Peppol CA",
+ * "valid_from": "2024-01-01",
+ * "valid_to": "2026-01-01"
+ * },
+ * "transport_profile": "peppol-transport-as4-v2_0"
+ * }
+ * }
+ * ```
+ *
+ * @param string $participantId The participant identifier
+ *
+ * @return Response
+ */
+ public function getServiceMetadata(string $participantId): Response
+ {
+ $url = $this->buildUrl("/participants/{$participantId}/metadata");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET->value, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/EInvoiceBe/TrackingClient.php b/Modules/Invoices/Peppol/Clients/EInvoiceBe/TrackingClient.php
new file mode 100644
index 000000000..9aae41eb8
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/EInvoiceBe/TrackingClient.php
@@ -0,0 +1,208 @@
+buildUrl("/tracking/{$documentId}/history");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ /**
+ * Get current status of a document.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "document_id": "DOC-123",
+ * "current_status": "delivered",
+ * "last_updated": "2025-01-15T10:05:30Z",
+ * "recipient_participant_id": "BE:0987654321",
+ * "transmission_details": {
+ * "sent_at": "2025-01-15T10:02:15Z",
+ * "delivered_at": "2025-01-15T10:05:30Z",
+ * "access_point": "https://recipient-ap.example.com"
+ * }
+ * }
+ * ```
+ *
+ * @param string $documentId The document ID
+ *
+ * @return Response
+ */
+ public function getStatus(string $documentId): Response
+ {
+ $url = $this->buildUrl("/tracking/{$documentId}/status");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ /**
+ * Get delivery confirmation details.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "document_id": "DOC-123",
+ * "delivery_confirmation": {
+ * "confirmed": true,
+ * "confirmed_at": "2025-01-15T10:05:30Z",
+ * "confirmation_type": "MDN",
+ * "message_id": "MDN-789",
+ * "recipient_signature": "..."
+ * },
+ * "processing_status": {
+ * "processed": true,
+ * "processed_at": "2025-01-15T10:10:00Z",
+ * "status_code": "AP", // Accepted
+ * "status_message": "Invoice accepted by recipient"
+ * }
+ * }
+ * ```
+ *
+ * @param string $documentId The document ID
+ *
+ * @return Response
+ */
+ public function getDeliveryConfirmation(string $documentId): Response
+ {
+ $url = $this->buildUrl("/tracking/{$documentId}/confirmation");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ /**
+ * List all documents with optional filtering.
+ *
+ * Example request:
+ * ```json
+ * {
+ * "status": "delivered",
+ * "from_date": "2025-01-01",
+ * "to_date": "2025-01-31",
+ * "recipient": "BE:0987654321",
+ * "limit": 50,
+ * "offset": 0
+ * }
+ * ```
+ *
+ * Example response:
+ * ```json
+ * {
+ * "total": 150,
+ * "limit": 50,
+ * "offset": 0,
+ * "documents": [
+ * {
+ * "document_id": "DOC-123",
+ * "invoice_number": "INV-2025-001",
+ * "status": "delivered",
+ * "recipient": "BE:0987654321",
+ * "sent_at": "2025-01-15T10:00:00Z",
+ * "delivered_at": "2025-01-15T10:05:30Z"
+ * },
+ * // ... more documents
+ * ]
+ * }
+ * ```
+ *
+ * @param array $filters Optional filters
+ *
+ * @return Response
+ */
+ public function listDocuments(array $filters = []): Response
+ {
+ $url = $this->buildUrl('/tracking/documents');
+ $options = $this->getRequestOptions([
+ 'payload' => $filters,
+ ]);
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ /**
+ * Get error details for a failed transmission.
+ *
+ * Example response:
+ * ```json
+ * {
+ * "document_id": "DOC-123",
+ * "status": "failed",
+ * "errors": [
+ * {
+ * "error_code": "RECIPIENT_NOT_FOUND",
+ * "error_message": "Recipient participant not found in SML",
+ * "occurred_at": "2025-01-15T10:02:00Z",
+ * "severity": "fatal"
+ * }
+ * ],
+ * "retry_possible": false,
+ * "suggested_action": "Verify recipient Peppol ID and resubmit"
+ * }
+ * ```
+ *
+ * @param string $documentId The document ID
+ *
+ * @return Response
+ */
+ public function getErrors(string $documentId): Response
+ {
+ $url = $this->buildUrl("/tracking/{$documentId}/errors");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/CreditNoteClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/CreditNoteClient.php
new file mode 100644
index 000000000..037af8a94
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/CreditNoteClient.php
@@ -0,0 +1,41 @@
+buildUrl('/credit-notes');
+ $options = $this->getRequestOptions(['payload' => $payload]);
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/DocumentClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/DocumentClient.php
new file mode 100644
index 000000000..ba0a23550
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/DocumentClient.php
@@ -0,0 +1,38 @@
+buildUrl("/documents/{$id}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ public function cancelDocument(string $id): Response
+ {
+ $url = $this->buildUrl("/documents/{$id}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::DELETE, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/InvoiceClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/InvoiceClient.php
new file mode 100644
index 000000000..def271ce8
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/InvoiceClient.php
@@ -0,0 +1,41 @@
+buildUrl('/invoices');
+ $options = $this->getRequestOptions(['payload' => $payload]);
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/LetsPeppolClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/LetsPeppolClient.php
new file mode 100644
index 000000000..0479e2d7c
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/LetsPeppolClient.php
@@ -0,0 +1,56 @@
+accessToken = $accessToken;
+ }
+
+ /**
+ * Get the list of configuration keys this provider requires from merchant_clients.
+ *
+ * @return array
+ */
+ public static function settings(): array
+ {
+ return ['client_id', 'client_secret', 'access_token'];
+ }
+
+ protected function getAuthenticationHeaders(): array
+ {
+ return [
+ 'Authorization' => 'Bearer ' . $this->accessToken,
+ ];
+ }
+
+ protected function getTimeout(): int
+ {
+ return 30;
+ }
+
+ /**
+ * Get the OAuth2 token endpoint URL for LetsPeppol.
+ *
+ * @return string the token endpoint URL
+ */
+ protected function tokenUrl(): ?string
+ {
+ return 'https://auth.letspeppol.com/oauth/token';
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/ParticipantClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/ParticipantClient.php
new file mode 100644
index 000000000..b50667e31
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/ParticipantClient.php
@@ -0,0 +1,35 @@
+buildUrl("/participants/{$scheme}/{$id}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/LetsPeppol/TransmissionClient.php b/Modules/Invoices/Peppol/Clients/LetsPeppol/TransmissionClient.php
new file mode 100644
index 000000000..6be6301be
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/LetsPeppol/TransmissionClient.php
@@ -0,0 +1,31 @@
+buildUrl("/transmissions/{$id}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Qonto/ClientInvoicesClient.php b/Modules/Invoices/Peppol/Clients/Qonto/ClientInvoicesClient.php
new file mode 100644
index 000000000..ab8706424
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Qonto/ClientInvoicesClient.php
@@ -0,0 +1,35 @@
+buildUrl('/client/invoices/import');
+ $options = $this->getRequestOptions([
+ 'headers' => ['Content-Type' => 'multipart/form-data'],
+ ]);
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+
+ public function sendByEinvoice(string $clientInvoiceId): Response
+ {
+ $url = $this->buildUrl("/client/invoices/{$clientInvoiceId}/send/einvoice");
+ $options = $this->getRequestOptions(['payload' => []]);
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+
+ public function getStatus(string $clientInvoiceId): Response
+ {
+ $url = $this->buildUrl("/client/invoices/{$clientInvoiceId}/status");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Qonto/QontoClient.php b/Modules/Invoices/Peppol/Clients/Qonto/QontoClient.php
new file mode 100644
index 000000000..634693a70
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Qonto/QontoClient.php
@@ -0,0 +1,62 @@
+stagingToken = $stagingToken;
+ }
+
+ /**
+ * Get the list of configuration keys this provider requires from merchant_clients.
+ *
+ * @return array
+ */
+ public static function settings(): array
+ {
+ return ['access_token', 'staging_token'];
+ }
+
+ /**
+ * Authenticate with Qonto using bearer token (API key).
+ *
+ * Qonto uses simple bearer token authentication — no token exchange required.
+ * This method validates the API key is present.
+ *
+ * @param array $credentials Must contain 'access_token' or 'api_key'
+ *
+ * @return bool True if API key/token is present and valid
+ */
+ public function authenticate(array $credentials = []): bool
+ {
+ return ! empty($credentials['access_token'] || $credentials['api_key']);
+ }
+
+ protected function getAuthenticationHeaders(): array
+ {
+ $headers = ['Authorization' => 'Bearer ' . $this->apiKey];
+
+ if ($this->stagingToken) {
+ $headers['X-Qonto-Staging-Token'] = $this->stagingToken;
+ }
+
+ return $headers;
+ }
+
+ protected function getTimeout(): int
+ {
+ return 30;
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Qonto/SupplierInvoicesClient.php b/Modules/Invoices/Peppol/Clients/Qonto/SupplierInvoicesClient.php
new file mode 100644
index 000000000..ddf746579
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Qonto/SupplierInvoicesClient.php
@@ -0,0 +1,17 @@
+buildUrl('/supplier/invoices');
+ $options = $this->getRequestOptions(['payload' => $filters]);
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Storecove/DocumentSubmissionsClient.php b/Modules/Invoices/Peppol/Clients/Storecove/DocumentSubmissionsClient.php
new file mode 100644
index 000000000..2fbd7beb3
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Storecove/DocumentSubmissionsClient.php
@@ -0,0 +1,93 @@
+buildUrl('/document_submissions');
+ $options = $this->getRequestOptions([
+ 'payload' => $payload,
+ ]);
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+
+ /**
+ * Get evidence for a document submission (sending/receiving evidence).
+ *
+ * Example response:
+ * ```json
+ * {
+ * "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ * "evidenceType": "sending",
+ * "evidence": "PD94bWwgdmVyc2lvbj...base64...==",
+ * "timestamp": "2025-01-15T10:05:30Z",
+ * "status": "delivered"
+ * }
+ * ```
+ *
+ * @param string $submissionGuid The GUID of the document submission
+ * @param string $type The type of evidence ('sending' or 'receiving')
+ *
+ * @return Response
+ */
+ public function getEvidence(string $submissionGuid, string $type = 'sending'): Response
+ {
+ $url = $this->buildUrl("/document_submissions/{$submissionGuid}/evidence/{$type}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Storecove/ReceivedDocumentsClient.php b/Modules/Invoices/Peppol/Clients/Storecove/ReceivedDocumentsClient.php
new file mode 100644
index 000000000..3b86b63b3
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Storecove/ReceivedDocumentsClient.php
@@ -0,0 +1,45 @@
+buildUrl("/received_documents/{$guid}/document");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/Storecove/StorecoveClient.php b/Modules/Invoices/Peppol/Clients/Storecove/StorecoveClient.php
new file mode 100644
index 000000000..a9efd7661
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/Storecove/StorecoveClient.php
@@ -0,0 +1,36 @@
+
+ */
+ public static function settings(): array
+ {
+ return ['api_key', 'legal_entity_id'];
+ }
+
+ protected function getAuthenticationHeaders(): array
+ {
+ return [
+ 'Authorization' => 'Bearer ' . $this->apiKey,
+ ];
+ }
+
+ protected function getTimeout(): int
+ {
+ return 30;
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/SuperPdp/InvoicesClient.php b/Modules/Invoices/Peppol/Clients/SuperPdp/InvoicesClient.php
new file mode 100644
index 000000000..6829da879
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/SuperPdp/InvoicesClient.php
@@ -0,0 +1,52 @@
+buildUrl('/invoices');
+ $options = $this->getRequestOptions([
+ 'payload' => $pdfBinary,
+ 'headers' => ['Content-Type' => 'application/pdf'],
+ ]);
+
+ if ( ! empty($query)) {
+ $url .= '?' . http_build_query($query);
+ }
+
+ return $this->client->request(RequestMethod::POST, $url, $options);
+ }
+
+ public function getInvoiceStatus(string $externalId): Response
+ {
+ $url = $this->buildUrl("/invoices/{$externalId}");
+ $options = $this->getRequestOptions();
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+
+ public function listEvents(array $filters = []): Response
+ {
+ $url = $this->buildUrl('/events');
+ $options = $this->getRequestOptions(['payload' => $filters]);
+
+ return $this->client->request(RequestMethod::GET, $url, $options);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Clients/SuperPdp/SuperPdpClient.php b/Modules/Invoices/Peppol/Clients/SuperPdp/SuperPdpClient.php
new file mode 100644
index 000000000..3248728f4
--- /dev/null
+++ b/Modules/Invoices/Peppol/Clients/SuperPdp/SuperPdpClient.php
@@ -0,0 +1,49 @@
+accessToken = $accessToken;
+ }
+
+ /**
+ * Get the list of configuration keys this provider requires from merchant_clients.
+ *
+ * @return array
+ */
+ public static function settings(): array
+ {
+ return ['client_id', 'client_secret', 'access_token'];
+ }
+
+ protected function getAuthenticationHeaders(): array
+ {
+ return ['Authorization' => 'Bearer ' . $this->accessToken];
+ }
+
+ protected function getTimeout(): int
+ {
+ return 30;
+ }
+
+ /**
+ * Get the OAuth2 token endpoint URL for SuperPDP.
+ *
+ * @return string the token endpoint URL
+ */
+ protected function tokenUrl(): ?string
+ {
+ return 'https://auth.superpdp.com/oauth/token';
+ }
+}
diff --git a/Modules/Invoices/Peppol/Contracts/ProviderInterface.php b/Modules/Invoices/Peppol/Contracts/ProviderInterface.php
new file mode 100644
index 000000000..1f4b1cd70
--- /dev/null
+++ b/Modules/Invoices/Peppol/Contracts/ProviderInterface.php
@@ -0,0 +1,132 @@
+ list of config keys (e.g., ['api_key'], ['client_id', 'client_secret', 'access_token'])
+ */
+ public static function settings(): array;
+
+ /**
+ * Get the subset of settings() that are system-managed rather than human-entered.
+ *
+ * OAuth2 providers list `access_token` in settings() because it's part of what
+ * authenticate() reads from stored config, but it's fetched and persisted
+ * automatically by RefreshesOAuth2Token::ensureAuthenticated() — an admin should
+ * never type it into a credential form. Static-credential providers have none.
+ *
+ * @return array subset of settings() keys to exclude from credential-entry forms
+ */
+ public static function managedSettingsKeys(): array;
+
+ /**
+ * Test the connection with provider credentials.
+ *
+ * @param array $config Provider-specific configuration
+ *
+ * @return array{ok: bool, message: string}
+ */
+ public function testConnection(array $config): array;
+
+ /**
+ * Validate a Peppol participant ID.
+ *
+ * @param string $scheme Peppol scheme (e.g., BE:CBE, DE:VAT)
+ * @param string $id Participant identifier
+ *
+ * @return array{present: bool, details: array|null}
+ */
+ public function validatePeppolId(string $scheme, string $id): array;
+
+ /**
+ * Transmit an invoice to the Peppol network.
+ *
+ * @param array $transmissionData data transfer object containing the invoice payload, recipient identifiers, and transmission metadata
+ *
+ * @return array{accepted: bool, external_id: string|null, status_code: int, message: string, response: array|null} associative array with keys: `accepted` is `true` if the provider accepted the submission, `external_id` the provider's transaction/document ID or `null`, `status_code` the provider HTTP/status code, `message` a human-readable status, and `response` the raw provider response or `null`
+ */
+ public function sendInvoice(array $transmissionData): array;
+
+ /**
+ * Get the status of a transmission.
+ *
+ * @param string $externalId Provider's transaction/document ID
+ *
+ * @return array{status: string, ack_payload: array|null}
+ */
+ public function getTransmissionStatus(string $externalId): array;
+
+ /**
+ * Register or update a webhook callback URL with the provider (optional — not all providers support this).
+ *
+ * @param string $url the webhook endpoint URL to register
+ * @param string $secret the webhook signing secret used to verify callbacks
+ *
+ * @return array{success: bool, message: string} `success` is `true` if registration succeeded, `false` otherwise; `message` contains a human-readable result or error
+ */
+ public function registerWebhookCallback(string $url, string $secret): array;
+
+ /**
+ * Retrieve acknowledgements from the provider for polling-based integrations.
+ *
+ * @param \Carbon\Carbon|null $since optional timestamp to limit results to acknowledgements received at or after this time
+ *
+ * @return array an array of acknowledgement records; each record is an associative array representing the provider's acknowledgement payload
+ */
+ public function fetchAcknowledgements(?\Carbon\Carbon $since = null): array;
+
+ /**
+ * Cancel a pending or sent document identified by the provider's external ID.
+ *
+ * @param string $externalId provider's transaction or document ID
+ *
+ * @return array{success: bool, message: string} `success` is true when the cancellation was accepted, `message` contains provider response or error details
+ */
+ public function cancelDocument(string $externalId): array;
+
+ /**
+ * Authenticate the provider using stored credentials.
+ *
+ * Implementation varies by provider: OAuth2 clients refresh an access token, static-credential
+ * providers validate that required keys are present.
+ *
+ * @return bool true if authentication succeeded or credentials are valid, false otherwise
+ */
+ public function authenticate(): bool;
+
+ /**
+ * Classify a provider error into a generic category.
+ *
+ * Maps provider responses to one of three categories to guide retry or handling:
+ * - `ERROR_TRANSIENT`: retryable conditions such as server errors, timeouts, or rate limits.
+ * - `ERROR_PERMANENT`: non-retryable conditions such as invalid data, unauthorized, or not found.
+ * - `ERROR_UNKNOWN`: ambiguous or unclassified conditions that require investigation.
+ *
+ * @param int $statusCode HTTP status code returned by the provider
+ * @param array|null $responseBody optional response body returned by the provider to aid classification
+ *
+ * @return string `ERROR_TRANSIENT` if the error is retryable, `ERROR_PERMANENT` if it is not retryable, `ERROR_UNKNOWN` otherwise
+ */
+ public function classifyError(int $statusCode, ?array $responseBody = null): string;
+
+ /**
+ * Retrieve the provider's canonical name.
+ *
+ * @return string The provider's identifier (human-readable name, e.g. "Storecove").
+ */
+ public function getProviderName(): string;
+}
diff --git a/Modules/Invoices/Peppol/Enums/PeppolDocumentFormat.php b/Modules/Invoices/Peppol/Enums/PeppolDocumentFormat.php
new file mode 100644
index 000000000..1602b29e3
--- /dev/null
+++ b/Modules/Invoices/Peppol/Enums/PeppolDocumentFormat.php
@@ -0,0 +1,228 @@
+ self::CII,
+ 'IT' => self::FATTURAPA_12,
+ 'ES' => self::FACTURAE_32,
+ 'DK' => self::OIOUBL,
+ 'NO' => self::EHF_30,
+ 'NL', 'BE', 'GB', 'SE', 'FI', 'XX', '' => self::UBL_24,
+ default => self::UBL_24,
+ };
+ }
+
+ /**
+ * Get all formats suitable for a given country.
+ *
+ * @param string|null $countryCode ISO 3166-1 alpha-2 country code
+ *
+ * @return array
+ */
+ public static function formatsForCountry(?string $countryCode): array
+ {
+ $country = mb_strtoupper($countryCode ?? '');
+
+ return match ($country) {
+ 'AT' => [self::CII, self::UBL_21],
+ 'DE' => [self::ZUGFERD_20, self::ZUGFERD_10, self::CII, self::UBL_21],
+ 'DK' => [self::OIOUBL, self::UBL_21],
+ 'ES' => [self::FACTURAE_32, self::UBL_21],
+ 'FR' => [self::FACTURX, self::CII, self::UBL_21],
+ 'IT' => [self::FATTURAPA_12, self::UBL_21],
+ 'NO' => [self::EHF_30, self::UBL_21],
+ default => [self::UBL_21, self::CII],
+ };
+ }
+
+ /**
+ * Get the human-readable label for the format.
+ *
+ * @return string
+ */
+ public function label(): string
+ {
+ return match ($this) {
+ self::UBL_21 => 'UBL 2.1',
+ self::UBL_24 => 'UBL 2.4',
+ self::CII => 'Cross Industry Invoice (CII)',
+ self::FACTURAE_32 => 'Facturae 3.2 (Spain)',
+ self::FATTURAPA_12 => 'FatturaPA 1.2 (Italy)',
+ self::FACTURX => 'Factur-X (France/Germany)',
+ self::ZUGFERD_10 => 'ZUGFeRD 1.0',
+ self::ZUGFERD_20 => 'ZUGFeRD 2.0',
+ self::OIOUBL => 'OIOUBL (Denmark)',
+ self::EHF_30 => 'EHF 3.0 (Norway)',
+ self::PEPPOL_BIS_30 => 'PEPPOL BIS Billing 3.0',
+ };
+ }
+
+ /**
+ * Get the description for the format.
+ *
+ * @return string
+ */
+ public function description(): string
+ {
+ return match ($this) {
+ self::UBL_21 => 'Most widely used format across Europe. Recommended for most use cases.',
+ self::UBL_24 => 'Updated UBL format with enhanced validation rules.',
+ self::CII => 'Common in Germany, France, and Austria. UN/CEFACT standard.',
+ self::FACTURAE_32 => 'Mandatory for invoices to Spanish public administration.',
+ self::FATTURAPA_12 => 'Mandatory format for all B2B and B2G invoices in Italy.',
+ self::FACTURX => 'Hybrid PDF/A-3 format with embedded XML. Used in France and Germany.',
+ self::ZUGFERD_10 => 'German standard combining PDF with embedded XML invoice data.',
+ self::ZUGFERD_20 => 'Updated ZUGFeRD compatible with Factur-X. Uses CII format.',
+ self::OIOUBL => 'Danish UBL-based format with national extensions.',
+ self::EHF_30 => 'Norwegian EHF 3.0 format for PEPPOL network.',
+ self::PEPPOL_BIS_30 => 'Default PEPPOL format for most countries. Based on UBL.',
+ };
+ }
+
+ /**
+ * Get the file extension for this format.
+ *
+ * @return string
+ */
+ public function extension(): string
+ {
+ return match ($this) {
+ self::FACTURX, self::ZUGFERD_10, self::ZUGFERD_20 => 'pdf',
+ default => 'xml',
+ };
+ }
+
+ /**
+ * Check if this format requires PDF/A-3 embedding.
+ *
+ * @return bool
+ */
+ public function requiresPdfEmbedding(): bool
+ {
+ return match ($this) {
+ self::FACTURX, self::ZUGFERD_10, self::ZUGFERD_20 => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Get the XML namespace for this format.
+ *
+ * @return string
+ */
+ public function xmlNamespace(): string
+ {
+ return match ($this) {
+ self::UBL_21, self::UBL_24, self::OIOUBL, self::EHF_30 => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
+ self::CII, self::FACTURX, self::ZUGFERD_20 => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
+ self::ZUGFERD_10 => 'urn:ferd:CrossIndustryDocument:invoice:1p0',
+ self::FACTURAE_32 => 'http://www.facturae.gob.es/formato/Versiones/Facturaev3_2.xml',
+ self::FATTURAPA_12 => 'http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2',
+ self::PEPPOL_BIS_30 => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
+ };
+ }
+
+ /**
+ * Check if this format is mandatory for the given country.
+ *
+ * @param string|null $countryCode ISO 3166-1 alpha-2 country code
+ *
+ * @return bool
+ */
+ public function isMandatoryFor(?string $countryCode): bool
+ {
+ $country = mb_strtoupper($countryCode ?? '');
+
+ return match ($this) {
+ self::FATTURAPA_12 => $country === 'IT',
+ // Note: FACTURAE_32 is only mandatory for Spanish public administration
+ // Not for all invoices in Spain, so we return false
+ default => false,
+ };
+ }
+}
diff --git a/Modules/Invoices/Peppol/Enums/PeppolEndpointScheme.php b/Modules/Invoices/Peppol/Enums/PeppolEndpointScheme.php
new file mode 100644
index 000000000..80e7d93ea
--- /dev/null
+++ b/Modules/Invoices/Peppol/Enums/PeppolEndpointScheme.php
@@ -0,0 +1,249 @@
+ self::BE_CBE,
+ 'DE' => self::DE_VAT,
+ 'FR' => self::FR_SIRENE,
+ 'IT' => self::IT_VAT,
+ 'ES' => self::ES_VAT,
+ 'NL' => self::NL_KVK,
+ 'NO' => self::NO_ORGNR,
+ 'DK' => self::DK_CVR,
+ 'SE' => self::SE_ORGNR,
+ 'FI' => self::FI_OVT,
+ 'AT' => self::AT_VAT,
+ 'CH' => self::CH_UIDB,
+ 'GB' => self::GB_COH,
+ default => self::ISO_6523,
+ };
+ }
+
+ /**
+ * Get the human-readable label for the scheme.
+ *
+ * @return string
+ */
+ public function label(): string
+ {
+ return match ($this) {
+ self::BE_CBE => 'Belgian CBE/KBO/BCE Number',
+ self::DE_VAT => 'German VAT Number',
+ self::FR_SIRENE => 'French SIREN/SIRET',
+ self::IT_VAT => 'Italian VAT Number (Partita IVA)',
+ self::IT_CF => 'Italian Tax Code (Codice Fiscale)',
+ self::ES_VAT => 'Spanish NIF/CIF',
+ self::NL_KVK => 'Dutch KVK Number',
+ self::NO_ORGNR => 'Norwegian Organization Number',
+ self::DK_CVR => 'Danish CVR Number',
+ self::SE_ORGNR => 'Swedish Organization Number',
+ self::FI_OVT => 'Finnish Business ID',
+ self::AT_VAT => 'Austrian UID Number',
+ self::CH_UIDB => 'Swiss UID Number',
+ self::GB_COH => 'UK Companies House Number',
+ self::GLN => 'Global Location Number (GLN)',
+ self::DUNS => 'DUNS Number',
+ self::ISO_6523 => 'ISO 6523 (ICD 0002)',
+ };
+ }
+
+ /**
+ * Get the description for the scheme.
+ *
+ * @return string
+ */
+ public function description(): string
+ {
+ return match ($this) {
+ self::BE_CBE => 'Belgian Crossroads Bank for Enterprises number (10 digits)',
+ self::DE_VAT => 'German VAT identification number (DE + 9 digits)',
+ self::FR_SIRENE => 'French business registry number (9 or 14 digits)',
+ self::IT_VAT => 'Italian VAT number (IT + 11 digits)',
+ self::IT_CF => 'Italian fiscal code for individuals and companies (16 characters)',
+ self::ES_VAT => 'Spanish tax identification number (9 characters)',
+ self::NL_KVK => 'Dutch Chamber of Commerce number (8 digits)',
+ self::NO_ORGNR => 'Norwegian business registry number (9 digits)',
+ self::DK_CVR => 'Danish Central Business Register number (8 digits)',
+ self::SE_ORGNR => 'Swedish organization number (10 digits)',
+ self::FI_OVT => 'Finnish business identifier (7 digits + check digit)',
+ self::AT_VAT => 'Austrian VAT number (ATU + 8 digits)',
+ self::CH_UIDB => 'Swiss business identification number (CHE + 9 digits)',
+ self::GB_COH => 'UK Companies House registration number',
+ self::GLN => 'International Global Location Number (13 digits)',
+ self::DUNS => 'International Data Universal Numbering System (9 digits)',
+ self::ISO_6523 => 'International ISO 6523 identifier',
+ };
+ }
+
+ /**
+ * Validate identifier format for this scheme.
+ *
+ * @param string $identifier The identifier to validate
+ *
+ * @return bool
+ */
+ public function validates(string $identifier): bool
+ {
+ $identifier = mb_trim($identifier);
+
+ return match ($this) {
+ self::BE_CBE => (bool) preg_match('/^\d{10}$/', $identifier),
+ self::DE_VAT => (bool) preg_match('/^DE\d{9}$/', $identifier),
+ self::FR_SIRENE => (bool) preg_match('/^\d{9}(\d{5})?$/', $identifier),
+ self::IT_VAT => (bool) preg_match('/^IT\d{11}$/', $identifier),
+ self::IT_CF => (bool) preg_match('/^[A-Z0-9]{16}$/', mb_strtoupper($identifier)),
+ self::ES_VAT => (bool) preg_match('/^[A-Z]\d{7,8}[A-Z0-9]$/', mb_strtoupper($identifier)),
+ self::NL_KVK => (bool) preg_match('/^\d{8}$/', $identifier),
+ self::NO_ORGNR => (bool) preg_match('/^\d{9}$/', $identifier),
+ self::DK_CVR => (bool) preg_match('/^\d{8}$/', $identifier),
+ self::SE_ORGNR => (bool) preg_match('/^\d{6}-?\d{4}$/', $identifier),
+ self::FI_OVT => (bool) preg_match('/^\d{7}-?\d$/', $identifier),
+ self::AT_VAT => (bool) preg_match('/^ATU\d{8}$/', $identifier),
+ self::CH_UIDB => (bool) preg_match('/^CHE[-.\s]?\d{3}[-.\s]?\d{3}[-.\s]?\d{3}$/', $identifier),
+ self::GB_COH => (bool) preg_match('/^[A-Z0-9]{8}$/', mb_strtoupper($identifier)),
+ self::GLN => (bool) preg_match('/^\d{13}$/', $identifier),
+ self::DUNS => (bool) preg_match('/^\d{9}$/', $identifier),
+ self::ISO_6523 => mb_strlen($identifier) > 0, // Flexible validation
+ };
+ }
+
+ /**
+ * Format identifier according to scheme rules.
+ *
+ * @param string $identifier The raw identifier
+ *
+ * @return string Formatted identifier
+ */
+ public function format(string $identifier): string
+ {
+ $identifier = mb_trim($identifier);
+
+ return match ($this) {
+ self::SE_ORGNR => preg_replace('/^(\d{6})(\d{4})$/', '$1-$2', $identifier) ?? $identifier,
+ self::FI_OVT => preg_replace('/^(\d{7})(\d)$/', '$1-$2', $identifier) ?? $identifier,
+ default => $identifier,
+ };
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/BaseFormatHandler.php b/Modules/Invoices/Peppol/FormatHandlers/BaseFormatHandler.php
new file mode 100644
index 000000000..53d617eea
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/BaseFormatHandler.php
@@ -0,0 +1,176 @@
+format = $format;
+ }
+ }
+
+ /**
+ * Format-specific validation logic.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array Validation errors
+ */
+ abstract protected function validateFormatSpecific(Invoice $invoice): array;
+
+ /**
+ * Set the format for this handler.
+ *
+ * This method is called by the factory after instantiation.
+ *
+ * @param PeppolDocumentFormat $format The format this handler supports
+ *
+ * @return void
+ */
+ public function setFormat(PeppolDocumentFormat $format): void
+ {
+ $this->format = $format;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormat(): PeppolDocumentFormat
+ {
+ if ($this->format === null) {
+ throw new RuntimeException('Format has not been set on this handler. Call setFormat() first.');
+ }
+
+ return $this->format;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supports(Invoice $invoice): bool
+ {
+ $format = $this->getFormat();
+
+ // Check if customer's country matches format requirements
+ $customerCountry = $invoice->customer?->country_code ?? null;
+
+ // Mandatory formats must be used for their countries
+ if ($format->isMandatoryFor($customerCountry)) {
+ return true;
+ }
+
+ // Check if format is suitable for customer's country
+ $suitableFormats = PeppolDocumentFormat::formatsForCountry($customerCountry);
+
+ return in_array($format, $suitableFormats, true);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // Common validation rules
+ if ( ! $invoice->customer) {
+ $errors[] = 'Invoice must have a customer';
+ }
+
+ if ( ! $invoice->invoice_number) {
+ $errors[] = 'Invoice must have an invoice number';
+ }
+
+ if ($invoice->invoiceItems->isEmpty()) {
+ $errors[] = 'Invoice must have at least one line item';
+ }
+
+ if ( ! $invoice->invoiced_at) {
+ $errors[] = 'Invoice must have an issue date';
+ }
+
+ if ( ! $invoice->invoice_due_at) {
+ $errors[] = 'Invoice must have a due date';
+ }
+
+ // Format-specific validation
+ $formatErrors = $this->validateFormatSpecific($invoice);
+
+ return array_merge($errors, $formatErrors);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMimeType(): string
+ {
+ $format = $this->getFormat();
+
+ return $format->requiresPdfEmbedding()
+ ? 'application/pdf'
+ : 'application/xml';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFileExtension(): string
+ {
+ return $this->getFormat()->extension();
+ }
+
+ /**
+ * Get currency code from invoice or configuration.
+ *
+ * @param Invoice $invoice
+ * @param mixed ...$args
+ *
+ * @return string
+ */
+ protected function getCurrencyCode(Invoice $invoice, ...$args): string
+ {
+ // Try to get from invoice, then company settings, then config
+ return $invoice->currency_code
+ ?? config('invoices.peppol.document.currency_code')
+ ?? 'EUR';
+ }
+
+ /**
+ * Get endpoint scheme for customer's country.
+ *
+ * @param Invoice $invoice
+ *
+ * @return PeppolEndpointScheme
+ */
+ protected function getEndpointScheme(Invoice $invoice): PeppolEndpointScheme
+ {
+ $countryCode = $invoice->customer?->country_code ?? null;
+
+ return PeppolEndpointScheme::forCountry($countryCode);
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/CiiHandler.php b/Modules/Invoices/Peppol/FormatHandlers/CiiHandler.php
new file mode 100644
index 000000000..3a2303a2b
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/CiiHandler.php
@@ -0,0 +1,387 @@
+customer;
+ $company = $invoice->company;
+
+ return [
+ 'ExchangedDocumentContext' => $this->buildDocumentContext(),
+ 'ExchangedDocument' => $this->buildExchangedDocument($invoice),
+ 'SupplyChainTradeTransaction' => [
+ 'ApplicableHeaderTradeAgreement' => $this->buildHeaderTradeAgreement($invoice, $customer),
+ 'ApplicableHeaderTradeDelivery' => $this->buildHeaderTradeDelivery($invoice),
+ 'ApplicableHeaderTradeSettlement' => $this->buildHeaderTradeSettlement($invoice, $customer, $company),
+ ],
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate(Invoice $invoice): array
+ {
+ $errors = [];
+ $customer = $invoice->customer;
+ // Required fields validation
+ if (empty($invoice->invoice_number)) {
+ $errors[] = 'Invoice number is required for CII format';
+ }
+ if ( ! $invoice->invoice_date) {
+ $errors[] = 'Invoice date is required for CII format';
+ }
+ if ( ! $invoice->invoice_due_at) {
+ $errors[] = 'Invoice due date is required for CII format';
+ }
+ if (empty($customer->name)) {
+ $errors[] = 'Customer name is required for CII format';
+ }
+ if (empty($customer->country_code)) {
+ $errors[] = 'Customer country code is required for CII format';
+ }
+ if ($invoice->items->isEmpty()) {
+ $errors[] = 'At least one invoice item is required for CII format';
+ }
+ // Validate amounts
+ if ($invoice->total <= 0) {
+ $errors[] = 'Invoice total must be greater than zero for CII format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ // Implement format-specific validation
+ return [];
+ }
+
+ /**
+ * Build the document context section.
+ *
+ * @return array
+ */
+ protected function buildDocumentContext(): array
+ {
+ return [
+ 'GuidelineSpecifiedDocumentContextParameter' => [
+ 'ID' => 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0',
+ ],
+ ];
+ }
+
+ /**
+ * Build the exchanged document section.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildExchangedDocument(Invoice $invoice): array
+ {
+ return [
+ 'ID' => $invoice->invoice_number,
+ 'TypeCode' => '380', // Commercial invoice
+ 'IssueDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '@value' => $invoice->invoice_date->format('Ymd'),
+ ],
+ ],
+ 'IncludedNote' => $invoice->notes ? [
+ [
+ 'Content' => $invoice->notes,
+ ],
+ ] : null,
+ ];
+ }
+
+ /**
+ * Build the header trade agreement section.
+ *
+ * @param Invoice $invoice
+ * @param mixed $customer
+ *
+ * @return array
+ */
+ protected function buildHeaderTradeAgreement(Invoice $invoice, $customer): array
+ {
+ return [
+ 'BuyerReference' => $customer->reference ?? '',
+ 'SellerTradeParty' => $this->buildSellerParty($invoice->company),
+ 'BuyerTradeParty' => $this->buildBuyerParty($customer),
+ ];
+ }
+
+ /**
+ * Build seller party details.
+ *
+ * @param mixed $company
+ *
+ * @return array
+ */
+ protected function buildSellerParty($company): array
+ {
+ return [
+ 'Name' => $company->name ?? config('invoices.peppol.supplier.company_name'),
+ 'DefinedTradeContact' => [
+ 'PersonName' => config('invoices.peppol.supplier.contact_name'),
+ 'TelephoneUniversalCommunication' => [
+ 'CompleteNumber' => config('invoices.peppol.supplier.contact_phone'),
+ ],
+ 'EmailURIUniversalCommunication' => [
+ 'URIID' => config('invoices.peppol.supplier.contact_email'),
+ ],
+ ],
+ 'PostalTradeAddress' => [
+ 'PostcodeCode' => $company->postal_code ?? config('invoices.peppol.supplier.postal_zone'),
+ 'LineOne' => $company->address ?? config('invoices.peppol.supplier.street_name'),
+ 'CityName' => $company->city ?? config('invoices.peppol.supplier.city_name'),
+ 'CountryID' => $company->country_code ?? config('invoices.peppol.supplier.country_code'),
+ ],
+ 'SpecifiedTaxRegistration' => [
+ [
+ 'ID' => [
+ '@schemeID' => 'VA',
+ '@value' => $company->vat_number ?? config('invoices.peppol.supplier.vat_number'),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build buyer party details.
+ *
+ * @param mixed $customer
+ *
+ * @return array
+ */
+ protected function buildBuyerParty($customer): array
+ {
+ return [
+ 'Name' => $customer->name,
+ 'PostalTradeAddress' => [
+ 'PostcodeCode' => $customer->postal_code ?? '',
+ 'LineOne' => $customer->address ?? '',
+ 'CityName' => $customer->city ?? '',
+ 'CountryID' => $customer->country_code ?? '',
+ ],
+ ];
+ }
+
+ /**
+ * Build header trade delivery section.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildHeaderTradeDelivery(Invoice $invoice): array
+ {
+ return [
+ 'ActualDeliverySupplyChainEvent' => [
+ 'OccurrenceDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '@value' => ($invoice->delivery_date ?? $invoice->invoice_date)->format('Ymd'),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build header trade settlement section.
+ *
+ * @param Invoice $invoice
+ * @param mixed $customer
+ * @param mixed $company
+ *
+ * @return array
+ */
+ protected function buildHeaderTradeSettlement(Invoice $invoice, $customer, $company): array
+ {
+ $currencyCode = $this->getCurrencyCode($invoice, $customer, $company);
+
+ return [
+ 'InvoiceCurrencyCode' => $currencyCode,
+ 'SpecifiedTradeSettlementPaymentMeans' => [
+ [
+ 'TypeCode' => $this->getPaymentMeansCode($invoice),
+ 'Information' => $invoice->payment_terms ?? '',
+ ],
+ ],
+ 'ApplicableTradeTax' => $this->buildTaxTotals($invoice, $currencyCode),
+ 'SpecifiedTradePaymentTerms' => [
+ 'DueDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '@value' => $invoice->invoice_due_at->format('Ymd'),
+ ],
+ ],
+ ],
+ 'SpecifiedTradeSettlementHeaderMonetarySummation' => [
+ 'LineTotalAmount' => number_format($invoice->subtotal, 2, '.', ''),
+ 'TaxBasisTotalAmount' => number_format($invoice->subtotal, 2, '.', ''),
+ 'TaxTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '@value' => number_format($invoice->total_tax, 2, '.', ''),
+ ],
+ 'GrandTotalAmount' => number_format($invoice->total, 2, '.', ''),
+ 'DuePayableAmount' => number_format($invoice->balance_due, 2, '.', ''),
+ ],
+ 'IncludedSupplyChainTradeLineItem' => $this->buildLineItems($invoice->items, $currencyCode),
+ ];
+ }
+
+ /**
+ * Build tax totals for the invoice.
+ *
+ * @param Invoice $invoice
+ * @param string $currencyCode
+ *
+ * @return array
+ */
+ protected function buildTaxTotals(Invoice $invoice, string $currencyCode): array
+ {
+ $taxTotals = [];
+
+ // Group taxes by rate
+ $taxGroups = [];
+ foreach ($invoice->items as $item) {
+ $rate = $item->tax_rate ?? 0;
+ $rateKey = (string) $rate;
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'basis' => 0,
+ 'amount' => 0,
+ ];
+ }
+ $taxGroups[$rateKey]['basis'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->tax_total;
+ }
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxTotals[] = [
+ 'CalculatedAmount' => number_format($group['amount'], 2, '.', ''),
+ 'TypeCode' => 'VAT',
+ 'BasisAmount' => number_format($group['basis'], 2, '.', ''),
+ 'CategoryCode' => $this->getTaxCategoryCode($rate),
+ 'RateApplicablePercent' => number_format($rate, 2, '.', ''),
+ ];
+ }
+
+ return $taxTotals;
+ }
+
+ /**
+ * Build line items for the invoice.
+ *
+ * @param mixed $items
+ * @param string $currencyCode
+ *
+ * @return array
+ */
+ protected function buildLineItems($items, string $currencyCode): array
+ {
+ $lineItems = [];
+
+ foreach ($items as $index => $item) {
+ $lineItems[] = [
+ 'AssociatedDocumentLineDocument' => [
+ 'LineID' => (string) ($index + 1),
+ ],
+ 'SpecifiedTradeProduct' => [
+ 'Name' => $item->name,
+ 'Description' => $item->description ?? '',
+ ],
+ 'SpecifiedLineTradeAgreement' => [
+ 'NetPriceProductTradePrice' => [
+ 'ChargeAmount' => number_format($item->price, 2, '.', ''),
+ ],
+ ],
+ 'SpecifiedLineTradeDelivery' => [
+ 'BilledQuantity' => [
+ '@unitCode' => $item->unit_code ?? config('invoices.peppol.document.default_unit_code'),
+ '@value' => number_format($item->quantity, 2, '.', ''),
+ ],
+ ],
+ 'SpecifiedLineTradeSettlement' => [
+ 'ApplicableTradeTax' => [
+ 'TypeCode' => 'VAT',
+ 'CategoryCode' => $this->getTaxCategoryCode($item->tax_rate ?? 0),
+ 'RateApplicablePercent' => number_format($item->tax_rate ?? 0, 2, '.', ''),
+ ],
+ 'SpecifiedTradeSettlementLineMonetarySummation' => [
+ 'LineTotalAmount' => number_format($item->subtotal, 2, '.', ''),
+ ],
+ ],
+ ];
+ }
+
+ return $lineItems;
+ }
+
+ /**
+ * Get payment means code based on invoice payment method.
+ *
+ * @param Invoice $invoice
+ *
+ * @return string
+ */
+ protected function getPaymentMeansCode(Invoice $invoice): string
+ {
+ // 30 = Credit transfer, 48 = Bank card, 49 = Direct debit
+ return '30'; // Default to credit transfer
+ }
+
+ /**
+ * Get tax category code based on tax rate.
+ *
+ * @param float $taxRate
+ *
+ * @return string
+ */
+ protected function getTaxCategoryCode(float $taxRate): string
+ {
+ if ($taxRate === 0.0) {
+ return 'Z'; // Zero rated
+ }
+
+ return 'S'; // Standard rate
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/EhfHandler.php b/Modules/Invoices/Peppol/FormatHandlers/EhfHandler.php
new file mode 100644
index 000000000..5bc68665f
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/EhfHandler.php
@@ -0,0 +1,496 @@
+customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'ubl_version_id' => '2.1',
+ 'customization_id' => 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
+ 'profile_id' => 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
+ 'id' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'due_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'invoice_type_code' => '380', // Commercial invoice
+ 'document_currency_code' => $currencyCode,
+ 'buyer_reference' => $this->getBuyerReference($invoice),
+
+ // Supplier party
+ 'accounting_supplier_party' => $this->buildSupplierParty($invoice, $endpointScheme),
+
+ // Customer party
+ 'accounting_customer_party' => $this->buildCustomerParty($invoice, $endpointScheme),
+
+ // Delivery
+ 'delivery' => $this->buildDelivery($invoice),
+
+ // Payment means
+ 'payment_means' => $this->buildPaymentMeans($invoice),
+
+ // Payment terms
+ 'payment_terms' => $this->buildPaymentTerms($invoice),
+
+ // Tax total
+ 'tax_total' => $this->buildTaxTotal($invoice, $currencyCode),
+
+ // Legal monetary total
+ 'legal_monetary_total' => $this->buildMonetaryTotal($invoice, $currencyCode),
+
+ // Invoice lines
+ 'invoice_line' => $this->buildInvoiceLines($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Generate the EHF-formatted document for an invoice as a string.
+ *
+ * Converts the given Invoice into the EHF document representation and returns it
+ * as a string. Note: the current implementation returns a JSON-encoded
+ * representation of the transformed data as a placeholder for the final XML.
+ *
+ * @param Invoice $invoice the invoice to convert
+ * @param array $options optional transformation options
+ *
+ * @return string the EHF-formatted document as a string; currently a JSON-encoded representation of the transformed data (placeholder for proper XML)
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Builds the supplier party structure for the EHF (Peppol) invoice payload.
+ *
+ * Returns a nested array under the `party` key containing the supplier's Peppol endpoint ID, party identification
+ * (organization number), company name, postal address (street, city, postal zone, country), tax scheme (VAT),
+ * legal entity details (registration name and address) and contact details (name, phone, email).
+ *
+ * @param Invoice $invoice invoice model (source of contextual invoice data; supplier values are taken from config)
+ * @param mixed $endpointScheme enum-like object providing the Peppol endpoint scheme identifier via `$endpointScheme->value`
+ *
+ * @return array structured supplier party data for inclusion in the transformed EHF payload
+ */
+ protected function buildSupplierParty(Invoice $invoice, $endpointScheme): array
+ {
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_identification' => [
+ 'id' => [
+ 'value' => config('invoices.peppol.supplier.organization_number'),
+ 'scheme_id' => 'NO:ORGNR',
+ ],
+ ],
+ 'party_name' => [
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'postal_address' => [
+ 'street_name' => config('invoices.peppol.supplier.street_name'),
+ 'city_name' => config('invoices.peppol.supplier.city_name'),
+ 'postal_zone' => config('invoices.peppol.supplier.postal_zone'),
+ 'country' => [
+ 'identification_code' => config('invoices.peppol.supplier.country_code', 'NO'),
+ ],
+ ],
+ 'party_tax_scheme' => [
+ 'company_id' => config('invoices.peppol.supplier.vat_number'),
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => config('invoices.peppol.supplier.company_name'),
+ 'company_id' => [
+ 'value' => config('invoices.peppol.supplier.organization_number'),
+ 'scheme_id' => 'NO:ORGNR',
+ ],
+ 'registration_address' => [
+ 'city_name' => config('invoices.peppol.supplier.city_name'),
+ 'country' => [
+ 'identification_code' => config('invoices.peppol.supplier.country_code', 'NO'),
+ ],
+ ],
+ ],
+ 'contact' => [
+ 'name' => config('invoices.peppol.supplier.contact_name'),
+ 'telephone' => config('invoices.peppol.supplier.contact_phone'),
+ 'electronic_mail' => config('invoices.peppol.supplier.contact_email'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the customer party section for an EHF invoice payload.
+ *
+ * @param Invoice $invoice invoice containing customer data used to populate party fields
+ * @param mixed $endpointScheme object providing a `value` property used as the endpoint identification scheme
+ *
+ * @return array array representing the customer party with keys: `party` => [
+ * 'endpoint_id', 'party_identification', 'party_name', 'postal_address',
+ * 'party_legal_entity', 'contact'
+ * ]
+ */
+ protected function buildCustomerParty(Invoice $invoice, $endpointScheme): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => $customer?->peppol_id ?? '',
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_identification' => [
+ 'id' => [
+ 'value' => $customer?->organization_number ?? $customer?->peppol_id ?? '',
+ 'scheme_id' => 'NO:ORGNR',
+ ],
+ ],
+ 'party_name' => [
+ 'name' => $customer?->company_name ?? $customer?->customer_name,
+ ],
+ 'postal_address' => [
+ 'street_name' => $customer?->street1 ?? '',
+ 'additional_street_name' => $customer?->street2 ?? '',
+ 'city_name' => $customer?->city ?? '',
+ 'postal_zone' => $customer?->zip ?? '',
+ 'country' => [
+ 'identification_code' => $customer?->country_code ?? 'NO',
+ ],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => $customer?->company_name ?? $customer?->customer_name,
+ 'company_id' => [
+ 'value' => $customer?->organization_number ?? $customer?->peppol_id ?? '',
+ 'scheme_id' => 'NO:ORGNR',
+ ],
+ ],
+ 'contact' => [
+ 'name' => $customer?->contact_name ?? '',
+ 'telephone' => $customer?->contact_phone ?? '',
+ 'electronic_mail' => $customer?->contact_email ?? '',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the delivery information array using the invoice date and the customer's address.
+ *
+ * @param Invoice $invoice the invoice from which to derive the delivery date and customer address
+ *
+ * @return array array with keys:
+ * - `actual_delivery_date`: date string in `YYYY-MM-DD` format,
+ * - `delivery_location`: array containing `address` with `street_name`, `city_name`, `postal_zone`, and `country` (`identification_code`)
+ */
+ protected function buildDelivery(Invoice $invoice): array
+ {
+ return [
+ 'actual_delivery_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'delivery_location' => [
+ 'address' => [
+ 'street_name' => $invoice->customer?->street1 ?? '',
+ 'city_name' => $invoice->customer?->city ?? '',
+ 'postal_zone' => $invoice->customer?->zip ?? '',
+ 'country' => [
+ 'identification_code' => $invoice->customer?->country_code ?? 'NO',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the payment means section for the given invoice.
+ *
+ * @param Invoice $invoice invoice used to populate the payment identifier (`payment_id`)
+ *
+ * @return array An associative array containing:
+ * - `payment_means_code`: code representing the payment method (credit transfer).
+ * - `payment_id`: invoice number used as the payment identifier.
+ * - `payee_financial_account`: account information with keys:
+ * - `id`: supplier bank account number,
+ * - `name`: supplier company name,
+ * - `financial_institution_branch`: bank branch info with `id` (BIC) and `name` (bank name).
+ */
+ protected function buildPaymentMeans(Invoice $invoice): array
+ {
+ return [
+ 'payment_means_code' => '30', // Credit transfer
+ 'payment_id' => $invoice->invoice_number,
+ 'payee_financial_account' => [
+ 'id' => config('invoices.peppol.supplier.bank_account', ''),
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ 'financial_institution_branch' => [
+ 'id' => config('invoices.peppol.supplier.bank_bic', ''),
+ 'name' => config('invoices.peppol.supplier.bank_name', ''),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs payment terms with a Norwegian note stating the number of days until the invoice is due.
+ *
+ * @param Invoice $invoice the invoice used to calculate days until due
+ *
+ * @return array an array containing a 'note' key with value like "Forfall X dager" where X is the number of days until due
+ */
+ protected function buildPaymentTerms(Invoice $invoice): array
+ {
+ $daysUntilDue = $invoice->invoiced_at->diffInDays($invoice->invoice_due_at);
+
+ return [
+ 'note' => sprintf('Forfall %d dager', $daysUntilDue), // Due in X days (Norwegian)
+ ];
+ }
+
+ /**
+ * Constructs the invoice tax total including per-rate subtotals.
+ *
+ * Builds the overall tax amount and an array of tax subtotals grouped by tax rate;
+ * each subtotal contains the taxable amount, tax amount (both formatted with the provided currency),
+ * and a tax category (id, percent and tax scheme).
+ *
+ * @param Invoice $invoice the invoice to compute taxes for
+ * @param string $currencyCode ISO 4217 currency code used for all monetary values
+ *
+ * @return array an array with keys:
+ * - `tax_amount`: array with `value` and `currency_id` for the total tax,
+ * - `tax_subtotal`: list of per-rate subtotals each containing `taxable_amount`,
+ * `tax_amount`, and `tax_category`
+ */
+ protected function buildTaxTotal(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ // Group items by tax rate
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getTaxRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'amount' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->subtotal * ($rate / 100);
+ }
+
+ $taxSubtotals = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxSubtotals[] = [
+ 'taxable_amount' => [
+ 'value' => number_format($group['base'], 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_amount' => [
+ 'value' => number_format($group['amount'], 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_category' => [
+ 'id' => $rate > 0 ? 'S' : 'Z',
+ 'percent' => $rate,
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ ];
+ }
+
+ return [
+ 'tax_amount' => [
+ 'value' => number_format($taxAmount, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_subtotal' => $taxSubtotals,
+ ];
+ }
+
+ /**
+ * Construct the invoice monetary totals section for the EHF payload.
+ *
+ * @param Invoice $invoice invoice model containing subtotal and total amounts
+ * @param string $currencyCode ISO 4217 currency code used for all monetary values
+ *
+ * @return array Associative array with these keys:
+ * - `line_extension_amount`: array with `value` (amount before taxes as a string with two decimals) and `currency_id`.
+ * - `tax_exclusive_amount`: array with `value` (amount excluding tax as a string with two decimals) and `currency_id`.
+ * - `tax_inclusive_amount`: array with `value` (amount including tax as a string with two decimals) and `currency_id`.
+ * - `payable_amount`: array with `value` (final payable amount as a string with two decimals) and `currency_id`.
+ */
+ protected function buildMonetaryTotal(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'line_extension_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_exclusive_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_inclusive_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'payable_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ ];
+ }
+
+ /**
+ * Create an array of invoice line entries for the EHF Peppol document.
+ *
+ * Each entry corresponds to an invoice item and includes identifiers, quantity,
+ * line extension amount, item details (description, name, seller item id, tax
+ * classification) and price information.
+ *
+ * @param Invoice $invoice invoice model containing `invoiceItems` to convert into lines
+ * @param string $currencyCode ISO 4217 currency code applied to monetary fields
+ *
+ * @return array> array of invoice line structures ready for transformation
+ */
+ protected function buildInvoiceLines(Invoice $invoice, string $currencyCode): array
+ {
+ return $invoice->invoiceItems->map(function ($item, $index) use ($currencyCode) {
+ $taxRate = $this->getTaxRate($item);
+
+ return [
+ 'id' => $index + 1,
+ 'invoiced_quantity' => [
+ 'value' => $item->quantity,
+ 'unit_code' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ ],
+ 'line_extension_amount' => [
+ 'value' => number_format($item->subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'item' => [
+ 'description' => $item->description ?? '',
+ 'name' => $item->item_name,
+ 'sellers_item_identification' => [
+ 'id' => $item->item_code ?? '',
+ ],
+ 'classified_tax_category' => [
+ 'id' => $taxRate > 0 ? 'S' : 'Z',
+ 'percent' => $taxRate,
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ ],
+ 'price' => [
+ 'price_amount' => [
+ 'value' => number_format($item->price, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'base_quantity' => [
+ 'value' => 1,
+ 'unit_code' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ ],
+ ],
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Validate invoice fields required by the EHF (Norwegian Peppol) format.
+ *
+ * Performs format-specific checks and returns any validation error messages.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return string[] an array of validation error messages; empty if the invoice meets EHF requirements
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // EHF requires Norwegian organization number
+ if ( ! config('invoices.peppol.supplier.organization_number')) {
+ $errors[] = 'Supplier organization number (ORGNR) is required for EHF format';
+ }
+
+ // Customer must have organization number or Peppol ID
+ if ( ! $invoice->customer?->organization_number && ! $invoice->customer?->peppol_id) {
+ $errors[] = 'Customer organization number or Peppol ID is required for EHF format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Selects the buyer reference used for EHF routing.
+ *
+ * @param Invoice $invoice invoice to extract the buyer reference from
+ *
+ * @return string the buyer reference from the invoice's customer if present, otherwise the invoice reference, or an empty string if neither is set
+ */
+ protected function getBuyerReference(Invoice $invoice): string
+ {
+ // EHF requires buyer reference for routing
+ return $invoice->customer?->reference ?? $invoice->reference ?? '';
+ }
+
+ /**
+ * Return the tax rate percentage for an invoice item.
+ *
+ * @param mixed $item invoice item (object or array) that may contain a `tax_rate` value
+ *
+ * @return float The tax rate as a percentage (e.g., 25.0). Defaults to 25.0 when not present.
+ */
+ protected function getTaxRate($item): float
+ {
+ return $item->tax_rate ?? 25.0; // Standard Norwegian VAT rate
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/FacturXHandler.php b/Modules/Invoices/Peppol/FormatHandlers/FacturXHandler.php
new file mode 100644
index 000000000..17f240d09
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/FacturXHandler.php
@@ -0,0 +1,361 @@
+buildCiiStructure($invoice);
+ }
+
+ /**
+ * Generate the Factur‑X (CII) representation for an invoice and, in a full implementation, embed it into a PDF/A‑3 container.
+ *
+ * @param Invoice $invoice the invoice to convert into Factur‑X (CII) format
+ * @param array $options optional generation options that may alter output formatting or embedding behavior
+ *
+ * @return string The generated output. Currently returns a pretty-printed JSON string of the internal CII structure (placeholder for the eventual PDF/A‑3 with embedded XML).
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Constructs the Cross Industry Invoice (CII) array representation for a Factur‑X 1.0 invoice.
+ *
+ * @param Invoice $invoice the invoice to convert into the CII structure
+ *
+ * @return array an associative array representing the CII payload with the root key `rsm:CrossIndustryInvoice`
+ */
+ protected function buildCiiStructure(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+
+ return [
+ 'rsm:CrossIndustryInvoice' => [
+ '@xmlns:rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
+ '@xmlns:ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
+ '@xmlns:udt' => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
+ 'rsm:ExchangedDocumentContext' => $this->buildDocumentContext(),
+ 'rsm:ExchangedDocument' => $this->buildExchangedDocument($invoice),
+ 'rsm:SupplyChainTradeTransaction' => $this->buildSupplyChainTradeTransaction($invoice, $currencyCode),
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the document context parameters required by the Factur‑X (CII) envelope.
+ *
+ * @return array array containing `ram:GuidelineSpecifiedDocumentContextParameter` with `ram:ID` set to the Factur‑X guideline URN
+ */
+ protected function buildDocumentContext(): array
+ {
+ return [
+ 'ram:GuidelineSpecifiedDocumentContextParameter' => [
+ 'ram:ID' => 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:basic',
+ ],
+ ];
+ }
+
+ /**
+ * Builds the ExchangedDocument section of the CII (Factur‑X) payload for the given invoice.
+ *
+ * @param Invoice $invoice the invoice whose identifying and date information will populate the section
+ *
+ * @return array associative array with keys:
+ * - `ram:ID`: invoice number,
+ * - `ram:TypeCode`: document type code ('380' for commercial invoice),
+ * - `ram:IssueDateTime`: contains `udt:DateTimeString` with `@format` '102' and the invoice date formatted as `Ymd`
+ */
+ protected function buildExchangedDocument(Invoice $invoice): array
+ {
+ return [
+ 'ram:ID' => $invoice->invoice_number,
+ 'ram:TypeCode' => '380', // Commercial invoice
+ 'ram:IssueDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the Supply Chain Trade Transaction section of the CII payload.
+ *
+ * @param Invoice $invoice the invoice to extract trade data from
+ * @param string $currencyCode ISO 4217 currency code used for monetary elements
+ *
+ * @return array array containing keys for 'ram:ApplicableHeaderTradeAgreement', 'ram:ApplicableHeaderTradeDelivery', and 'ram:ApplicableHeaderTradeSettlement' representing their respective CII subsections
+ */
+ protected function buildSupplyChainTradeTransaction(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'ram:ApplicableHeaderTradeAgreement' => $this->buildHeaderTradeAgreement($invoice),
+ 'ram:ApplicableHeaderTradeDelivery' => $this->buildHeaderTradeDelivery($invoice),
+ 'ram:ApplicableHeaderTradeSettlement' => $this->buildHeaderTradeSettlement($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Constructs seller and buyer party data for the CII header trade agreement.
+ *
+ * Seller values are sourced from configuration; buyer values are populated from the
+ * invoice's customer (company/name and postal address).
+ *
+ * @param Invoice $invoice the invoice whose customer and address data populate the buyer party
+ *
+ * @return array an array containing `ram:SellerTradeParty` and `ram:BuyerTradeParty` structures suitable for the CII header trade agreement
+ */
+ protected function buildHeaderTradeAgreement(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'ram:SellerTradeParty' => [
+ 'ram:Name' => config('invoices.peppol.supplier.company_name'),
+ 'ram:SpecifiedTaxRegistration' => [
+ 'ram:ID' => [
+ '@schemeID' => 'VA',
+ '#' => config('invoices.peppol.supplier.vat_number'),
+ ],
+ ],
+ 'ram:PostalTradeAddress' => [
+ 'ram:PostcodeCode' => config('invoices.peppol.supplier.postal_zone'),
+ 'ram:LineOne' => config('invoices.peppol.supplier.street_name'),
+ 'ram:CityName' => config('invoices.peppol.supplier.city_name'),
+ 'ram:CountryID' => config('invoices.peppol.supplier.country_code'),
+ ],
+ ],
+ 'ram:BuyerTradeParty' => [
+ 'ram:Name' => $customer->company_name ?? $customer->customer_name,
+ 'ram:PostalTradeAddress' => [
+ 'ram:PostcodeCode' => $customer->zip ?? '',
+ 'ram:LineOne' => $customer->street1 ?? '',
+ 'ram:CityName' => $customer->city ?? '',
+ 'ram:CountryID' => $customer->country_code ?? '',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the header trade delivery section containing the actual delivery event date.
+ *
+ * @param Invoice $invoice invoice model whose invoiced_at date is used for the delivery occurrence
+ *
+ * @return array array representing `ram:ActualDeliverySupplyChainEvent` with `ram:OccurrenceDateTime` containing a `udt:DateTimeString` using format '102' and the invoice date formatted as `Ymd`
+ */
+ protected function buildHeaderTradeDelivery(Invoice $invoice): array
+ {
+ return [
+ 'ram:ActualDeliverySupplyChainEvent' => [
+ 'ram:OccurrenceDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Construct the header trade settlement block for the invoice's CII payload, including currency, payment means, tax totals, payment terms, monetary summation, and line items.
+ *
+ * @param string $currencyCode ISO 4217 currency code used for monetary amounts
+ *
+ * @return array the `ram:ApplicableHeaderTradeSettlement` structure ready for inclusion in the CII document
+ */
+ protected function buildHeaderTradeSettlement(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'ram:InvoiceCurrencyCode' => $currencyCode,
+ 'ram:SpecifiedTradeSettlementPaymentMeans' => [
+ 'ram:TypeCode' => '30', // Credit transfer
+ ],
+ 'ram:ApplicableTradeTax' => $this->buildTaxTotals($invoice, $currencyCode),
+ 'ram:SpecifiedTradePaymentTerms' => [
+ 'ram:DueDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoice_due_at->format('Ymd'),
+ ],
+ ],
+ ],
+ 'ram:SpecifiedTradeSettlementHeaderMonetarySummation' => [
+ 'ram:LineTotalAmount' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'ram:TaxBasisTotalAmount' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'ram:TaxTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($invoice->invoice_total - $invoice->invoice_subtotal, 2, '.', ''),
+ ],
+ 'ram:GrandTotalAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'ram:DuePayableAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ 'ram:IncludedSupplyChainTradeLineItem' => $this->buildLineItems($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Aggregate invoice item taxes by tax rate and format them for the CII tax totals section.
+ *
+ * Each returned entry represents a tax group for a specific rate and includes the calculated tax amount,
+ * the taxable basis, the VAT category code, and the applicable rate percent. Monetary and percent values
+ * are formatted as strings with two decimal places and a dot decimal separator.
+ *
+ * @param Invoice $invoice the invoice whose items will be grouped by tax rate
+ * @param string $currencyCode ISO 4217 currency code used for the tax totals (included for context)
+ *
+ * @return array> array of tax entries suitable for embedding under `ram:ApplicableTradeTax`
+ */
+ protected function buildTaxTotals(Invoice $invoice, string $currencyCode): array
+ {
+ // Group items by tax rate
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getTaxRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'amount' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->subtotal * ($rate / 100);
+ }
+
+ $taxes = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxes[] = [
+ 'ram:CalculatedAmount' => number_format($group['amount'], 2, '.', ''),
+ 'ram:TypeCode' => 'VAT',
+ 'ram:BasisAmount' => number_format($group['base'], 2, '.', ''),
+ 'ram:CategoryCode' => $rate > 0 ? 'S' : 'Z',
+ 'ram:RateApplicablePercent' => number_format($rate, 2, '.', ''),
+ ];
+ }
+
+ return $taxes;
+ }
+
+ /**
+ * Constructs the CII-formatted line items for the given invoice.
+ *
+ * Each entry contains product details, net price, billed quantity (with unit code),
+ * applicable tax information, and the line total amount formatted for Factur‑X CII.
+ *
+ * @param Invoice $invoice the invoice containing items to convert
+ * @param string $currencyCode ISO 4217 currency code used for monetary formatting
+ *
+ * @return array> array of associative arrays representing CII line-item entries
+ */
+ protected function buildLineItems(Invoice $invoice, string $currencyCode): array
+ {
+ return $invoice->invoiceItems->map(function ($item, $index) {
+ $taxRate = $this->getTaxRate($item);
+
+ return [
+ 'ram:AssociatedDocumentLineDocument' => [
+ 'ram:LineID' => (string) ($index + 1),
+ ],
+ 'ram:SpecifiedTradeProduct' => [
+ 'ram:Name' => $item->item_name,
+ 'ram:Description' => $item->description ?? '',
+ ],
+ 'ram:SpecifiedLineTradeAgreement' => [
+ 'ram:NetPriceProductTradePrice' => [
+ 'ram:ChargeAmount' => number_format($item->price, 2, '.', ''),
+ ],
+ ],
+ 'ram:SpecifiedLineTradeDelivery' => [
+ 'ram:BilledQuantity' => [
+ '@unitCode' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ '#' => number_format($item->quantity, 2, '.', ''),
+ ],
+ ],
+ 'ram:SpecifiedLineTradeSettlement' => [
+ 'ram:ApplicableTradeTax' => [
+ 'ram:TypeCode' => 'VAT',
+ 'ram:CategoryCode' => $taxRate > 0 ? 'S' : 'Z',
+ 'ram:RateApplicablePercent' => number_format($taxRate, 2, '.', ''),
+ ],
+ 'ram:SpecifiedTradeSettlementLineMonetarySummation' => [
+ 'ram:LineTotalAmount' => number_format($item->subtotal, 2, '.', ''),
+ ],
+ ],
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Validate format-specific requirements for Factur-X invoices.
+ *
+ * Ensures the invoice meets constraints required by the Factur-X (CII) format.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return string[] an array of validation error messages; empty if there are no format-specific errors
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // Factur-X requires VAT number
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier VAT number is required for Factur-X format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Retrieve the tax rate percentage for an invoice item.
+ *
+ * @param mixed $item invoice item (object or array) that may provide a `tax_rate` property or key
+ *
+ * @return float The tax rate percentage for the item; defaults to 20.0 if not present.
+ */
+ protected function getTaxRate(mixed $item): float
+ {
+ return $item->tax_rate ?? 20.0; // Default French VAT rate
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/FacturaeHandler.php b/Modules/Invoices/Peppol/FormatHandlers/FacturaeHandler.php
new file mode 100644
index 000000000..55380d534
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/FacturaeHandler.php
@@ -0,0 +1,463 @@
+getCurrencyCode($invoice);
+
+ return [
+ 'FileHeader' => $this->buildFileHeader($invoice),
+ 'Parties' => $this->buildParties($invoice),
+ 'Invoices' => [
+ 'Invoice' => $this->buildInvoice($invoice, $currencyCode),
+ ],
+ ];
+ }
+
+ /**
+ * Produce a Facturae 3.2 XML representation for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to convert
+ * @param array $options optional transform options
+ *
+ * @return string A string containing the Facturae 3.2 XML payload for the invoice. Current implementation returns a pretty-printed JSON representation of the prepared payload as a placeholder.
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Create the Facturae 3.2 file header containing schema and batch metadata.
+ *
+ * @param Invoice $invoice invoice used to populate the batch identifier and total amount
+ *
+ * @return array array with keys `SchemaVersion`, `Modality`, `InvoiceIssuerType`, and `Batch` (where `Batch` contains `BatchIdentifier`, `InvoicesCount`, and `TotalInvoicesAmount` with `TotalAmount`)
+ */
+ protected function buildFileHeader(Invoice $invoice): array
+ {
+ return [
+ 'SchemaVersion' => '3.2',
+ 'Modality' => 'I', // Individual invoice
+ 'InvoiceIssuerType' => 'EM', // Issuer
+ 'Batch' => [
+ 'BatchIdentifier' => $invoice->invoice_number,
+ 'InvoicesCount' => '1',
+ 'TotalInvoicesAmount' => [
+ 'TotalAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Assembles the seller and buyer party structures for the given invoice.
+ *
+ * @param Invoice $invoice invoice to extract seller and buyer information from
+ *
+ * @return array array with 'SellerParty' and 'BuyerParty' keys containing their respective structured data
+ */
+ protected function buildParties(Invoice $invoice): array
+ {
+ return [
+ 'SellerParty' => $this->buildSellerParty($invoice),
+ 'BuyerParty' => $this->buildBuyerParty($invoice),
+ ];
+ }
+
+ /**
+ * Create the seller (supplier) party structure for the Facturae 3.2 payload.
+ *
+ * The structure is populated from supplier configuration and contains the
+ * TaxIdentification, PartyIdentification, AdministrativeCentres, and LegalEntity
+ * sections required by the Facturae schema.
+ *
+ * @param Invoice $invoice invoice model (unused for most fields; provided for context)
+ *
+ * @return array Seller party data matching Facturae 3.2 structure.
+ */
+ protected function buildSellerParty(Invoice $invoice): array
+ {
+ return [
+ 'TaxIdentification' => [
+ 'PersonTypeCode' => 'J', // Legal entity
+ 'ResidenceTypeCode' => 'R', // Resident
+ 'TaxIdentificationNumber' => config('invoices.peppol.supplier.vat_number'),
+ ],
+ 'PartyIdentification' => config('invoices.peppol.supplier.vat_number'),
+ 'AdministrativeCentres' => [
+ 'AdministrativeCentre' => [
+ 'CentreCode' => '1',
+ 'RoleTypeCode' => '01', // Fiscal address
+ 'Name' => config('invoices.peppol.supplier.company_name'),
+ 'AddressInSpain' => [
+ 'Address' => config('invoices.peppol.supplier.street_name'),
+ 'PostCode' => config('invoices.peppol.supplier.postal_zone'),
+ 'Town' => config('invoices.peppol.supplier.city_name'),
+ 'Province' => config('invoices.peppol.supplier.province', 'Madrid'),
+ 'CountryCode' => config('invoices.peppol.supplier.country_code', 'ESP'),
+ ],
+ ],
+ ],
+ 'LegalEntity' => [
+ 'CorporateName' => config('invoices.peppol.supplier.company_name'),
+ 'AddressInSpain' => [
+ 'Address' => config('invoices.peppol.supplier.street_name'),
+ 'PostCode' => config('invoices.peppol.supplier.postal_zone'),
+ 'Town' => config('invoices.peppol.supplier.city_name'),
+ 'Province' => config('invoices.peppol.supplier.province', 'Madrid'),
+ 'CountryCode' => config('invoices.peppol.supplier.country_code', 'ESP'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the buyer party structure for the Facturae payload using the invoice's customer data.
+ *
+ * Populates tax identification, administrative centre, and legal entity sections. Address fields are
+ * provided as `AddressInSpain` for Spanish customers or `OverseasAddress` for foreign customers.
+ *
+ * @param Invoice $invoice the invoice whose customer information is used to build the buyer party
+ *
+ * @return array Array with keys:
+ * - `TaxIdentification`: contains `PersonTypeCode`, `ResidenceTypeCode`, and `TaxIdentificationNumber`.
+ * - `AdministrativeCentres`: contains `AdministrativeCentre` with `CentreCode`, `RoleTypeCode`, `Name` and an address block (`AddressInSpain` or `OverseasAddress`).
+ * - `LegalEntity`: contains `CorporateName` and the same address block used in `AdministrativeCentres`.
+ */
+ protected function buildBuyerParty(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+ $isSpanish = mb_strtoupper($customer->country_code ?? '') === 'ES';
+
+ $address = $isSpanish ? [
+ 'AddressInSpain' => [
+ 'Address' => $customer->street1 ?? '',
+ 'PostCode' => $customer->zip ?? '',
+ 'Town' => $customer->city ?? '',
+ 'Province' => $customer->province ?? 'Madrid',
+ 'CountryCode' => 'ESP',
+ ],
+ ] : [
+ 'OverseasAddress' => [
+ 'Address' => $customer->street1 ?? '',
+ 'PostCodeAndTown' => ($customer->zip ?? '') . ' ' . ($customer->city ?? ''),
+ 'Province' => $customer->province ?? '',
+ 'CountryCode' => $customer->country_code ?? '',
+ ],
+ ];
+
+ return [
+ 'TaxIdentification' => [
+ 'PersonTypeCode' => 'J', // Legal entity
+ 'ResidenceTypeCode' => $isSpanish ? 'R' : 'U', // Resident or foreign
+ 'TaxIdentificationNumber' => $customer->peppol_id ?? $customer->tax_code ?? '',
+ ],
+ 'AdministrativeCentres' => [
+ 'AdministrativeCentre' => array_merge(
+ [
+ 'CentreCode' => '1',
+ 'RoleTypeCode' => '01', // Fiscal address
+ 'Name' => $customer->company_name ?? $customer->customer_name,
+ ],
+ $address
+ ),
+ ],
+ 'LegalEntity' => array_merge(
+ [
+ 'CorporateName' => $customer->company_name ?? $customer->customer_name,
+ ],
+ $address
+ ),
+ ];
+ }
+
+ /**
+ * Assembles the invoice sections required for the Facturae 3.2 invoice payload.
+ *
+ * Returns an associative array containing the invoice parts used in the payload:
+ * `InvoiceHeader`, `InvoiceIssueData`, `TaxesOutputs`, `InvoiceTotals`, `Items`, and `PaymentDetails`.
+ *
+ * @return array associative array keyed by Facturae element names with their corresponding data
+ */
+ protected function buildInvoice(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'InvoiceHeader' => $this->buildInvoiceHeader($invoice, $currencyCode),
+ 'InvoiceIssueData' => $this->buildInvoiceIssueData($invoice),
+ 'TaxesOutputs' => $this->buildTaxesOutputs($invoice, $currencyCode),
+ 'InvoiceTotals' => $this->buildInvoiceTotals($invoice, $currencyCode),
+ 'Items' => $this->buildItems($invoice, $currencyCode),
+ 'PaymentDetails' => $this->buildPaymentDetails($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Build invoice header.
+ *
+ * @param Invoice $invoice
+ * @param string $currencyCode
+ *
+ * @return array
+ */
+ protected function buildInvoiceHeader(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'InvoiceNumber' => $invoice->invoice_number,
+ 'InvoiceSeriesCode' => $this->extractSeriesCode($invoice->invoice_number),
+ 'InvoiceDocumentType' => 'FC', // Complete invoice
+ 'InvoiceClass' => 'OO', // Original
+ ];
+ }
+
+ /**
+ * Builds the invoice issuance metadata required by the Facturae payload.
+ *
+ * Returns an associative array containing the issue date, invoice and tax currency codes,
+ * and the language code used for the invoice.
+ *
+ * @param Invoice $invoice the invoice model from which dates and currency are derived
+ *
+ * @return array An array with keys:
+ * - `IssueDate`: the invoice issue date in Y-m-d format,
+ * - `InvoiceCurrencyCode`: the invoice currency code,
+ * - `TaxCurrencyCode`: the tax currency code,
+ * - `LanguageName`: the language code (e.g., 'es').
+ */
+ protected function buildInvoiceIssueData(Invoice $invoice): array
+ {
+ return [
+ 'IssueDate' => $invoice->invoiced_at->format('Y-m-d'),
+ 'InvoiceCurrencyCode' => $this->getCurrencyCode($invoice),
+ 'TaxCurrencyCode' => $this->getCurrencyCode($invoice),
+ 'LanguageName' => 'es', // Spanish
+ ];
+ }
+
+ /**
+ * Assemble tax output entries grouped by tax rate for the Facturae payload.
+ *
+ * @param Invoice $invoice the invoice whose items will be grouped by tax rate to produce tax entries
+ * @param string $currencyCode the currency code used when formatting monetary amounts
+ *
+ * @return array An array with a `Tax` key containing a list of tax group entries. Each entry includes a `Tax` structure with `TaxTypeCode`, `TaxRate`, `TaxableBase['TotalAmount']`, and `TaxAmount['TotalAmount']` formatted as strings with two decimal places.
+ */
+ protected function buildTaxesOutputs(Invoice $invoice, string $currencyCode): array
+ {
+ // Group items by tax rate
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getTaxRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'amount' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->subtotal * ($rate / 100);
+ }
+
+ $taxes = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxes[] = [
+ 'Tax' => [
+ 'TaxTypeCode' => '01', // IVA (VAT)
+ 'TaxRate' => number_format($rate, 2, '.', ''),
+ 'TaxableBase' => [
+ 'TotalAmount' => number_format($group['base'], 2, '.', ''),
+ ],
+ 'TaxAmount' => [
+ 'TotalAmount' => number_format($group['amount'], 2, '.', ''),
+ ],
+ ],
+ ];
+ }
+
+ return ['Tax' => $taxes];
+ }
+
+ /**
+ * Assembles invoice total amounts formatted for the Facturae payload.
+ *
+ * @param Invoice $invoice the invoice model providing subtotal and total amounts
+ * @param string $currencyCode the invoice currency code (used for context; amounts are formatted to two decimals)
+ *
+ * @return array An associative array with the following keys:
+ * - `TotalGrossAmount`: subtotal formatted with 2 decimals.
+ * - `TotalGrossAmountBeforeTaxes`: subtotal formatted with 2 decimals.
+ * - `TotalTaxOutputs`: tax amount (invoice total minus subtotal) formatted with 2 decimals.
+ * - `TotalTaxesWithheld`: taxes withheld, represented as `'0.00'`.
+ * - `InvoiceTotal`: invoice total formatted with 2 decimals.
+ * - `TotalOutstandingAmount`: outstanding amount formatted with 2 decimals.
+ * - `TotalExecutableAmount`: executable amount formatted with 2 decimals.
+ */
+ protected function buildInvoiceTotals(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ return [
+ 'TotalGrossAmount' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'TotalGrossAmountBeforeTaxes' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'TotalTaxOutputs' => number_format($taxAmount, 2, '.', ''),
+ 'TotalTaxesWithheld' => '0.00',
+ 'InvoiceTotal' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'TotalOutstandingAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'TotalExecutableAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ ];
+ }
+
+ /**
+ * Map invoice items to Facturae 3.2 `InvoiceLine` structures.
+ *
+ * @param Invoice $invoice the invoice whose items will be converted into line entries
+ * @param string $currencyCode currency ISO code used for monetary formatting
+ *
+ * @return array an array with the key `InvoiceLine` containing a list of line entries formatted for Facturae (each entry includes quantities, unit price, totals and tax breakdowns)
+ */
+ protected function buildItems(Invoice $invoice, string $currencyCode): array
+ {
+ $items = $invoice->invoiceItems->map(function ($item, $index) {
+ $taxRate = $this->getTaxRate($item);
+ $taxAmount = $item->subtotal * ($taxRate / 100);
+
+ return [
+ 'InvoiceLine' => [
+ 'ItemDescription' => $item->item_name,
+ 'Quantity' => number_format($item->quantity, 2, '.', ''),
+ 'UnitOfMeasure' => '01', // Units
+ 'UnitPriceWithoutTax' => number_format($item->price, 2, '.', ''),
+ 'TotalCost' => number_format($item->subtotal, 2, '.', ''),
+ 'GrossAmount' => number_format($item->subtotal, 2, '.', ''),
+ 'TaxesOutputs' => [
+ 'Tax' => [
+ 'TaxTypeCode' => '01', // IVA
+ 'TaxRate' => number_format($taxRate, 2, '.', ''),
+ 'TaxableBase' => [
+ 'TotalAmount' => number_format($item->subtotal, 2, '.', ''),
+ ],
+ 'TaxAmount' => [
+ 'TotalAmount' => number_format($taxAmount, 2, '.', ''),
+ ],
+ ],
+ ],
+ ],
+ ];
+ })->toArray();
+
+ return ['InvoiceLine' => $items];
+ }
+
+ /**
+ * Constructs the payment details structure containing a single installment.
+ *
+ * @param Invoice $invoice the invoice used to populate the installment due date and amount
+ * @param string $currencyCode the currency code (ISO 4217) associated with the installment amount
+ *
+ * @return array An array with an 'Installment' entry containing:
+ * - 'InstallmentDueDate' (string, Y-m-d),
+ * - 'InstallmentAmount' (string, formatted with two decimals),
+ * - 'PaymentMeans' (string, payment method code, e.g. '04' for transfer).
+ */
+ protected function buildPaymentDetails(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'Installment' => [
+ 'InstallmentDueDate' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'InstallmentAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'PaymentMeans' => '04', // Transfer
+ ],
+ ];
+ }
+
+ /**
+ * Validate Facturae-specific requirements for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return string[] an array of validation error messages; empty if no errors
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // Facturae requires Spanish tax identification
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier tax identification (NIF/CIF) is required for Facturae format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Extracts the leading alphabetic series code from an invoice number.
+ *
+ * @param string $invoiceNumber invoice identifier that may start with a letter-based series
+ *
+ * @return string the extracted series code (leading uppercase letters), or 'A' if none are present
+ */
+ protected function extractSeriesCode(string $invoiceNumber): string
+ {
+ // Extract letters from invoice number (e.g., "INV" from "INV-2024-001")
+ if (preg_match('/^([A-Z]+)/', $invoiceNumber, $matches)) {
+ return $matches[1];
+ }
+
+ return 'A'; // Default series
+ }
+
+ /**
+ * Retrieve the tax rate for an invoice item.
+ *
+ * @param mixed $item invoice item expected to contain a `tax_rate` property or key
+ *
+ * @return float The tax rate to apply; `21.0` if the item does not specify one.
+ */
+ protected function getTaxRate($item): float
+ {
+ // Default Spanish VAT rate is 21%
+ return $item->tax_rate ?? 21.0;
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/FatturaPaHandler.php b/Modules/Invoices/Peppol/FormatHandlers/FatturaPaHandler.php
new file mode 100644
index 000000000..c98d2ea0b
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/FatturaPaHandler.php
@@ -0,0 +1,378 @@
+customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+
+ return [
+ 'FatturaElettronicaHeader' => $this->buildHeader($invoice),
+ 'FatturaElettronicaBody' => $this->buildBody($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Generate the FatturaPA-compliant XML representation for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to convert
+ * @param array $options optional transformation options
+ *
+ * @return string the FatturaPA XML as a string; currently returns a JSON-formatted string of the transformed data as a placeholder
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Build the FatturaPA electronic invoice header for the given invoice.
+ *
+ * @param Invoice $invoice the invoice used to populate header sections
+ *
+ * @return array array with 'DatiTrasmissione', 'CedentePrestatore' and 'CessionarioCommittente' entries
+ */
+ protected function buildHeader(Invoice $invoice): array
+ {
+ return [
+ 'DatiTrasmissione' => $this->buildTransmissionData($invoice),
+ 'CedentePrestatore' => $this->buildSupplierData($invoice),
+ 'CessionarioCommittente' => $this->buildCustomerData($invoice),
+ ];
+ }
+
+ /**
+ * Constructs the FatturaPA DatiTrasmissione (transmission data) for the given invoice.
+ *
+ * @param Invoice $invoice the invoice used to populate transmission fields
+ *
+ * @return array array containing `IdTrasmittente` (with `IdPaese` and `IdCodice`), `ProgressivoInvio`, `FormatoTrasmissione`, and `CodiceDestinatario`
+ */
+ protected function buildTransmissionData(Invoice $invoice): array
+ {
+ return [
+ 'IdTrasmittente' => [
+ 'IdPaese' => config('invoices.peppol.supplier.country_code', 'IT'),
+ 'IdCodice' => $this->extractIdCodice(config('invoices.peppol.supplier.vat_number')),
+ ],
+ 'ProgressivoInvio' => $invoice->invoice_number,
+ 'FormatoTrasmissione' => 'FPR12', // FatturaPA 1.2 format
+ 'CodiceDestinatario' => $invoice->customer?->peppol_id ?? '0000000',
+ ];
+ }
+
+ /**
+ * Constructs the supplier (CedentePrestatore) data structure required by FatturaPA header.
+ *
+ * The returned array contains the supplier fiscal and registry information under `DatiAnagrafici`
+ * and the supplier address under `Sede`.
+ *
+ * @param Invoice $invoice invoice instance (unused directly; kept for interface consistency)
+ *
+ * @return array Array with keys:
+ * - `DatiAnagrafici`: [
+ * `IdFiscaleIVA` => ['IdPaese' => string, 'IdCodice' => string],
+ * `Anagrafica` => ['Denominazione' => string|null],
+ * `RegimeFiscale` => string
+ * ]
+ * - `Sede`: [
+ * `Indirizzo` => string|null,
+ * `CAP` => string|null,
+ * `Comune` => string|null,
+ * `Nazione` => string
+ * ]
+ */
+ protected function buildSupplierData(Invoice $invoice): array
+ {
+ return [
+ 'DatiAnagrafici' => [
+ 'IdFiscaleIVA' => [
+ 'IdPaese' => config('invoices.peppol.supplier.country_code', 'IT'),
+ 'IdCodice' => $this->extractIdCodice(config('invoices.peppol.supplier.vat_number')),
+ ],
+ 'Anagrafica' => [
+ 'Denominazione' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'RegimeFiscale' => 'RF01', // Ordinary regime
+ ],
+ 'Sede' => [
+ 'Indirizzo' => config('invoices.peppol.supplier.street_name'),
+ 'CAP' => config('invoices.peppol.supplier.postal_zone'),
+ 'Comune' => config('invoices.peppol.supplier.city_name'),
+ 'Nazione' => config('invoices.peppol.supplier.country_code', 'IT'),
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the customer data structure used in the FatturaPA header.
+ *
+ * @param Invoice $invoice invoice containing the customer information
+ *
+ * @return array Array with keys:
+ * - `DatiAnagrafici`: contains `CodiceFiscale` (customer tax code or empty string)
+ * and `Anagrafica` with `Denominazione` (company name or customer name).
+ * - `Sede`: contains address fields `Indirizzo`, `CAP`, `Comune`, and `Nazione`
+ * (country code, defaults to "IT" when absent).
+ */
+ protected function buildCustomerData(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'DatiAnagrafici' => [
+ 'CodiceFiscale' => $customer?->tax_code ?? '',
+ 'Anagrafica' => [
+ 'Denominazione' => $customer?->company_name ?? $customer?->customer_name,
+ ],
+ ],
+ 'Sede' => [
+ 'Indirizzo' => $customer?->street1 ?? '',
+ 'CAP' => $customer?->zip ?? '',
+ 'Comune' => $customer?->city ?? '',
+ 'Nazione' => $customer?->country_code ?? 'IT',
+ ],
+ ];
+ }
+
+ /**
+ * Assembles the body section of a FatturaPA 1.2 document.
+ *
+ * @param Invoice $invoice the invoice to convert into FatturaPA body data
+ * @param string $currencyCode ISO 4217 currency code to format monetary fields
+ *
+ * @return array associative array with keys:
+ * - `DatiGenerali`: general document data,
+ * - `DatiBeniServizi`: line items and tax summary,
+ * - `DatiPagamento`: payment terms and details
+ */
+ protected function buildBody(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'DatiGenerali' => $this->buildGeneralData($invoice),
+ 'DatiBeniServizi' => $this->buildItemsData($invoice, $currencyCode),
+ 'DatiPagamento' => $this->buildPaymentData($invoice),
+ ];
+ }
+
+ /**
+ * Builds the 'DatiGeneraliDocumento' section for a FatturaPA invoice.
+ *
+ * @param Invoice $invoice the invoice to extract general document fields from
+ *
+ * @return array array with a single key 'DatiGeneraliDocumento' containing:
+ * - 'TipoDocumento' (document type code),
+ * - 'Divisa' (currency code),
+ * - 'Data' (invoice date in 'Y-m-d' format),
+ * - 'Numero' (invoice number)
+ */
+ protected function buildGeneralData(Invoice $invoice): array
+ {
+ return [
+ 'DatiGeneraliDocumento' => [
+ 'TipoDocumento' => 'TD01', // Invoice
+ 'Divisa' => $this->getCurrencyCode($invoice),
+ 'Data' => $invoice->invoiced_at->format('Y-m-d'),
+ 'Numero' => $invoice->invoice_number,
+ ],
+ ];
+ }
+
+ /**
+ * Construct the items section with detailed line entries and the aggregated tax summary.
+ *
+ * Each line in `DettaglioLinee` contains numeric and descriptive fields for a single invoice item.
+ *
+ * @param Invoice $invoice the invoice whose items will be converted into line entries
+ * @param string $currencyCode ISO 4217 currency code used for the line amounts
+ *
+ * @return array An array with two keys:
+ * - `DettaglioLinee`: array of line entries, each containing:
+ * - `NumeroLinea`: line number (1-based).
+ * - `Descrizione`: item description.
+ * - `Quantita`: quantity formatted with two decimals.
+ * - `PrezzoUnitario`: unit price formatted with two decimals.
+ * - `PrezzoTotale`: total price for the line formatted with two decimals.
+ * - `AliquotaIVA`: VAT rate for the line formatted with two decimals.
+ * - `DatiRiepilogo`: tax summary grouped by VAT rate (base and tax amounts).
+ */
+ protected function buildItemsData(Invoice $invoice, string $currencyCode): array
+ {
+ $lines = $invoice->invoiceItems->map(function ($item, $index) {
+ return [
+ 'NumeroLinea' => $index + 1,
+ 'Descrizione' => $item->item_name,
+ 'Quantita' => number_format($item->quantity, 2, '.', ''),
+ 'PrezzoUnitario' => number_format($item->price, 2, '.', ''),
+ 'PrezzoTotale' => number_format($item->subtotal, 2, '.', ''),
+ 'AliquotaIVA' => number_format($this->getVatRate($item), 2, '.', ''),
+ ];
+ })->toArray();
+
+ return [
+ 'DettaglioLinee' => $lines,
+ 'DatiRiepilogo' => $this->buildTaxSummary($invoice),
+ ];
+ }
+
+ /**
+ * Builds the VAT summary grouped by VAT rate.
+ *
+ * Groups invoice items by their VAT rate and returns an array of summary entries.
+ * Each entry contains:
+ * - `AliquotaIVA`: VAT rate as a string formatted with two decimals.
+ * - `ImponibileImporto`: taxable base amount as a string formatted with two decimals.
+ * - `Imposta`: tax amount as a string formatted with two decimals.
+ *
+ * @param Invoice $invoice the invoice to summarize
+ *
+ * @return array> array of summary entries keyed numerically
+ */
+ protected function buildTaxSummary(Invoice $invoice): array
+ {
+ // Group items by tax rate
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getVatRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'tax' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['tax'] += $item->subtotal * ($rate / 100);
+ }
+
+ $summary = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $summary[] = [
+ 'AliquotaIVA' => number_format($rate, 2, '.', ''),
+ 'ImponibileImporto' => number_format($group['base'], 2, '.', ''),
+ 'Imposta' => number_format($group['tax'], 2, '.', ''),
+ ];
+ }
+
+ return $summary;
+ }
+
+ /**
+ * Assemble the payment section for the FatturaPA body.
+ *
+ * @param Invoice $invoice invoice used to obtain the payment due date and amount
+ *
+ * @return array payment data with keys:
+ * - 'CondizioniPagamento': payment condition code,
+ * - 'DettaglioPagamento': array of payment entries each containing 'ModalitaPagamento', 'DataScadenzaPagamento', and 'ImportoPagamento'
+ */
+ protected function buildPaymentData(Invoice $invoice): array
+ {
+ return [
+ 'CondizioniPagamento' => 'TP02', // Complete payment
+ 'DettaglioPagamento' => [
+ [
+ 'ModalitaPagamento' => 'MP05', // Bank transfer
+ 'DataScadenzaPagamento' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'ImportoPagamento' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Validate FatturaPA-specific requirements for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return string[] list of validation error messages; empty array if there are no validation errors
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // FatturaPA requires Italian VAT number or Codice Fiscale
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier VAT number (Partita IVA) is required for FatturaPA format';
+ }
+
+ // Customer must be in Italy or have Italian tax code for mandatory usage
+ if ($invoice->customer?->country_code === 'IT' && ! $invoice->customer?->tax_code) {
+ $errors[] = 'Customer tax code (Codice Fiscale) is required for Italian customers in FatturaPA format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Return the VAT identifier without the country prefix.
+ *
+ * @param string|null $vatNumber VAT number possibly prefixed with a country code (e.g., "IT12345678901").
+ *
+ * @return string the VAT identifier with any leading "IT" removed; returns an empty string when the input is null or empty
+ */
+ protected function extractIdCodice(?string $vatNumber): string
+ {
+ if ( ! $vatNumber) {
+ return '';
+ }
+
+ // Remove IT prefix if present
+ return preg_replace('/^IT/i', '', $vatNumber);
+ }
+
+ /**
+ * Obtain the VAT rate percentage for an invoice item.
+ *
+ * @param mixed $item invoice item expected to expose a numeric `tax_rate` property (percentage)
+ *
+ * @return float The VAT percentage to apply (uses the item's `tax_rate` if present, otherwise 22.0).
+ */
+ protected function getVatRate($item): float
+ {
+ // Assuming the item has a tax_rate or we use default Italian VAT rate
+ return $item->tax_rate ?? 22.0; // 22% is standard Italian VAT
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php b/Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php
new file mode 100644
index 000000000..0be9c5b61
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php
@@ -0,0 +1,204 @@
+>
+ */
+ 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,
+ ];
+
+ /**
+ * Create a handler for the specified format.
+ *
+ * @param PeppolDocumentFormat $format The format to create a handler for
+ *
+ * @return InvoiceFormatHandlerInterface
+ *
+ * @throws RuntimeException If no handler is available for the format
+ */
+ public static function create(PeppolDocumentFormat $format): InvoiceFormatHandlerInterface
+ {
+ $handlerClass = self::$handlers[$format->value] ?? null;
+
+ if ( ! $handlerClass) {
+ throw new RuntimeException("No handler available for format: {$format->value}");
+ }
+
+ 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);
+
+ return $handler;
+ } catch (Throwable $e) {
+ throw new RuntimeException("Failed to create handler for format: {$format->value}", 0, $e);
+ }
+ }
+
+ /**
+ * Create a handler for an invoice based on customer requirements.
+ *
+ * Automatically selects the appropriate format based on:
+ * 1. Customer's preferred format (if set)
+ * 2. Mandatory format for customer's country
+ * 3. Recommended format for customer's country
+ *
+ * @param Invoice $invoice The invoice to create a handler for
+ *
+ * @return InvoiceFormatHandlerInterface
+ *
+ * @throws RuntimeException If no suitable handler is found
+ */
+ public static function createForInvoice(Invoice $invoice): InvoiceFormatHandlerInterface
+ {
+ $customer = $invoice->customer;
+ $countryCode = $customer->country_code ?? null;
+
+ // 1. Try customer's preferred format
+ if ($customer->peppol_format) {
+ try {
+ $format = PeppolDocumentFormat::from($customer->peppol_format);
+
+ return self::create($format);
+ } 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)) {
+ 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
+ return self::create(PeppolDocumentFormat::PEPPOL_BIS_30);
+ }
+
+ /**
+ * Register a custom handler for a format.
+ *
+ * @param PeppolDocumentFormat $format The format
+ * @param class-string $handlerClass The handler class
+ *
+ * @return void
+ */
+ public static function register(PeppolDocumentFormat $format, string $handlerClass): void
+ {
+ self::$handlers[$format->value] = $handlerClass;
+ }
+
+ /**
+ * Check if a handler is available for a format.
+ *
+ * @param PeppolDocumentFormat $format The format to check
+ *
+ * @return bool
+ */
+ public static function hasHandler(PeppolDocumentFormat $format): bool
+ {
+ return isset(self::$handlers[$format->value]);
+ }
+
+ /**
+ * Return the registry mapping format string values to their handler class names.
+ *
+ * @return array> array where keys are format values and values are handler class-strings implementing InvoiceFormatHandlerInterface
+ */
+ public static function getRegisteredHandlers(): array
+ {
+ return self::$handlers;
+ }
+
+ /**
+ * Create an invoice format handler from a format string.
+ *
+ * @param string $formatString Format identifier, e.g. 'peppol_bis_3.0'.
+ *
+ * @return InvoiceFormatHandlerInterface the handler instance for the parsed format
+ *
+ * @throws RuntimeException if the provided format string is not a valid PeppolDocumentFormat
+ */
+ public static function make(string $formatString): InvoiceFormatHandlerInterface
+ {
+ try {
+ $format = PeppolDocumentFormat::from($formatString);
+
+ return self::create($format);
+ } catch (ValueError $e) {
+ throw new RuntimeException("Invalid format: {$formatString}");
+ }
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/InvoiceFormatHandlerInterface.php b/Modules/Invoices/Peppol/FormatHandlers/InvoiceFormatHandlerInterface.php
new file mode 100644
index 000000000..ee632d1e3
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/InvoiceFormatHandlerInterface.php
@@ -0,0 +1,82 @@
+ $options Additional options for transformation
+ *
+ * @return array The transformed invoice data
+ *
+ * @throws InvalidArgumentException If the invoice cannot be transformed
+ */
+ public function transform(Invoice $invoice, array $options = []): array;
+
+ /**
+ * Generate XML document from invoice data.
+ *
+ * @param Invoice $invoice The invoice to convert
+ * @param array $options Additional options
+ *
+ * @return string The generated XML content
+ *
+ * @throws InvalidArgumentException If generation fails
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string;
+
+ /**
+ * Validate that an invoice meets the format's requirements.
+ *
+ * @param Invoice $invoice The invoice to validate
+ *
+ * @return array Array of validation error messages (empty if valid)
+ */
+ public function validate(Invoice $invoice): array;
+
+ /**
+ * Check if this handler can process the given invoice.
+ *
+ * @param Invoice $invoice The invoice to check
+ *
+ * @return bool True if the handler can process the invoice
+ */
+ public function supports(Invoice $invoice): bool;
+
+ /**
+ * Get the MIME type for this format.
+ *
+ * @return string
+ */
+ public function getMimeType(): string;
+
+ /**
+ * Get the file extension for this format.
+ *
+ * @return string
+ */
+ public function getFileExtension(): string;
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/OioublHandler.php b/Modules/Invoices/Peppol/FormatHandlers/OioublHandler.php
new file mode 100644
index 000000000..f0580b2e1
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/OioublHandler.php
@@ -0,0 +1,474 @@
+customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'ubl_version_id' => '2.0',
+ 'customization_id' => 'OIOUBL-2.02',
+ 'profile_id' => 'Procurement-OrdSim-BilSim-1.0',
+ 'id' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'invoice_type_code' => '380', // Commercial invoice
+ 'document_currency_code' => $currencyCode,
+ 'accounting_cost' => $this->getAccountingCost($invoice),
+
+ // Supplier party
+ 'accounting_supplier_party' => $this->buildSupplierParty($invoice, $endpointScheme),
+
+ // Customer party
+ 'accounting_customer_party' => $this->buildCustomerParty($invoice, $endpointScheme),
+
+ // Payment means
+ 'payment_means' => $this->buildPaymentMeans($invoice),
+
+ // Payment terms
+ 'payment_terms' => $this->buildPaymentTerms($invoice),
+
+ // Tax total
+ 'tax_total' => $this->buildTaxTotal($invoice, $currencyCode),
+
+ // Legal monetary total
+ 'legal_monetary_total' => $this->buildMonetaryTotal($invoice, $currencyCode),
+
+ // Invoice lines
+ 'invoice_line' => $this->buildInvoiceLines($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Generate an OIOUBL XML representation of the given invoice.
+ *
+ * Converts the invoice into the OIOUBL structure and returns it as an XML string.
+ * Currently this method returns a JSON-formatted placeholder of the transformed data.
+ *
+ * @param Invoice $invoice the invoice to convert
+ * @param array $options additional options forwarded to the transform step
+ *
+ * @return string the OIOUBL XML string, or a JSON-formatted placeholder of the transformed data
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Construct the supplier party block for the OIOUBL document using configured supplier data and the provided endpoint scheme.
+ *
+ * @param Invoice $invoice the invoice being transformed (unused except for context)
+ * @param mixed $endpointScheme endpoint scheme object whose `value` property is used as the endpoint scheme identifier
+ *
+ * @return array array representing the supplier `party` structure for the OIOUBL document
+ */
+ protected function buildSupplierParty(Invoice $invoice, $endpointScheme): array
+ {
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_identification' => [
+ 'id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => 'DK:CVR',
+ ],
+ ],
+ 'party_name' => [
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'postal_address' => [
+ 'street_name' => config('invoices.peppol.supplier.street_name'),
+ 'city_name' => config('invoices.peppol.supplier.city_name'),
+ 'postal_zone' => config('invoices.peppol.supplier.postal_zone'),
+ 'country' => [
+ 'identification_code' => config('invoices.peppol.supplier.country_code'),
+ ],
+ ],
+ 'party_tax_scheme' => [
+ 'company_id' => config('invoices.peppol.supplier.vat_number'),
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => config('invoices.peppol.supplier.company_name'),
+ 'company_id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => 'DK:CVR',
+ ],
+ ],
+ 'contact' => [
+ 'name' => config('invoices.peppol.supplier.contact_name'),
+ 'telephone' => config('invoices.peppol.supplier.contact_phone'),
+ 'electronic_mail' => config('invoices.peppol.supplier.contact_email'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Construct the OIOUBL customer party block for the invoice.
+ *
+ * Builds a nested array representing the customer party including endpoint identification,
+ * party identification (DK:CVR), party name, postal address, legal entity, and contact details.
+ *
+ * @param Invoice $invoice the invoice containing customer information
+ * @param mixed $endpointScheme an object with a `value` property used as the endpoint scheme identifier
+ *
+ * @return array nested array representing the customer party section of the OIOUBL document
+ */
+ protected function buildCustomerParty(Invoice $invoice, $endpointScheme): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => $customer?->peppol_id ?? '',
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_identification' => [
+ 'id' => [
+ 'value' => $customer?->peppol_id ?? '',
+ 'scheme_id' => 'DK:CVR',
+ ],
+ ],
+ 'party_name' => [
+ 'name' => $customer?->company_name ?? $customer?->customer_name,
+ ],
+ 'postal_address' => [
+ 'street_name' => $customer?->street1 ?? '',
+ 'additional_street_name' => $customer?->street2 ?? '',
+ 'city_name' => $customer?->city ?? '',
+ 'postal_zone' => $customer?->zip ?? '',
+ 'country' => [
+ 'identification_code' => $customer?->country_code ?? 'DK',
+ ],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => $customer?->company_name ?? $customer?->customer_name,
+ ],
+ 'contact' => [
+ 'name' => $customer?->contact_name ?? '',
+ 'telephone' => $customer?->contact_phone ?? '',
+ 'electronic_mail' => $customer?->contact_email ?? '',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the payment means section for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to build payment means for
+ *
+ * @return array An associative array with keys:
+ * - `payment_means_code`: string, code '31' for international bank transfer.
+ * - `payment_due_date`: string, due date in `YYYY-MM-DD` format.
+ * - `payment_id`: string, the invoice number.
+ * - `payee_financial_account`: array with `id` (account identifier) and
+ * `financial_institution_branch` containing `id` (bank SWIFT/BIC).
+ */
+ protected function buildPaymentMeans(Invoice $invoice): array
+ {
+ return [
+ 'payment_means_code' => '31', // International bank transfer
+ 'payment_due_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'payment_id' => $invoice->invoice_number,
+ 'payee_financial_account' => [
+ 'id' => config('invoices.peppol.supplier.bank_account', ''),
+ 'financial_institution_branch' => [
+ 'id' => config('invoices.peppol.supplier.bank_swift', ''),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build payment terms for the invoice, including a human-readable note and settlement period.
+ *
+ * @param Invoice $invoice the invoice to derive payment terms from
+ *
+ * @return array An array containing:
+ * - `note` (string): A message like "Payment due within X days".
+ * - `settlement_period` (array): Contains `end_date` (string, YYYY-MM-DD) for the settlement end.
+ */
+ protected function buildPaymentTerms(Invoice $invoice): array
+ {
+ $daysUntilDue = $invoice->invoiced_at->diffInDays($invoice->invoice_due_at);
+
+ return [
+ 'note' => sprintf('Payment due within %d days', $daysUntilDue),
+ 'settlement_period' => [
+ 'end_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ ],
+ ];
+ }
+
+ /**
+ * Builds the invoice-level tax total and per-rate tax subtotals.
+ *
+ * Computes the total tax (invoice total minus invoice subtotal), groups invoice items by tax rate,
+ * and produces a list of tax subtotals for each rate with taxable base and tax amount.
+ *
+ * @param Invoice $invoice the invoice used to compute tax bases and amounts
+ * @param string $currencyCode ISO currency code to attach to monetary values
+ *
+ * @return array An array containing:
+ * - `tax_amount`: ['value' => string (formatted to 2 decimals), 'currency_id' => string]
+ * - `tax_subtotal`: array of entries each with:
+ * - `taxable_amount`: ['value' => string (2 decimals), 'currency_id' => string]
+ * - `tax_amount`: ['value' => string (2 decimals), 'currency_id' => string]
+ * - `tax_category`: ['id' => 'S'|'Z', 'percent' => float, 'tax_scheme' => ['id' => 'VAT']]
+ */
+ protected function buildTaxTotal(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ // Group items by tax rate
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getTaxRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'amount' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->subtotal * ($rate / 100);
+ }
+
+ $taxSubtotals = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxSubtotals[] = [
+ 'taxable_amount' => [
+ 'value' => number_format($group['base'], 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_amount' => [
+ 'value' => number_format($group['amount'], 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_category' => [
+ 'id' => $rate > 0 ? 'S' : 'Z',
+ 'percent' => $rate,
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ ];
+ }
+
+ return [
+ 'tax_amount' => [
+ 'value' => number_format($taxAmount, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_subtotal' => $taxSubtotals,
+ ];
+ }
+
+ /**
+ * Construct the monetary totals section for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to derive totals from
+ * @param string $currencyCode currency code used for all returned amounts
+ *
+ * @return array An associative array with keys:
+ * - `line_extension_amount`: array with `value` (subtotal as string formatted to 2 decimals) and `currency_id`.
+ * - `tax_exclusive_amount`: array with `value` (subtotal) and `currency_id`.
+ * - `tax_inclusive_amount`: array with `value` (total amount) and `currency_id`.
+ * - `payable_amount`: array with `value` (total amount) and `currency_id`.
+ */
+ protected function buildMonetaryTotal(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ return [
+ 'line_extension_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_exclusive_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_inclusive_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'payable_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ ];
+ }
+
+ /**
+ * Convert invoice items into an array of OIOUBL invoice line entries.
+ *
+ * Each line entry contains: sequential `id`; `invoiced_quantity` with value and unit code; `line_extension_amount`
+ * and `price` values annotated with the provided currency; `accounting_cost`; and an `item` block including
+ * description, name, seller item id and a `classified_tax_category` (id 'S' for taxed lines, 'Z' for zero rate)
+ * with the tax percent and tax scheme.
+ *
+ * @param Invoice $invoice the invoice whose items will be converted into lines
+ * @param string $currencyCode ISO currency code used for monetary values in each line
+ *
+ * @return array> array of invoice line structures suitable for OIOUBL output
+ */
+ protected function buildInvoiceLines(Invoice $invoice, string $currencyCode): array
+ {
+ return $invoice->invoiceItems->map(function ($item, $index) use ($currencyCode) {
+ $taxRate = $this->getTaxRate($item);
+ $taxAmount = $item->subtotal * ($taxRate / 100);
+
+ return [
+ 'id' => $index + 1,
+ 'invoiced_quantity' => [
+ 'value' => $item->quantity,
+ 'unit_code' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ ],
+ 'line_extension_amount' => [
+ 'value' => number_format($item->subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'accounting_cost' => $this->getLineAccountingCost($item),
+ 'item' => [
+ 'description' => $item->description ?? '',
+ 'name' => $item->item_name,
+ 'sellers_item_identification' => [
+ 'id' => $item->item_code ?? '',
+ ],
+ 'classified_tax_category' => [
+ 'id' => $taxRate > 0 ? 'S' : 'Z',
+ 'percent' => $taxRate,
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ ],
+ 'price' => [
+ 'price_amount' => [
+ 'value' => number_format($item->price, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ ],
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Validate OIOUBL-specific invoice requirements.
+ *
+ * Checks that a supplier CVR (VAT number) is configured and that the invoice's customer has a Peppol ID.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return array array of validation error messages; empty if there are no violations
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // OIOUBL requires CVR number for Danish companies
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier CVR number is required for OIOUBL format';
+ }
+
+ // Customer must have Peppol ID for OIOUBL
+ if ( ! $invoice->customer?->peppol_id) {
+ $errors[] = 'Customer Peppol ID (CVR) is required for OIOUBL format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Uses the invoice reference as the OIOUBL accounting cost code.
+ *
+ * @param Invoice $invoice the invoice to read the reference from
+ *
+ * @return string the invoice reference used as accounting cost, or an empty string if none
+ */
+ protected function getAccountingCost(Invoice $invoice): string
+ {
+ // OIOUBL specific accounting cost reference
+ return $invoice->reference ?? '';
+ }
+
+ /**
+ * Retrieve the accounting cost code for a single invoice line.
+ *
+ * @param mixed $item invoice line item object; expected to have an `accounting_cost` property
+ *
+ * @return string the line's accounting cost code, or an empty string if none is set
+ */
+ protected function getLineAccountingCost($item): string
+ {
+ return $item->accounting_cost ?? '';
+ }
+
+ /**
+ * Return the tax rate for an invoice item, defaulting to 25.0 if the item does not specify one.
+ *
+ * @param mixed $item invoice line item object; may provide a `tax_rate` property
+ *
+ * @return float The tax rate as a percentage (e.g., 25.0).
+ */
+ protected function getTaxRate($item): float
+ {
+ return $item->tax_rate ?? 25.0; // Standard Danish VAT rate
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/PeppolBisHandler.php b/Modules/Invoices/Peppol/FormatHandlers/PeppolBisHandler.php
new file mode 100644
index 000000000..df9842381
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/PeppolBisHandler.php
@@ -0,0 +1,287 @@
+customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'customization_id' => 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
+ 'profile_id' => 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
+ 'id' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'due_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'invoice_type_code' => '380', // Commercial invoice
+ 'document_currency_code' => $currencyCode,
+
+ // Supplier party
+ 'accounting_supplier_party' => [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_name' => [
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'postal_address' => [
+ 'street_name' => config('invoices.peppol.supplier.street_name'),
+ 'city_name' => config('invoices.peppol.supplier.city_name'),
+ 'postal_zone' => config('invoices.peppol.supplier.postal_zone'),
+ 'country' => [
+ 'identification_code' => config('invoices.peppol.supplier.country_code'),
+ ],
+ ],
+ 'party_tax_scheme' => [
+ 'company_id' => config('invoices.peppol.supplier.vat_number'),
+ 'tax_scheme' => [
+ 'id' => 'VAT',
+ ],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'contact' => [
+ 'name' => config('invoices.peppol.supplier.contact_name'),
+ 'telephone' => config('invoices.peppol.supplier.contact_phone'),
+ 'electronic_mail' => config('invoices.peppol.supplier.contact_email'),
+ ],
+ ],
+ ],
+
+ // Customer party
+ 'accounting_customer_party' => [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => $customer?->peppol_id,
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_name' => [
+ 'name' => $customer?->company_name ?? $customer?->customer_name,
+ ],
+ 'postal_address' => [
+ 'street_name' => $customer?->street1,
+ 'city_name' => $customer?->city,
+ 'postal_zone' => $customer?->zip,
+ 'country' => [
+ 'identification_code' => $customer?->country_code,
+ ],
+ ],
+ ],
+ ],
+
+ // Invoice lines
+ 'invoice_line' => $invoice->invoiceItems->map(function ($item, $index) use ($currencyCode) {
+ return [
+ 'id' => $index + 1,
+ 'invoiced_quantity' => [
+ 'value' => $item->quantity,
+ 'unit_code' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ ],
+ 'line_extension_amount' => [
+ 'value' => $item->subtotal,
+ 'currency_id' => $currencyCode,
+ ],
+ 'item' => [
+ 'name' => $item->item_name,
+ 'description' => $item->description,
+ ],
+ 'price' => [
+ 'price_amount' => [
+ 'value' => $item->price,
+ 'currency_id' => $currencyCode,
+ ],
+ ],
+ ];
+ })->toArray(),
+
+ // Monetary totals
+ 'legal_monetary_total' => [
+ 'line_extension_amount' => [
+ 'value' => $invoice->invoice_subtotal,
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_exclusive_amount' => [
+ 'value' => $invoice->invoice_subtotal,
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_inclusive_amount' => [
+ 'value' => $invoice->invoice_total,
+ 'currency_id' => $currencyCode,
+ ],
+ 'payable_amount' => [
+ 'value' => $invoice->invoice_total,
+ 'currency_id' => $currencyCode,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Generates a real UBL 2.1 / PEPPOL BIS Billing 3.0 XML document via num-num/ubl-invoice
+ * (schema-validated against the official UBL-Invoice-2.1.xsd in that package's own test
+ * suite). Field mapping mirrors transform() above one-for-one.
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ $customer = $invoice->customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+ $endpointScheme = $this->getEndpointScheme($invoice)->value;
+
+ $supplierAddress = (new Address())
+ ->setStreetName((string) config('invoices.peppol.supplier.street_name'))
+ ->setCityName((string) config('invoices.peppol.supplier.city_name'))
+ ->setPostalZone((string) config('invoices.peppol.supplier.postal_zone'))
+ ->setCountry((new Country())->setIdentificationCode((string) config('invoices.peppol.supplier.country_code')));
+
+ $supplierParty = (new Party())
+ ->setName((string) config('invoices.peppol.supplier.company_name'))
+ ->setEndpointId(config('invoices.peppol.supplier.vat_number'), $endpointScheme)
+ ->setPostalAddress($supplierAddress)
+ ->setPartyTaxScheme(
+ (new PartyTaxScheme())
+ ->setCompanyId(config('invoices.peppol.supplier.vat_number'))
+ ->setTaxScheme((new TaxScheme())->setId('VAT'))
+ )
+ ->setLegalEntity(
+ (new LegalEntity())
+ ->setRegistrationName((string) config('invoices.peppol.supplier.company_name'))
+ ->setCompanyId(config('invoices.peppol.supplier.vat_number'))
+ );
+
+ $customerAddress = (new Address())
+ ->setStreetName((string) $customer?->street1)
+ ->setCityName((string) $customer?->city)
+ ->setPostalZone((string) $customer?->zip)
+ ->setCountry((new Country())->setIdentificationCode((string) $customer?->country_code));
+
+ $customerParty = (new Party())
+ ->setName($customer?->company_name ?? $customer?->customer_name)
+ ->setEndpointId($customer?->peppol_id, $endpointScheme)
+ ->setPostalAddress($customerAddress);
+
+ $invoiceLines = [];
+
+ foreach ($invoice->invoiceItems as $index => $item) {
+ $price = (new Price())
+ ->setPriceAmount((float) $item->price)
+ ->setBaseQuantity(1)
+ ->setUnitCode(config('invoices.peppol.document.default_unit_code', 'C62'));
+
+ $lineItem = (new Item())
+ ->setName((string) $item->item_name)
+ ->setDescription((string) $item->description);
+
+ $invoiceLines[] = (new InvoiceLine())
+ ->setId((string) ($index + 1))
+ ->setInvoicedQuantity((float) $item->quantity)
+ ->setLineExtensionAmount((float) $item->subtotal)
+ ->setItem($lineItem)
+ ->setPrice($price);
+ }
+
+ $taxAmount = (float) ($invoice->invoice_tax_total ?? 0);
+ $taxPercent = (float) ($invoice->invoiceItems->first()?->tax_rate?->rate ?? 0);
+ $taxCategory = (new TaxCategory())
+ ->setId('S')
+ ->setPercent($taxPercent)
+ ->setTaxScheme((new TaxScheme())->setId('VAT'));
+
+ $taxSubTotal = (new TaxSubTotal())
+ ->setTaxableAmount((float) $invoice->invoice_subtotal)
+ ->setTaxAmount($taxAmount)
+ ->setTaxCategory($taxCategory);
+
+ $taxTotal = (new TaxTotal())
+ ->setTaxAmount($taxAmount)
+ ->setTaxSubTotals([$taxSubTotal]);
+
+ $legalMonetaryTotal = (new LegalMonetaryTotal())
+ ->setLineExtensionAmount((float) $invoice->invoice_subtotal)
+ ->setTaxExclusiveAmount((float) $invoice->invoice_subtotal)
+ ->setTaxInclusiveAmount((float) $invoice->invoice_total)
+ ->setPayableAmount((float) $invoice->invoice_total);
+
+ $ublInvoice = (new UblInvoice())
+ ->setUBLVersionId('2.1')
+ ->setCustomizationId('urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0')
+ ->setProfileId('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0')
+ ->setId((string) $invoice->invoice_number)
+ ->setIssueDate($invoice->invoiced_at instanceof DateTime ? $invoice->invoiced_at : new DateTime())
+ ->setDueDate($invoice->invoice_due_at instanceof DateTime ? $invoice->invoice_due_at : null)
+ ->setInvoiceTypeCode(380) // Commercial invoice
+ ->setDocumentCurrencyCode($currencyCode)
+ ->setAccountingSupplierParty((new AccountingParty())->setParty($supplierParty))
+ ->setAccountingCustomerParty((new AccountingParty())->setParty($customerParty))
+ ->setInvoiceLines($invoiceLines)
+ ->setTaxTotal($taxTotal)
+ ->setLegalMonetaryTotal($legalMonetaryTotal);
+
+ return Generator::invoice($ublInvoice, $currencyCode);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // PEPPOL BIS specific validation
+ if ( ! $invoice->customer?->peppol_id) {
+ $errors[] = 'Customer must have a Peppol ID for PEPPOL BIS format';
+ }
+
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier VAT number is required for PEPPOL BIS format';
+ }
+
+ return $errors;
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/UblHandler.php b/Modules/Invoices/Peppol/FormatHandlers/UblHandler.php
new file mode 100644
index 000000000..de03d8266
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/UblHandler.php
@@ -0,0 +1,230 @@
+customer;
+ $currencyCode = $this->getCurrencyCode($invoice);
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'ubl_version_id' => $this->format === PeppolDocumentFormat::UBL_24 ? '2.4' : '2.1',
+ 'customization_id' => config('invoices.peppol.formats.ubl.customization_id', 'urn:cen.eu:en16931:2017'),
+ 'profile_id' => 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
+ 'id' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'due_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'invoice_type_code' => '380', // Standard commercial invoice
+ 'document_currency_code' => $currencyCode,
+
+ // Supplier
+ 'accounting_supplier_party' => $this->buildSupplierParty($invoice),
+
+ // Customer
+ 'accounting_customer_party' => $this->buildCustomerParty($invoice),
+
+ // Invoice lines
+ 'invoice_line' => $this->buildInvoiceLines($invoice, $currencyCode),
+
+ // Totals
+ 'legal_monetary_total' => $this->buildMonetaryTotals($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // UBL requires certain fields
+ if ( ! $invoice->customer?->peppol_id && config('invoices.peppol.validation.require_customer_peppol_id')) {
+ $errors[] = 'Customer Peppol ID is required for UBL format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Build supplier party data.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildSupplierParty(Invoice $invoice): array
+ {
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => config('invoices.peppol.supplier.vat_number'),
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_name' => [
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'postal_address' => [
+ 'street_name' => config('invoices.peppol.supplier.street_name'),
+ 'city_name' => config('invoices.peppol.supplier.city_name'),
+ 'postal_zone' => config('invoices.peppol.supplier.postal_zone'),
+ 'country' => [
+ 'identification_code' => config('invoices.peppol.supplier.country_code'),
+ ],
+ ],
+ 'party_tax_scheme' => [
+ 'company_id' => config('invoices.peppol.supplier.vat_number'),
+ 'tax_scheme' => ['id' => 'VAT'],
+ ],
+ 'party_legal_entity' => [
+ 'registration_name' => config('invoices.peppol.supplier.company_name'),
+ ],
+ 'contact' => [
+ 'name' => config('invoices.peppol.supplier.contact_name'),
+ 'telephone' => config('invoices.peppol.supplier.contact_phone'),
+ 'electronic_mail' => config('invoices.peppol.supplier.contact_email'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build customer party data.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildCustomerParty(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+ $endpointScheme = $this->getEndpointScheme($invoice);
+
+ return [
+ 'party' => [
+ 'endpoint_id' => [
+ 'value' => $customer->peppol_id,
+ 'scheme_id' => $endpointScheme->value,
+ ],
+ 'party_name' => [
+ 'name' => $customer->company_name ?? $customer->customer_name,
+ ],
+ 'postal_address' => [
+ 'street_name' => $customer->street1,
+ 'additional_street_name' => $customer->street2,
+ 'city_name' => $customer->city,
+ 'postal_zone' => $customer->zip,
+ 'country' => [
+ 'identification_code' => $customer->country_code,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build invoice lines data.
+ *
+ * @param Invoice $invoice
+ * @param string $currencyCode
+ *
+ * @return array>
+ */
+ protected function buildInvoiceLines(Invoice $invoice, string $currencyCode): array
+ {
+ return $invoice->invoiceItems->map(function ($item, $index) use ($currencyCode) {
+ return [
+ 'id' => $index + 1,
+ 'invoiced_quantity' => [
+ 'value' => $item->quantity,
+ 'unit_code' => config('invoices.peppol.document.default_unit_code', 'C62'),
+ ],
+ 'line_extension_amount' => [
+ 'value' => number_format($item->subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'item' => [
+ 'name' => $item->item_name,
+ 'description' => $item->description,
+ ],
+ 'price' => [
+ 'price_amount' => [
+ 'value' => number_format($item->price, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ ],
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Build monetary totals data.
+ *
+ * @param Invoice $invoice
+ * @param string $currencyCode
+ *
+ * @return array
+ */
+ protected function buildMonetaryTotals(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'line_extension_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_exclusive_amount' => [
+ 'value' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'tax_inclusive_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ 'payable_amount' => [
+ 'value' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'currency_id' => $currencyCode,
+ ],
+ ];
+ }
+}
diff --git a/Modules/Invoices/Peppol/FormatHandlers/ZugferdHandler.php b/Modules/Invoices/Peppol/FormatHandlers/ZugferdHandler.php
new file mode 100644
index 000000000..0ca73d582
--- /dev/null
+++ b/Modules/Invoices/Peppol/FormatHandlers/ZugferdHandler.php
@@ -0,0 +1,566 @@
+format === PeppolDocumentFormat::ZUGFERD_10) {
+ return $this->buildZugferd10Structure($invoice);
+ }
+
+ return $this->buildZugferd20Structure($invoice);
+ }
+
+ /**
+ * Generate a string representation of the invoice's ZUGFeRD data.
+ *
+ * Converts the given invoice into the format-specific ZUGFeRD structure and returns it as a string.
+ *
+ * @param Invoice $invoice the invoice to convert into ZUGFeRD format
+ * @param array $options optional format-specific options
+ *
+ * @return string the pretty-printed JSON representation of the transformed ZUGFeRD data (placeholder for the actual XML embedding)
+ */
+ public function generateXml(Invoice $invoice, array $options = []): string
+ {
+ throw new RuntimeException(
+ $this->getFormat()->label() . ' XML generation is not yet implemented — see InvoicePlane-v2#767.'
+ );
+ }
+
+ /**
+ * Build ZUGFeRD 1.0 structure.
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildZugferd10Structure(Invoice $invoice): array
+ {
+ $currencyCode = $this->getCurrencyCode($invoice);
+
+ return [
+ 'CrossIndustryDocument' => [
+ '@xmlns' => 'urn:ferd:CrossIndustryDocument:invoice:1p0',
+ 'SpecifiedExchangedDocumentContext' => [
+ 'GuidelineSpecifiedDocumentContextParameter' => [
+ 'ID' => 'urn:ferd:CrossIndustryDocument:invoice:1p0:comfort',
+ ],
+ ],
+ 'HeaderExchangedDocument' => $this->buildHeaderExchangedDocument($invoice),
+ 'SpecifiedSupplyChainTradeTransaction' => $this->buildSupplyChainTradeTransaction10($invoice, $currencyCode),
+ ],
+ ];
+ }
+
+ /**
+ * Build ZUGFeRD 2.0 structure (compatible with Factur-X).
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildZugferd20Structure(Invoice $invoice): array
+ {
+ $currencyCode = $this->getCurrencyCode($invoice);
+
+ return [
+ 'rsm:CrossIndustryInvoice' => [
+ '@xmlns:rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
+ '@xmlns:ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
+ '@xmlns:udt' => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
+ 'rsm:ExchangedDocumentContext' => $this->buildDocumentContext20(),
+ 'rsm:ExchangedDocument' => $this->buildExchangedDocument20($invoice),
+ 'rsm:SupplyChainTradeTransaction' => $this->buildSupplyChainTradeTransaction20($invoice, $currencyCode),
+ ],
+ ];
+ }
+
+ /**
+ * Create the HeaderExchangedDocument structure for ZUGFeRD 1.0 using invoice data.
+ *
+ * @param Invoice $invoice invoice whose number and issue date populate the header
+ *
+ * @return array associative array representing the HeaderExchangedDocument (ID, Name, TypeCode, IssueDateTime)
+ */
+ protected function buildHeaderExchangedDocument(Invoice $invoice): array
+ {
+ return [
+ 'ID' => $invoice->invoice_number,
+ 'Name' => 'RECHNUNG',
+ 'TypeCode' => '380',
+ 'IssueDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the ZUGFeRD 2.0 document context identifying the basic-compliance guideline.
+ *
+ * @return array Associative array containing `ram:GuidelineSpecifiedDocumentContextParameter` with `ram:ID` set to the ZUGFeRD 2.0 basic-profile URN.
+ */
+ protected function buildDocumentContext20(): array
+ {
+ return [
+ 'ram:GuidelineSpecifiedDocumentContextParameter' => [
+ 'ram:ID' => 'urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p0:basic',
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the ZUGFeRD 2.0 ExchangedDocument block from the invoice metadata.
+ *
+ * @param Invoice $invoice invoice providing the document ID and issue date
+ *
+ * @return array associative array with keys:
+ * - `ram:ID` (invoice number),
+ * - `ram:TypeCode` (invoice type code, "380"),
+ * - `ram:IssueDateTime` containing `udt:DateTimeString` with `@format` "102" and the issue date in `Ymd` format
+ */
+ protected function buildExchangedDocument20(Invoice $invoice): array
+ {
+ return [
+ 'ram:ID' => $invoice->invoice_number,
+ 'ram:TypeCode' => '380',
+ 'ram:IssueDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Assembles the ApplicableSupplyChainTradeTransaction structure for ZUGFeRD 1.0.
+ *
+ * @param string $currencyCode ISO 4217 currency code used for monetary amount fields
+ *
+ * @return array nested array with keys:
+ * - 'ApplicableSupplyChainTradeAgreement' => seller/buyer trade party blocks,
+ * - 'ApplicableSupplyChainTradeDelivery' => delivery event block,
+ * - 'ApplicableSupplyChainTradeSettlement' => settlement and monetary summation block
+ */
+ protected function buildSupplyChainTradeTransaction10(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'ApplicableSupplyChainTradeAgreement' => $this->buildTradeAgreement10($invoice),
+ 'ApplicableSupplyChainTradeDelivery' => $this->buildTradeDelivery10($invoice),
+ 'ApplicableSupplyChainTradeSettlement' => $this->buildTradeSettlement10($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Build supply chain trade transaction (ZUGFeRD 2.0).
+ *
+ * @param Invoice $invoice
+ * @param string $currencyCode
+ *
+ * @return array
+ */
+ protected function buildSupplyChainTradeTransaction20(Invoice $invoice, string $currencyCode): array
+ {
+ return [
+ 'ram:ApplicableHeaderTradeAgreement' => $this->buildTradeAgreement20($invoice),
+ 'ram:ApplicableHeaderTradeDelivery' => $this->buildTradeDelivery20($invoice),
+ 'ram:ApplicableHeaderTradeSettlement' => $this->buildTradeSettlement20($invoice, $currencyCode),
+ ];
+ }
+
+ /**
+ * Builds the ZUGFeRD 1.0 trade agreement section containing seller and buyer party information.
+ *
+ * The returned array contains keyed blocks for `SellerTradeParty` and `BuyerTradeParty`, including
+ * postal address fields and, for the seller, a tax registration entry with VAT scheme ID.
+ *
+ * @param Invoice $invoice invoice object used to source buyer details
+ *
+ * @return array Associative array representing the ApplicableSupplyChainTradeTransaction trade agreement portion for ZUGFeRD 1.0.
+ */
+ protected function buildTradeAgreement10(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'SellerTradeParty' => [
+ 'Name' => config('invoices.peppol.supplier.company_name'),
+ 'PostalTradeAddress' => [
+ 'PostcodeCode' => config('invoices.peppol.supplier.postal_zone'),
+ 'LineOne' => config('invoices.peppol.supplier.street_name'),
+ 'CityName' => config('invoices.peppol.supplier.city_name'),
+ 'CountryID' => config('invoices.peppol.supplier.country_code'),
+ ],
+ 'SpecifiedTaxRegistration' => [
+ 'ID' => [
+ '@schemeID' => 'VA',
+ '#' => config('invoices.peppol.supplier.vat_number'),
+ ],
+ ],
+ ],
+ 'BuyerTradeParty' => [
+ 'Name' => $customer->company_name ?? $customer->customer_name,
+ 'PostalTradeAddress' => [
+ 'PostcodeCode' => $customer->zip ?? '',
+ 'LineOne' => $customer->street1 ?? '',
+ 'CityName' => $customer->city ?? '',
+ 'CountryID' => $customer->country_code ?? '',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build trade agreement (ZUGFeRD 2.0).
+ *
+ * @param Invoice $invoice
+ *
+ * @return array
+ */
+ protected function buildTradeAgreement20(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+
+ return [
+ 'ram:SellerTradeParty' => [
+ 'ram:Name' => config('invoices.peppol.supplier.company_name'),
+ 'ram:PostalTradeAddress' => [
+ 'ram:PostcodeCode' => config('invoices.peppol.supplier.postal_zone'),
+ 'ram:LineOne' => config('invoices.peppol.supplier.street_name'),
+ 'ram:CityName' => config('invoices.peppol.supplier.city_name'),
+ 'ram:CountryID' => config('invoices.peppol.supplier.country_code'),
+ ],
+ 'ram:SpecifiedTaxRegistration' => [
+ 'ram:ID' => [
+ '@schemeID' => 'VA',
+ '#' => config('invoices.peppol.supplier.vat_number'),
+ ],
+ ],
+ ],
+ 'ram:BuyerTradeParty' => [
+ 'ram:Name' => $customer->company_name ?? $customer->customer_name,
+ 'ram:PostalTradeAddress' => [
+ 'ram:PostcodeCode' => $customer->zip ?? '',
+ 'ram:LineOne' => $customer->street1 ?? '',
+ 'ram:CityName' => $customer->city ?? '',
+ 'ram:CountryID' => $customer->country_code ?? '',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the ZUGFeRD 1.0 ActualDeliverySupplyChainEvent using the invoice's issue date.
+ *
+ * @param Invoice $invoice the invoice whose invoiced_at date is used for the occurrence date
+ *
+ * @return array array representing the ActualDeliverySupplyChainEvent with a `DateTimeString` in format `102` (YYYYMMDD)
+ */
+ protected function buildTradeDelivery10(Invoice $invoice): array
+ {
+ return [
+ 'ActualDeliverySupplyChainEvent' => [
+ 'OccurrenceDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the trade delivery block for ZUGFeRD 2.0 with the delivery occurrence date.
+ *
+ * @param Invoice $invoice invoice whose `invoiced_at` date is used as the occurrence date
+ *
+ * @return array associative array representing `ram:ActualDeliverySupplyChainEvent` with `ram:OccurrenceDateTime` containing `udt:DateTimeString` (format `102`) set to the invoice's `invoiced_at` in `Ymd` format
+ */
+ protected function buildTradeDelivery20(Invoice $invoice): array
+ {
+ return [
+ 'ram:ActualDeliverySupplyChainEvent' => [
+ 'ram:OccurrenceDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoiced_at->format('Ymd'),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Constructs the trade settlement section for a ZUGFeRD 1.0 invoice.
+ *
+ * The resulting array contains invoice currency, payment means (SEPA), applicable tax totals,
+ * payment terms with due date, and the monetary summation (line total, tax basis, tax total,
+ * grand total, and due payable amounts).
+ *
+ * @param Invoice $invoice the invoice to derive settlement values from
+ * @param string $currencyCode ISO 4217 currency code used for monetary amounts
+ *
+ * @return array Array representing the SpecifiedTradeSettlement structure for ZUGFeRD 1.0.
+ */
+ protected function buildTradeSettlement10(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ return [
+ 'InvoiceCurrencyCode' => $currencyCode,
+ 'SpecifiedTradeSettlementPaymentMeans' => [
+ 'TypeCode' => '58', // SEPA credit transfer
+ ],
+ 'ApplicableTradeTax' => $this->buildTaxTotals10($invoice),
+ 'SpecifiedTradePaymentTerms' => [
+ 'DueDateTime' => [
+ 'DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoice_due_at->format('Ymd'),
+ ],
+ ],
+ ],
+ 'SpecifiedTradeSettlementMonetarySummation' => [
+ 'LineTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ ],
+ 'TaxBasisTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ ],
+ 'TaxTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($taxAmount, 2, '.', ''),
+ ],
+ 'GrandTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ 'DuePayableAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Build the ZUGFeRD 2.0 trade settlement section for the given invoice.
+ *
+ * Returns an associative array containing the settlement information:
+ * - `ram:InvoiceCurrencyCode`
+ * - `ram:SpecifiedTradeSettlementPaymentMeans` (TypeCode "58" for SEPA)
+ * - `ram:ApplicableTradeTax` (per-rate tax totals)
+ * - `ram:SpecifiedTradePaymentTerms` (due date as `udt:DateTimeString` format 102)
+ * - `ram:SpecifiedTradeSettlementHeaderMonetarySummation` (line, tax, grand and due payable amounts)
+ *
+ * @param Invoice $invoice invoice model providing amounts and dates
+ * @param string $currencyCode ISO 4217 currency code used for monetary elements
+ *
+ * @return array Associative array representing the ZUGFeRD 2.0 settlement structure.
+ */
+ protected function buildTradeSettlement20(Invoice $invoice, string $currencyCode): array
+ {
+ $taxAmount = $invoice->invoice_total - $invoice->invoice_subtotal;
+
+ return [
+ 'ram:InvoiceCurrencyCode' => $currencyCode,
+ 'ram:SpecifiedTradeSettlementPaymentMeans' => [
+ 'ram:TypeCode' => '58', // SEPA credit transfer
+ ],
+ 'ram:ApplicableTradeTax' => $this->buildTaxTotals20($invoice),
+ 'ram:SpecifiedTradePaymentTerms' => [
+ 'ram:DueDateTime' => [
+ 'udt:DateTimeString' => [
+ '@format' => '102',
+ '#' => $invoice->invoice_due_at->format('Ymd'),
+ ],
+ ],
+ ],
+ 'ram:SpecifiedTradeSettlementHeaderMonetarySummation' => [
+ 'ram:LineTotalAmount' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'ram:TaxBasisTotalAmount' => number_format($invoice->invoice_subtotal, 2, '.', ''),
+ 'ram:TaxTotalAmount' => [
+ '@currencyID' => $currencyCode,
+ '#' => number_format($taxAmount, 2, '.', ''),
+ ],
+ 'ram:GrandTotalAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ 'ram:DuePayableAmount' => number_format($invoice->invoice_total, 2, '.', ''),
+ ],
+ ];
+ }
+
+ /**
+ * Builds tax total entries for ZUGFeRD 1.0 grouped by tax rate.
+ *
+ * Each entry contains:
+ * - `CalculatedAmount`: array with `@currencyID` and numeric string value (`#`).
+ * - `TypeCode`: tax type (always `'VAT'`).
+ * - `BasisAmount`: array with `@currencyID` and numeric string value (`#`).
+ * - `CategoryCode`: `'S'` for taxable rates greater than zero, `'Z'` for zero rate.
+ * - `ApplicablePercent`: tax rate as a numeric string.
+ *
+ * @param Invoice $invoice invoice used to compute tax groups
+ *
+ * @return array Array of tax total entries suitable for ZUGFeRD 1.0.
+ */
+ protected function buildTaxTotals10(Invoice $invoice): array
+ {
+ $taxGroups = $this->groupTaxesByRate($invoice);
+ $taxes = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxes[] = [
+ 'CalculatedAmount' => [
+ '@currencyID' => $this->getCurrencyCode($invoice),
+ '#' => number_format($group['amount'], 2, '.', ''),
+ ],
+ 'TypeCode' => 'VAT',
+ 'BasisAmount' => [
+ '@currencyID' => $this->getCurrencyCode($invoice),
+ '#' => number_format($group['base'], 2, '.', ''),
+ ],
+ 'CategoryCode' => $rate > 0 ? 'S' : 'Z',
+ 'ApplicablePercent' => number_format($rate, 2, '.', ''),
+ ];
+ }
+
+ return $taxes;
+ }
+
+ /**
+ * Build the ZUGFeRD 2.0 tax total entries grouped by tax rate.
+ *
+ * Produces an array of RAM tax nodes where each entry contains formatted strings for
+ * `ram:CalculatedAmount`, `ram:BasisAmount`, and `ram:RateApplicablePercent`, plus
+ * `ram:TypeCode` and `ram:CategoryCode` (\"S\" for taxable rates > 0, \"Z\" for zero rate).
+ *
+ * @param Invoice $invoice invoice to derive tax groups from
+ *
+ * @return array> List of tax entries suitable for inclusion in a ZUGFeRD 2.0 payload.
+ */
+ protected function buildTaxTotals20(Invoice $invoice): array
+ {
+ $taxGroups = $this->groupTaxesByRate($invoice);
+ $taxes = [];
+
+ foreach ($taxGroups as $rateKey => $group) {
+ $rate = (float) $rateKey;
+ $taxes[] = [
+ 'ram:CalculatedAmount' => number_format($group['amount'], 2, '.', ''),
+ 'ram:TypeCode' => 'VAT',
+ 'ram:BasisAmount' => number_format($group['base'], 2, '.', ''),
+ 'ram:CategoryCode' => $rate > 0 ? 'S' : 'Z',
+ 'ram:RateApplicablePercent' => number_format($rate, 2, '.', ''),
+ ];
+ }
+
+ return $taxes;
+ }
+
+ /**
+ * Groups invoice tax bases and tax amounts by tax rate.
+ *
+ * Builds an associative array keyed by tax rate (percentage) where each value contains
+ * the cumulative 'base' (taxable amount) and 'amount' (calculated tax) for that rate,
+ * using the invoice currency values.
+ *
+ * @param Invoice $invoice the invoice whose items will be grouped
+ *
+ * @return array> associative array keyed by tax rate with keys 'base' and 'amount' holding totals as floats
+ */
+ protected function groupTaxesByRate(Invoice $invoice): array
+ {
+ $taxGroups = [];
+
+ foreach ($invoice->invoiceItems as $item) {
+ $rate = $this->getTaxRate($item);
+ $rateKey = (string) $rate;
+
+ if ( ! isset($taxGroups[$rateKey])) {
+ $taxGroups[$rateKey] = [
+ 'base' => 0,
+ 'amount' => 0,
+ ];
+ }
+
+ $taxGroups[$rateKey]['base'] += $item->subtotal;
+ $taxGroups[$rateKey]['amount'] += $item->subtotal * ($rate / 100);
+ }
+
+ return $taxGroups;
+ }
+
+ /**
+ * Perform ZUGFeRD-specific validation on an invoice.
+ *
+ * @param Invoice $invoice the invoice to validate
+ *
+ * @return string[] array of validation error messages; empty if the invoice passes ZUGFeRD-specific checks
+ */
+ protected function validateFormatSpecific(Invoice $invoice): array
+ {
+ $errors = [];
+
+ // ZUGFeRD requires VAT number
+ if ( ! config('invoices.peppol.supplier.vat_number')) {
+ $errors[] = 'Supplier VAT number is required for ZUGFeRD format';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Retrieve the tax rate percent from an invoice item.
+ *
+ * @param mixed $item invoice line item object or array expected to contain a `tax_rate` value
+ *
+ * @return float The tax rate as a percentage (e.g., 19.0). Returns 19.0 if the item has no `tax_rate`.
+ */
+ protected function getTaxRate($item): float
+ {
+ return $item->tax_rate ?? 19.0; // Default German VAT rate
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/BaseProvider.php b/Modules/Invoices/Peppol/Providers/BaseProvider.php
new file mode 100644
index 000000000..1208f366d
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/BaseProvider.php
@@ -0,0 +1,140 @@
+integration = $integration;
+ $this->config = $integration?->config ?? [];
+ }
+
+ /**
+ * Provide the provider's default API base URL.
+ *
+ * @return string the default base URL to use when no explicit configuration is available
+ */
+ abstract protected function getDefaultBaseUrl(): string;
+
+ /**
+ * Default: no settings() keys are system-managed. OAuth2 providers override this.
+ */
+ public static function managedSettingsKeys(): array
+ {
+ return [];
+ }
+
+ /**
+ * Indicates that webhook registration is not supported by this provider.
+ *
+ * @param string $url the webhook callback URL to register
+ * @param string $secret the shared secret used to sign or verify callbacks
+ *
+ * @return array{success:bool,message:string} an associative array with `success` set to `false` and a human-readable `message`
+ */
+ public function registerWebhookCallback(string $url, string $secret): array
+ {
+ return [
+ 'success' => false,
+ 'message' => 'Webhooks not supported by this provider',
+ ];
+ }
+
+ /**
+ * Retrieve Peppol acknowledgements available since an optional timestamp.
+ *
+ * Providers that support polling should override this method to return acknowledgement records.
+ *
+ * @param \Carbon\Carbon|null $since an optional cutoff; only acknowledgements at or after this time should be returned
+ *
+ * @return array an array of acknowledgement entries; empty by default
+ */
+ public function fetchAcknowledgements(?\Carbon\Carbon $since = null): array
+ {
+ return [];
+ }
+
+ /**
+ * Classifies an HTTP response into a Peppol error category.
+ *
+ * Defaults to mapping server errors, rate limits, and timeouts to `PeppolErrorType::TRANSIENT`;
+ * authentication, client/validation and not-found errors to `PeppolErrorType::PERMANENT`;
+ * and all other statuses to `PeppolErrorType::UNKNOWN`. Providers may override for custom rules.
+ *
+ * @param int $statusCode the HTTP status code to classify
+ * @param array|null $responseBody optional parsed response body from the provider; available for provider-specific overrides
+ *
+ * @return string one of the `PeppolErrorType` values (`TRANSIENT`, `PERMANENT`, or `UNKNOWN`) as a string
+ */
+ public function classifyError(int $statusCode, ?array $responseBody = null): string
+ {
+ return match(true) {
+ $statusCode >= 500 => PeppolErrorType::TRANSIENT->value, // Server errors
+ $statusCode === 429 => PeppolErrorType::TRANSIENT->value, // Rate limit
+ $statusCode === 408 => PeppolErrorType::TRANSIENT->value, // Timeout
+ $statusCode === 401 || $statusCode === 403 => PeppolErrorType::PERMANENT->value, // Auth errors
+ $statusCode === 404 => PeppolErrorType::PERMANENT->value, // Not found
+ $statusCode === 400 || $statusCode === 422 => PeppolErrorType::PERMANENT->value, // Validation errors
+ default => PeppolErrorType::UNKNOWN->value,
+ };
+ }
+
+ /**
+ * Default authenticate implementation for providers using static credentials.
+ *
+ * Checks that all settings from the provider's schema are present and non-empty.
+ * This default implementation is used by EInvoiceBeProvider, QontoProvider, and StorecoveProvider.
+ * OAuth2 providers (LetsPeppolProvider, SuperPdpProvider) override this with their own logic.
+ *
+ * @return bool true if all required settings are configured, false otherwise
+ */
+ public function authenticate(): bool
+ {
+ $settings = static::settings();
+
+ foreach ($settings as $key) {
+ if (empty($this->config[$key])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Resolve the provider's base URL.
+ *
+ * Looks up a base URL from the provider instance config, then from the application
+ * configuration for the provider, and falls back to the provider's default.
+ *
+ * @return string The resolved base URL. */
+ protected function getBaseUrl(): string
+ {
+ return $this->config['base_url']
+ ?? config("invoices.peppol.{$this->getProviderName()}.base_url")
+ ?? $this->getDefaultBaseUrl();
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/Concerns/RefreshesOAuth2Token.php b/Modules/Invoices/Peppol/Providers/Concerns/RefreshesOAuth2Token.php
new file mode 100644
index 000000000..0ed2939d0
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/Concerns/RefreshesOAuth2Token.php
@@ -0,0 +1,123 @@
+config['access_token'] ?? null;
+ $expiresAt = $this->config['token_expires_at'] ?? null;
+
+ // Token exists and is not expired
+ if ($accessToken && $expiresAt) {
+ $expiry = Carbon::parse($expiresAt);
+ if ($expiry->isFuture()) {
+ return true;
+ }
+ }
+
+ // Token missing or expired — refresh
+ $clientId = $this->config['client_id'] ?? null;
+ $clientSecret = $this->config['client_secret'] ?? null;
+
+ if ( ! $clientId || ! $clientSecret) {
+ return false;
+ }
+
+ // Build temporary client to perform OAuth2 exchange
+ $clientClass = $this->getOAuth2ClientClass();
+ if ( ! $clientClass || ! class_exists($clientClass)) {
+ return false;
+ }
+
+ $tempClient = new $clientClass(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ 'placeholder',
+ $this->getDefaultBaseUrl()
+ );
+
+ // Perform OAuth2 authentication
+ if ( ! $tempClient->authenticate(['client_id' => $clientId, 'client_secret' => $clientSecret])) {
+ return false;
+ }
+
+ // Extract token and expiry from response
+ $response = $tempClient->getLastAuthResponse();
+ if ( ! $response || ! isset($response['access_token'])) {
+ return false;
+ }
+
+ $newToken = $response['access_token'];
+ $expiresIn = $response['expires_in'] ?? 3600;
+ $expiresAt = Carbon::now()->addSeconds($expiresIn)->toDateTimeString();
+
+ // Persist token and expiry back to merchant_clients
+ if ($this->integration) {
+ $this->integration->setConfig([
+ 'access_token' => $newToken,
+ 'token_expires_at' => $expiresAt,
+ ]);
+
+ // Refresh config in memory
+ $this->config = $this->integration->config;
+ }
+
+ // Propagate token to all resource clients
+ $this->propagateAccessToken($newToken);
+
+ return true;
+ }
+
+ /**
+ * Get the OAuth2 client class name for this provider.
+ *
+ * Subclasses should override this to return the full class name of their OAuth2 client.
+ * Example: return LetsPeppolClient::class;
+ *
+ * @return string|null the full class name of the OAuth2 client, or null if not applicable
+ */
+ protected function getOAuth2ClientClass(): ?string
+ {
+ return null;
+ }
+
+ /**
+ * Propagate the access token to all resource clients.
+ *
+ * Subclasses should override this to call setAccessToken() on all clients
+ * that were constructed in __construct().
+ *
+ * @param string $token the new access token
+ */
+ protected function propagateAccessToken(string $token): void
+ {
+ // Override in subclass to call setAccessToken() on each resource client
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php b/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php
new file mode 100644
index 000000000..a3f612160
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php
@@ -0,0 +1,442 @@
+getApiKey() ?? '';
+ $baseUrl = $this->getDefaultBaseUrl();
+
+ $this->documentsClient = $documentsClient ?? new DocumentsClient($httpClient, $apiKey, $baseUrl);
+ $this->participantsClient = $participantsClient ?? new ParticipantsClient($httpClient, $apiKey, $baseUrl);
+ $this->trackingClient = $trackingClient ?? new TrackingClient($httpClient, $apiKey, $baseUrl);
+ $this->healthClient = $healthClient ?? new HealthClient($httpClient, $apiKey, $baseUrl);
+ }
+
+ /**
+ * Get the declarative settings schema for e-invoice.be.
+ *
+ * Delegates to the client's static method for a single source of truth.
+ *
+ * @return array list of config keys
+ */
+ public static function settings(): array
+ {
+ return EInvoiceBeClient::settings();
+ }
+
+ /**
+ * Provider identifier for the e-invoice.be Peppol integration.
+ *
+ * @return string the provider identifier 'e_invoice_be'
+ */
+ public function getProviderName(): string
+ {
+ return 'e_invoice_be';
+ }
+
+ /**
+ * Checks connectivity to the e-invoice.be API via the health client.
+ *
+ * @param array $config optional connection configuration (may include credentials or endpoint overrides)
+ *
+ * @return array associative array with keys: 'ok' (`true` if API reachable, `false` otherwise) and 'message' (human-readable status or error message)
+ */
+ public function testConnection(array $config): array
+ {
+ try {
+ $response = $this->healthClient->ping();
+
+ if ($response->successful()) {
+ $data = $response->json();
+
+ return [
+ 'ok' => true,
+ 'message' => 'Connection successful. API is reachable.',
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'message' => "Connection failed with status: {$response->status()}",
+ ];
+ } catch (Exception $e) {
+ $this->logPeppolError('e-invoice.be connection test failed', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ return [
+ 'ok' => false,
+ 'message' => 'Connection test failed: ' . $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Checks whether a Peppol participant exists for the given identifier and returns details if found.
+ *
+ * Performs a lookup using the participants client; a 404 response is treated as "not present".
+ *
+ * @param string $scheme Identifier scheme used for the lookup (e.g., "GLN", "VAT").
+ * @param string $id the participant identifier to validate
+ *
+ * @return array An array with keys:
+ * - `present` (bool): `true` if the participant exists, `false` otherwise.
+ * - `details` (array|null): participant data when present; `null` if not found; or an `['error' => string]` structure on failure.
+ */
+ public function validatePeppolId(string $scheme, string $id): array
+ {
+ try {
+ $response = $this->participantsClient->searchParticipant($id, $scheme);
+
+ if ($response->successful()) {
+ $data = $response->json();
+
+ return [
+ 'present' => true,
+ 'details' => $data,
+ ];
+ }
+
+ // 404 means participant not found
+ if ($response->status() === 404) {
+ return [
+ 'present' => false,
+ 'details' => null,
+ ];
+ }
+
+ // Other errors
+ return [
+ 'present' => false,
+ 'details' => ['error' => $response->body()],
+ ];
+ } catch (Exception $e) {
+ $this->logPeppolError('Peppol ID validation failed', [
+ 'scheme' => $scheme,
+ 'id' => $id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [
+ 'present' => false,
+ 'details' => ['error' => $e->getMessage()],
+ ];
+ }
+ }
+
+ /**
+ * Submits an invoice document to e-invoice.be and returns the submission result.
+ *
+ * @param array $transmissionData transmission DTO; must include an 'invoice' key holding the
+ * Invoice model — this provider builds its own e-invoice.be-shaped
+ * document from it (see buildDocumentPayload())
+ *
+ * @return array{
+ * accepted: bool, // `true` if the document was accepted by the API, `false` otherwise
+ * external_id: string|null, // provider-assigned document identifier when available
+ * status_code: int, // HTTP status code returned by the provider (0 on exception)
+ * message: string, // human-readable message or error body
+ * response: array|null // parsed response body on success/failure, or null if an exception occurred
+ * }
+ */
+ public function sendInvoice(array $transmissionData): array
+ {
+ try {
+ $invoice = $transmissionData['invoice'] ?? null;
+
+ if ( ! $invoice instanceof Invoice) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => 0,
+ 'message' => 'Missing invoice',
+ 'response' => null,
+ ];
+ }
+
+ $response = $this->documentsClient->submitDocument($this->buildDocumentPayload($invoice));
+
+ if ($response->successful()) {
+ $data = $response->json();
+
+ return [
+ 'accepted' => true,
+ 'external_id' => $data['document_id'] ?? $data['id'] ?? null,
+ 'status_code' => $response->status(),
+ 'message' => 'Document submitted successfully',
+ 'response' => $data,
+ ];
+ }
+
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => $response->status(),
+ 'message' => $response->body(),
+ 'response' => $response->json(),
+ ];
+ } catch (Exception $e) {
+ $this->logPeppolError('Invoice submission to e-invoice.be failed', [
+ 'invoice_id' => $transmissionData['invoice_id'] ?? null,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => 0,
+ 'message' => $e->getMessage(),
+ 'response' => null,
+ ];
+ }
+ }
+
+ /**
+ * Retrieve the transmission status and acknowledgement payload for a given external document ID.
+ *
+ * @param string $externalId the provider's external document identifier
+ *
+ * @return array An associative array with keys:
+ * - `status` (string): transmission status (e.g., `'unknown'`, `'error'`, or provider-specific status).
+ * - `ack_payload` (array|null): acknowledgement payload returned by the provider, or `null` when unavailable.
+ */
+ public function getTransmissionStatus(string $externalId): array
+ {
+ try {
+ $response = $this->trackingClient->getStatus($externalId);
+
+ if ($response->successful()) {
+ $data = $response->json();
+
+ return [
+ 'status' => $data['status'] ?? 'unknown',
+ 'ack_payload' => $data,
+ ];
+ }
+
+ return [
+ 'status' => 'error',
+ 'ack_payload' => null,
+ ];
+ } catch (Exception $e) {
+ $this->logPeppolError('Status check failed for e-invoice.be', [
+ 'external_id' => $externalId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => $e->getMessage()],
+ ];
+ }
+ }
+
+ /**
+ * Cancel a previously submitted document identified by its external ID.
+ *
+ * @param string $externalId the external identifier of the document to cancel
+ *
+ * @return array An associative array with keys:
+ * - `success` (`bool`): `true` if cancellation succeeded, `false` otherwise.
+ * - `message` (`string`): a success message or an error/cancellation failure message.
+ */
+ public function cancelDocument(string $externalId): array
+ {
+ try {
+ $response = $this->documentsClient->cancelDocument($externalId);
+
+ if ($response->successful()) {
+ return [
+ 'success' => true,
+ 'message' => 'Document cancelled successfully',
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => "Cancellation failed: {$response->body()}",
+ ];
+ } catch (Exception $e) {
+ $this->logPeppolError('Document cancellation failed', [
+ 'external_id' => $externalId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Retrieve acknowledgement documents from e-invoice.be since a given timestamp.
+ *
+ * If `$since` is null, defaults to 7 days ago. Queries the tracking client and
+ * returns the `documents` array from the response or an empty array on failure.
+ *
+ * @param Carbon|null $since the earliest timestamp to include (ISO-8601); if null, defaults to now minus 7 days
+ *
+ * @return array an array of acknowledgement document payloads, or an empty array if none were found or the request failed
+ */
+ public function fetchAcknowledgements(?Carbon $since = null): array
+ {
+ try {
+ // Default to last 7 days if not specified
+ $since ??= Carbon::now()->subDays(7);
+
+ $response = $this->trackingClient->listDocuments([
+ 'from_date' => $since->toIso8601String(),
+ ]);
+
+ if ($response->successful()) {
+ return $response->json('documents', []);
+ }
+
+ return [];
+ } catch (Exception $e) {
+ $this->logPeppolError('Failed to fetch acknowledgements from e-invoice.be', [
+ 'since' => $since,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+
+ /**
+ * Classifies an error according to e-invoice.be-specific error codes.
+ *
+ * If `$responseBody` contains an `error_code`, maps known codes to either
+ * `'TRANSIENT'` or `'PERMANENT'`. If no known code is present, delegates to
+ * the general classification logic.
+ *
+ * @param int $statusCode HTTP status code returned by the upstream service
+ * @param array|null $responseBody decoded JSON response body; may contain an `error_code` key
+ *
+ * @return string `'TRANSIENT'` if the error is transient, `'PERMANENT'` if permanent, otherwise the general classification result
+ */
+ public function classifyError(int $statusCode, ?array $responseBody = null): string
+ {
+ // Check for specific e-invoice.be error codes in response body
+ if ($responseBody && isset($responseBody['error_code'])) {
+ return match($responseBody['error_code']) {
+ 'RATE_LIMIT_EXCEEDED' => 'TRANSIENT',
+ 'SERVICE_UNAVAILABLE' => 'TRANSIENT',
+ 'INVALID_PARTICIPANT' => 'PERMANENT',
+ 'INVALID_DOCUMENT' => 'PERMANENT',
+ 'AUTHENTICATION_FAILED' => 'PERMANENT',
+ default => parent::classifyError($statusCode, $responseBody),
+ };
+ }
+
+ return parent::classifyError($statusCode, $responseBody);
+ }
+
+ /**
+ * Get the API key from integration configuration.
+ *
+ * @return string|null The API key, or null if not configured
+ */
+ public function getApiKey(): ?string
+ {
+ return $this->config['api_key'] ?? null;
+ }
+
+ /**
+ * Provide the default base URL for the e-invoice.be API.
+ *
+ * @return string The default base URL for the e-invoice.be API.
+ */
+ protected function getDefaultBaseUrl(): string
+ {
+ return 'https://api.e-invoice.be';
+ }
+
+ /**
+ * Build the e-invoice.be documents API request body from an invoice.
+ *
+ * Field names for `invoice_lines`/`legal_monetary_total` follow this codebase's own
+ * DocumentsClient documentation, which only illustrates them as `[...]`/`{...}` — verify
+ * the exact nested shape against e-invoice.be's real API docs before relying on this in
+ * production.
+ *
+ * @return array
+ */
+ private function buildDocumentPayload(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+ $currencyCode = $invoice->currency_code ?? config('invoices.peppol.document.currency_code', 'EUR');
+
+ return [
+ 'document_type' => 'invoice',
+ 'invoice_number' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at?->format('Y-m-d'),
+ 'due_date' => $invoice->invoice_due_at?->format('Y-m-d'),
+ 'currency_code' => $currencyCode,
+ 'supplier' => [
+ 'name' => config('invoices.peppol.supplier.company_name'),
+ 'vat_number' => config('invoices.peppol.supplier.vat_number'),
+ ],
+ 'customer' => [
+ 'name' => $customer?->company_name ?? $customer?->customer_name,
+ 'endpoint_id' => $customer?->peppol_id,
+ 'endpoint_scheme' => $customer?->peppol_scheme,
+ ],
+ 'invoice_lines' => $invoice->invoiceItems->map(fn ($item) => [
+ 'description' => $item->item_name,
+ 'quantity' => $item->quantity,
+ 'unit_price' => $item->price,
+ 'line_total' => $item->subtotal,
+ ])->all(),
+ 'legal_monetary_total' => [
+ 'line_extension_amount' => $invoice->invoice_subtotal,
+ 'tax_exclusive_amount' => $invoice->invoice_subtotal,
+ 'tax_inclusive_amount' => $invoice->invoice_total,
+ 'payable_amount' => $invoice->invoice_total,
+ ],
+ ];
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/LetsPeppol/LetsPeppolProvider.php b/Modules/Invoices/Peppol/Providers/LetsPeppol/LetsPeppolProvider.php
new file mode 100644
index 000000000..cb03ec747
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/LetsPeppol/LetsPeppolProvider.php
@@ -0,0 +1,315 @@
+invoiceClient = $invoiceClient;
+ } else {
+ $this->invoiceClient = new InvoiceClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ if ($creditNoteClient) {
+ $this->creditNoteClient = $creditNoteClient;
+ } else {
+ $this->creditNoteClient = new CreditNoteClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ if ($participantClient) {
+ $this->participantClient = $participantClient;
+ } else {
+ $this->participantClient = new ParticipantClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ if ($transmissionClient) {
+ $this->transmissionClient = $transmissionClient;
+ } else {
+ $this->transmissionClient = new TransmissionClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ if ($documentClient) {
+ $this->documentClient = $documentClient;
+ } else {
+ $this->documentClient = new DocumentClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+ }
+
+ /**
+ * Get the declarative settings schema for LetsPeppol OAuth2.
+ *
+ * Delegates to the client's static method for a single source of truth.
+ *
+ * @return array list of config keys
+ */
+ public static function settings(): array
+ {
+ return LetsPeppolClient::settings();
+ }
+
+ public static function managedSettingsKeys(): array
+ {
+ return ['access_token'];
+ }
+
+ public function getProviderName(): string
+ {
+ return 'lets_peppol';
+ }
+
+ public function testConnection(array $config): array
+ {
+ if ($this->ensureAuthenticated()) {
+ return [
+ 'ok' => true,
+ 'message' => 'LetsPeppol authentication succeeded',
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'message' => 'LetsPeppol authentication failed — check client_id/client_secret',
+ ];
+ }
+
+ public function validatePeppolId(string $scheme, string $id): array
+ {
+ return [
+ 'present' => true,
+ 'details' => ['scheme' => $scheme, 'identifier' => $id],
+ ];
+ }
+
+ public function sendInvoice(array $transmissionData): array
+ {
+ if ( ! $this->ensureAuthenticated()) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => 401,
+ 'message' => 'LetsPeppol authentication failed',
+ 'response' => null,
+ ];
+ }
+
+ try {
+ $xml = $transmissionData['xml'] ?? '';
+ $recipientScheme = $transmissionData['recipient_scheme'] ?? '';
+ $recipientId = $transmissionData['recipient_id'] ?? '';
+
+ $payload = [
+ 'document' => base64_encode($xml),
+ 'documentType' => 'invoice',
+ 'recipient' => ['scheme' => $recipientScheme, 'identifier' => $recipientId],
+ ];
+
+ $response = $this->invoiceClient->submitInvoice($payload);
+
+ if ( ! $response->successful()) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => $response->status(),
+ 'message' => 'LetsPeppol rejected submission',
+ 'response' => $response->json(),
+ ];
+ }
+
+ $id = $response->json('id');
+
+ return [
+ 'accepted' => true,
+ 'external_id' => $id,
+ 'status_code' => $response->status(),
+ 'message' => 'Document submitted to LetsPeppol',
+ 'response' => $response->json(),
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => 0,
+ 'message' => 'LetsPeppol submission error: ' . $e->getMessage(),
+ 'response' => null,
+ ];
+ }
+ }
+
+ public function getTransmissionStatus(string $externalId): array
+ {
+ if ( ! $this->ensureAuthenticated()) {
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => 'LetsPeppol authentication failed'],
+ ];
+ }
+
+ try {
+ $response = $this->transmissionClient->getStatus($externalId);
+
+ if ( ! $response->successful()) {
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => 'Failed to retrieve status'],
+ ];
+ }
+
+ return [
+ 'status' => $response->json('status', 'unknown'),
+ 'ack_payload' => $response->json(),
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => $e->getMessage()],
+ ];
+ }
+ }
+
+ public function cancelDocument(string $externalId): array
+ {
+ try {
+ $response = $this->documentClient->cancelDocument($externalId);
+
+ return [
+ 'success' => $response->successful(),
+ 'message' => $response->successful() ? 'Document cancelled' : 'Cancellation failed',
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Cancellation error: ' . $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Get the OAuth2 client ID from integration configuration.
+ *
+ * @return string|null The client ID, or null if not configured
+ */
+ public function getClientId(): ?string
+ {
+ return $this->config['client_id'] ?? null;
+ }
+
+ /**
+ * Get the OAuth2 client secret from integration configuration.
+ *
+ * @return string|null The client secret, or null if not configured
+ */
+ public function getClientSecret(): ?string
+ {
+ return $this->config['client_secret'] ?? null;
+ }
+
+ /**
+ * Get the stored OAuth2 access token from integration configuration.
+ *
+ * @return string|null The access token, or null if not yet obtained
+ */
+ public function getAccessToken(): ?string
+ {
+ return $this->config['access_token'] ?? null;
+ }
+
+ /**
+ * Authenticate with LetsPeppol using OAuth2 client-credentials flow.
+ *
+ * Ensures a valid access token exists, fetching and persisting a new one if needed.
+ * Tokens are stored in merchant_clients with expiry tracking for automatic refresh.
+ *
+ * @return bool True if authentication succeeded and token is valid
+ */
+ public function authenticate(): bool
+ {
+ return $this->ensureAuthenticated();
+ }
+
+ protected function getDefaultBaseUrl(): string
+ {
+ return 'https://api.letspeppol.com/api/v1';
+ }
+
+ /**
+ * Get the OAuth2 client class for LetsPeppol.
+ *
+ * @return string the full class name
+ */
+ protected function getOAuth2ClientClass(): ?string
+ {
+ return LetsPeppolClient::class;
+ }
+
+ /**
+ * Propagate the access token to all resource clients.
+ *
+ * Called after token refresh to ensure all clients use the new token.
+ *
+ * @param string $token the new access token
+ */
+ protected function propagateAccessToken(string $token): void
+ {
+ $this->invoiceClient->setAccessToken($token);
+ $this->creditNoteClient->setAccessToken($token);
+ $this->participantClient->setAccessToken($token);
+ $this->transmissionClient->setAccessToken($token);
+ $this->documentClient->setAccessToken($token);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/ProviderFactory.php b/Modules/Invoices/Peppol/Providers/ProviderFactory.php
new file mode 100644
index 000000000..463c518e1
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/ProviderFactory.php
@@ -0,0 +1,160 @@
+provider_name, $integration);
+ }
+
+ /**
+ * Instantiate a Peppol provider by provider key.
+ *
+ * @param string $providerName the provider key (snake_case directory name) identifying which provider to create
+ * @param PeppolIntegration|null $integration optional integration model to pass to the provider constructor
+ *
+ * @return ProviderInterface the created provider instance
+ *
+ * @throws InvalidArgumentException if no provider matches the given name
+ */
+ public static function makeFromName(string $providerName, ?PeppolIntegration $integration = null): ProviderInterface
+ {
+ return app(self::getProviderClass($providerName), ['integration' => $integration]);
+ }
+
+ /**
+ * Resolve a provider key to its fully-qualified class name, without instantiating it —
+ * useful for reading static methods like settings() or managedSettingsKeys().
+ *
+ * @throws InvalidArgumentException if no provider matches the given name
+ */
+ public static function getProviderClass(string $providerName): string
+ {
+ $providers = self::discoverProviders();
+
+ if ( ! isset($providers[$providerName])) {
+ throw new InvalidArgumentException("Unknown Peppol provider: {$providerName}");
+ }
+
+ return $providers[$providerName];
+ }
+
+ /**
+ * Map discovered provider keys to user-friendly provider names.
+ *
+ * Names are derived from each provider class basename by removing the "Provider"
+ * suffix and converting the remainder to Title Case with spaces.
+ *
+ * @return array associative array mapping provider key => friendly name
+ */
+ public static function getAvailableProviders(): array
+ {
+ $providers = self::discoverProviders();
+ $result = [];
+
+ foreach ($providers as $key => $class) {
+ // Get friendly name from class name
+ $className = class_basename($class);
+ $friendlyName = str_replace('Provider', '', $className);
+ $friendlyName = Str::title(Str::snake($friendlyName, ' '));
+
+ $result[$key] = $friendlyName;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Determines whether a provider with the given key is available.
+ *
+ * @param string $providerName the provider key (snake_case name derived from the provider directory)
+ *
+ * @return bool `true` if the provider is available, `false` otherwise
+ */
+ public static function isSupported(string $providerName): bool
+ {
+ return array_key_exists($providerName, self::discoverProviders());
+ }
+
+ /**
+ * Reset the internal provider discovery cache.
+ *
+ * Clears the cached mapping of provider keys to class names so providers will be rediscovered on next access.
+ */
+ public static function clearCache(): void
+ {
+ self::$providers = null;
+ }
+
+ /**
+ * Discovers available provider classes in the Providers directory and caches the result.
+ *
+ * Scans subdirectories under this class's directory for concrete classes that implement ProviderInterface
+ * and registers each provider using the provider directory name converted to snake_case as the key.
+ *
+ * @return array mapping of provider key to fully-qualified provider class name
+ */
+ protected static function discoverProviders(): array
+ {
+ if (self::$providers !== null) {
+ return self::$providers;
+ }
+
+ self::$providers = [];
+
+ $basePath = __DIR__;
+ $baseNamespace = 'Modules\\Invoices\\Peppol\\Providers\\';
+
+ // Get all subdirectories (each provider has its own directory)
+ $directories = glob($basePath . '/*', GLOB_ONLYDIR) ?: [];
+
+ foreach ($directories as $directory) {
+ $providerDir = basename($directory);
+
+ // Look for a Provider class in this directory
+ $providerFiles = glob($directory . '/*Provider.php') ?: [];
+
+ foreach ($providerFiles as $file) {
+ $className = basename($file, '.php');
+ $fullClassName = $baseNamespace . $providerDir . '\\' . $className;
+
+ // Check if class exists and implements ProviderInterface
+ if (class_exists($fullClassName)) {
+ $reflection = new ReflectionClass($fullClassName);
+ if ($reflection->implementsInterface(ProviderInterface::class) && ! $reflection->isAbstract()) {
+ // Convert directory name to snake_case key
+ $key = Str::snake($providerDir);
+ self::$providers[$key] = $fullClassName;
+ }
+ }
+ }
+ }
+
+ return self::$providers;
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/Qonto/QontoProvider.php b/Modules/Invoices/Peppol/Providers/Qonto/QontoProvider.php
new file mode 100644
index 000000000..8eac5815a
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/Qonto/QontoProvider.php
@@ -0,0 +1,135 @@
+getAccessToken() ?? '';
+ $baseUrl = $this->getDefaultBaseUrl();
+
+ $this->clientInvoicesClient = $clientInvoicesClient ?? new ClientInvoicesClient($httpClient, $apiKey, $baseUrl);
+ $this->supplierInvoicesClient = $supplierInvoicesClient ?? new SupplierInvoicesClient($httpClient, $apiKey, $baseUrl);
+ }
+
+ /**
+ * Get the declarative settings schema for Qonto.
+ *
+ * Delegates to the client's static method for a single source of truth.
+ *
+ * @return array list of config keys
+ */
+ public static function settings(): array
+ {
+ return QontoClient::settings();
+ }
+
+ public function getProviderName(): string
+ {
+ return 'qonto';
+ }
+
+ public function testConnection(array $config): array
+ {
+ return ['ok' => true, 'message' => 'Qonto connection configured'];
+ }
+
+ public function validatePeppolId(string $scheme, string $id): array
+ {
+ return ['present' => true, 'details' => ['scheme' => $scheme, 'identifier' => $id]];
+ }
+
+ public function sendInvoice(array $transmissionData): array
+ {
+ try {
+ $invoice = $transmissionData['invoice'] ?? null;
+ if ( ! $invoice) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => 0, 'message' => 'Missing invoice', 'response' => null];
+ }
+
+ $html = app(InvoiceService::class)->renderHtml($invoice);
+ $pdfBinary = PDFFactory::create()->getOutput($html);
+
+ $response = $this->clientInvoicesClient->import($pdfBinary);
+ if ( ! $response->successful()) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => $response->status(), 'message' => 'Qonto import failed', 'response' => $response->json()];
+ }
+
+ $invoiceId = $response->json('id');
+ $sendResponse = $this->clientInvoicesClient->sendByEinvoice($invoiceId);
+
+ return [
+ 'accepted' => $sendResponse->successful(),
+ 'external_id' => $invoiceId,
+ 'status_code' => $sendResponse->status(),
+ 'message' => 'Invoice submitted to Qonto',
+ 'response' => $sendResponse->json(),
+ ];
+ } catch (Throwable $e) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => 0, 'message' => 'Qonto error: ' . $e->getMessage(), 'response' => null];
+ }
+ }
+
+ public function getTransmissionStatus(string $externalId): array
+ {
+ try {
+ $response = $this->clientInvoicesClient->getStatus($externalId);
+
+ return ['status' => $response->json('status', 'unknown'), 'ack_payload' => $response->json()];
+ } catch (Throwable $e) {
+ return ['status' => 'error', 'ack_payload' => ['error' => $e->getMessage()]];
+ }
+ }
+
+ public function fetchAcknowledgements(?\Carbon\Carbon $since = null): array
+ {
+ try {
+ $filters = $since ? ['since' => $since->toIso8601String()] : [];
+ $response = $this->supplierInvoicesClient->list($filters);
+
+ return $response->json('invoices', []);
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ public function cancelDocument(string $externalId): array
+ {
+ return ['success' => false, 'message' => 'Qonto does not support document cancellation'];
+ }
+
+ public function getAccessToken(): ?string
+ {
+ return $this->config['access_token'] ?? null;
+ }
+
+ public function getStagingToken(): ?string
+ {
+ return $this->config['staging_token'] ?? null;
+ }
+
+ protected function getDefaultBaseUrl(): string
+ {
+ return 'https://thirdparty.qonto.com/api';
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php b/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php
new file mode 100644
index 000000000..279c4043b
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php
@@ -0,0 +1,231 @@
+documentSubmissionsClient = $documentSubmissionsClient;
+ } else {
+ $this->documentSubmissionsClient = new DocumentSubmissionsClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getApiKey() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ if ($receivedDocumentsClient) {
+ $this->receivedDocumentsClient = $receivedDocumentsClient;
+ } else {
+ $this->receivedDocumentsClient = new ReceivedDocumentsClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getApiKey() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+ }
+
+ /**
+ * Get the declarative settings schema for Storecove.
+ *
+ * Delegates to the client's static method for a single source of truth.
+ *
+ * @return array list of config keys
+ */
+ public static function settings(): array
+ {
+ return StorecoveClient::settings();
+ }
+
+ public function getProviderName(): string
+ {
+ return 'storecove';
+ }
+
+ public function testConnection(array $config): array
+ {
+ try {
+ // Call a real endpoint with a non-existent GUID
+ // 404 means API key was accepted (endpoint reachable but resource not found)
+ // 401/403 means authentication failed
+ // Any 2xx means success
+ $response = $this->documentSubmissionsClient->getEvidence('connectivity-check', 'sending');
+
+ if ($response->status() === 404) {
+ // 404 on a valid endpoint = API key worked, just invalid GUID (expected)
+ return [
+ 'ok' => true,
+ 'message' => 'Connection successful',
+ ];
+ }
+
+ if ($response->status() === 401 || $response->status() === 403) {
+ return [
+ 'ok' => false,
+ 'message' => 'Invalid API key or unauthorized',
+ ];
+ }
+
+ if ($response->successful()) {
+ return [
+ 'ok' => true,
+ 'message' => 'Connection successful',
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'message' => 'Connection failed: ' . ($response->body() ?: 'HTTP ' . $response->status()),
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'ok' => false,
+ 'message' => 'Connection failed: ' . $e->getMessage(),
+ ];
+ }
+ }
+
+ public function validatePeppolId(string $scheme, string $id): array
+ {
+ return [
+ 'present' => true,
+ 'details' => [
+ 'scheme' => $scheme,
+ 'identifier' => $id,
+ 'note' => 'Storecove accepts all valid Peppol identifiers',
+ ],
+ ];
+ }
+
+ public function sendInvoice(array $transmissionData): array
+ {
+ try {
+ $xml = $transmissionData['xml'] ?? '';
+ $recipientScheme = $transmissionData['recipient_scheme'] ?? '';
+ $recipientId = $transmissionData['recipient_id'] ?? '';
+ $legalEntityId = $this->getLegalEntityId();
+
+ $payload = [
+ 'legalEntityId' => (int) $legalEntityId,
+ 'document' => [
+ 'rawDocumentData' => [
+ 'document' => base64_encode($xml),
+ 'documentType' => 'invoice',
+ 'parseStrategy' => 'ubl',
+ ],
+ ],
+ 'routing' => [
+ 'eIdentifiers' => [
+ [
+ 'scheme' => $recipientScheme,
+ 'id' => $recipientId,
+ ],
+ ],
+ ],
+ ];
+
+ $response = $this->documentSubmissionsClient->submitDocument($payload);
+
+ if ( ! $response->successful()) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => $response->status(),
+ 'message' => 'Storecove rejected submission',
+ 'response' => $response->json(),
+ ];
+ }
+
+ $guid = $response->json('entity.guid');
+
+ return [
+ 'accepted' => true,
+ 'external_id' => $guid,
+ 'status_code' => $response->status(),
+ 'message' => 'Document submitted to Storecove',
+ 'response' => $response->json(),
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'accepted' => false,
+ 'external_id' => null,
+ 'status_code' => 0,
+ 'message' => 'Storecove submission error: ' . $e->getMessage(),
+ 'response' => null,
+ ];
+ }
+ }
+
+ public function getTransmissionStatus(string $externalId): array
+ {
+ try {
+ $response = $this->documentSubmissionsClient->getEvidence($externalId, 'sending');
+
+ if ( ! $response->successful()) {
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => 'Failed to retrieve transmission status'],
+ ];
+ }
+
+ $status = $response->json('status', 'unknown');
+
+ return [
+ 'status' => $status,
+ 'ack_payload' => $response->json(),
+ ];
+ } catch (Throwable $e) {
+ return [
+ 'status' => 'error',
+ 'ack_payload' => ['error' => $e->getMessage()],
+ ];
+ }
+ }
+
+ public function cancelDocument(string $externalId): array
+ {
+ return [
+ 'success' => false,
+ 'message' => 'Storecove does not support document cancellation',
+ ];
+ }
+
+ public function getApiKey(): ?string
+ {
+ return $this->config['api_key'] ?? null;
+ }
+
+ public function getLegalEntityId(): ?string
+ {
+ return $this->config['legal_entity_id'] ?? null;
+ }
+
+ protected function getDefaultBaseUrl(): string
+ {
+ return 'https://api.storecove.com/api/v2';
+ }
+}
diff --git a/Modules/Invoices/Peppol/Providers/SuperPdp/SuperPdpProvider.php b/Modules/Invoices/Peppol/Providers/SuperPdp/SuperPdpProvider.php
new file mode 100644
index 000000000..b6a6a4939
--- /dev/null
+++ b/Modules/Invoices/Peppol/Providers/SuperPdp/SuperPdpProvider.php
@@ -0,0 +1,172 @@
+invoicesClient = $invoicesClient ?? new InvoicesClient(
+ app(\Modules\Invoices\Http\Contracts\HttpClientInterface::class),
+ $this->getAccessToken() ?? '',
+ $this->getDefaultBaseUrl()
+ );
+ }
+
+ /**
+ * Get the declarative settings schema for SuperPDP OAuth2.
+ *
+ * Delegates to the client's static method for a single source of truth.
+ *
+ * @return array list of config keys
+ */
+ public static function settings(): array
+ {
+ return SuperPdpClient::settings();
+ }
+
+ public static function managedSettingsKeys(): array
+ {
+ return ['access_token'];
+ }
+
+ public function getProviderName(): string
+ {
+ return 'super_pdp';
+ }
+
+ public function testConnection(array $config): array
+ {
+ if ($this->ensureAuthenticated()) {
+ return ['ok' => true, 'message' => 'SuperPDP authentication succeeded'];
+ }
+
+ return ['ok' => false, 'message' => 'SuperPDP authentication failed — check client_id/client_secret'];
+ }
+
+ public function validatePeppolId(string $scheme, string $id): array
+ {
+ return ['present' => true, 'details' => ['scheme' => $scheme, 'identifier' => $id]];
+ }
+
+ public function sendInvoice(array $transmissionData): array
+ {
+ if ( ! $this->ensureAuthenticated()) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => 401, 'message' => 'SuperPDP authentication failed', 'response' => null];
+ }
+
+ try {
+ $invoice = $transmissionData['invoice'] ?? null;
+ if ( ! $invoice) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => 0, 'message' => 'Missing invoice', 'response' => null];
+ }
+
+ $html = app(InvoiceService::class)->renderHtml($invoice);
+ $pdfBinary = PDFFactory::create()->getOutput($html);
+
+ $response = $this->invoicesClient->sendInvoice($pdfBinary, [
+ 'recipient' => $transmissionData['recipient_id'] ?? '',
+ 'scheme' => $transmissionData['recipient_scheme'] ?? '',
+ ]);
+
+ if ( ! $response->successful()) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => $response->status(), 'message' => 'SuperPDP rejected submission', 'response' => $response->json()];
+ }
+
+ return ['accepted' => true, 'external_id' => $response->json('externalId'), 'status_code' => $response->status(), 'message' => 'PDF submitted to SuperPDP', 'response' => $response->json()];
+ } catch (Throwable $e) {
+ return ['accepted' => false, 'external_id' => null, 'status_code' => 0, 'message' => 'SuperPDP error: ' . $e->getMessage(), 'response' => null];
+ }
+ }
+
+ public function getTransmissionStatus(string $externalId): array
+ {
+ if ( ! $this->ensureAuthenticated()) {
+ return ['status' => 'error', 'ack_payload' => ['error' => 'SuperPDP authentication failed']];
+ }
+
+ try {
+ $response = $this->invoicesClient->getInvoiceStatus($externalId);
+
+ return ['status' => $response->json('status', 'unknown'), 'ack_payload' => $response->json()];
+ } catch (Throwable $e) {
+ return ['status' => 'error', 'ack_payload' => ['error' => $e->getMessage()]];
+ }
+ }
+
+ public function cancelDocument(string $externalId): array
+ {
+ return ['success' => false, 'message' => 'SuperPDP does not support document cancellation'];
+ }
+
+ public function getClientId(): ?string
+ {
+ return $this->config['client_id'] ?? null;
+ }
+
+ public function getClientSecret(): ?string
+ {
+ return $this->config['client_secret'] ?? null;
+ }
+
+ public function getAccessToken(): ?string
+ {
+ return $this->config['access_token'] ?? null;
+ }
+
+ /**
+ * Authenticate with SuperPDP using OAuth2 client-credentials flow.
+ *
+ * Ensures a valid access token exists, fetching and persisting a new one if needed.
+ * Tokens are stored in merchant_clients with expiry tracking for automatic refresh.
+ *
+ * @return bool True if authentication succeeded and token is valid
+ */
+ public function authenticate(): bool
+ {
+ return $this->ensureAuthenticated();
+ }
+
+ protected function getDefaultBaseUrl(): string
+ {
+ return 'https://api.superpdp.com/v1';
+ }
+
+ /**
+ * Get the OAuth2 client class for SuperPDP.
+ *
+ * @return string the full class name
+ */
+ protected function getOAuth2ClientClass(): ?string
+ {
+ return SuperPdpClient::class;
+ }
+
+ /**
+ * Propagate the access token to all resource clients.
+ *
+ * Called after token refresh to ensure all clients use the new token.
+ *
+ * @param string $token the new access token
+ */
+ protected function propagateAccessToken(string $token): void
+ {
+ $this->invoicesClient->setAccessToken($token);
+ }
+}
diff --git a/Modules/Invoices/Peppol/README.md b/Modules/Invoices/Peppol/README.md
new file mode 100644
index 000000000..326290c9e
--- /dev/null
+++ b/Modules/Invoices/Peppol/README.md
@@ -0,0 +1,631 @@
+# Peppol Integration Documentation
+
+## Overview
+
+This Peppol integration allows InvoicePlane v2 to send invoices electronically through the Peppol network. The
+implementation follows a modular architecture with clean separation of concerns, comprehensive error handling, and
+extensive test coverage.
+
+## Architecture
+
+### Components
+
+1. **HTTP Client Layer**
+
+- HTTP client: Laravel's Http facade wrapper
+- Comprehensive exception handling and logging for all API requests
+
+2. **Peppol Provider Layer**
+
+- `BasePeppolClient`: Abstract base class for all Peppol providers
+- `EInvoiceBeClient`: Concrete implementation for e-invoice.be provider
+- `DocumentsClient`: Specific client for document operations
+
+3. **Service Layer**
+
+- `PeppolService`: Business logic for Peppol operations
+- Handles invoice validation, data preparation, and transmission
+
+4. **Action Layer**
+
+- `SendInvoiceToPeppolAction`: Orchestrates invoice sending process
+- Can be called from UI actions or programmatically
+
+5. **UI Integration**
+
+- Header action in `EditInvoice` page
+- Table action in `ListInvoices` page
+- Modal form for entering customer Peppol ID
+
+## Installation & Configuration
+
+### 1. Environment Variables
+
+Add the following to your `.env` file:
+
+```env
+# Peppol Provider Configuration
+PEPPOL_PROVIDER=e_invoice_be
+PEPPOL_E_INVOICE_BE_API_KEY=your-api-key-here
+PEPPOL_E_INVOICE_BE_BASE_URL=https://api.e-invoice.be
+
+# Optional Peppol Settings
+PEPPOL_CURRENCY_CODE=EUR
+```
+
+### 2. Configuration File
+
+The configuration is located at `Modules/Invoices/Config/config.php` and contains:
+
+- Provider settings
+- Document format defaults
+- Validation rules
+
+### 3. Service Registration
+
+All Peppol services are automatically registered in `InvoicesServiceProvider`. The provider:
+
+- Binds HTTP clients with dependency injection
+- Configures exception handler with logging (non-production only)
+- Registers Peppol clients and services
+
+## Usage
+
+### From UI (Filament Actions)
+
+#### Edit Invoice Page
+
+1. Navigate to an invoice edit page
+2. Click the "Send to Peppol" button in the header
+3. Enter the customer's Peppol ID (e.g., `BE:0123456789`)
+4. Click submit
+
+#### Invoices List Page
+
+1. Navigate to the invoices list
+2. Click the action menu on an invoice row
+3. Select "Send to Peppol"
+4. Enter the customer's Peppol ID
+5. Click submit
+
+### Programmatically
+
+```php
+use Modules\Invoices\Actions\SendInvoiceToPeppolAction;
+use Modules\Invoices\Models\Invoice;
+
+$invoice = Invoice::query()->find($invoiceId);
+$action = app(SendInvoiceToPeppolAction::class);
+
+try {
+ $result = $action->execute($invoice, [
+ 'customer_peppol_id' => 'BE:0123456789',
+ ]);
+
+ // Success! Document ID is available
+ $documentId = $result['document_id'];
+ $status = $result['status'];
+
+} catch (\InvalidArgumentException $e) {
+ // Validation error
+ Log::error('Invalid invoice data: ' . $e->getMessage());
+
+} catch (\Illuminate\Http\Client\RequestException $e) {
+ // API request failed
+ Log::error('Peppol API error: ' . $e->getMessage());
+}
+```
+
+### Check Document Status
+
+```php
+$action = app(SendInvoiceToPeppolAction::class);
+$status = $action->getStatus('DOC-123456');
+
+// Returns:
+// [
+// 'status' => 'delivered',
+// 'delivered_at' => '2024-01-15T12:30:00Z',
+// ...
+// ]
+```
+
+### Cancel Document
+
+```php
+$action = app(SendInvoiceToPeppolAction::class);
+$success = $action->cancel('DOC-123456');
+```
+
+## Data Mapping
+
+### Invoice to Peppol Document
+
+The `PeppolService` transforms InvoicePlane invoices to Peppol UBL format:
+
+```php
+[
+ 'document_type' => 'invoice',
+ 'invoice_number' => 'INV-2024-001',
+ 'issue_date' => '2024-01-15',
+ 'due_date' => '2024-02-14',
+ 'currency_code' => 'EUR',
+
+ 'supplier' => [
+ 'name' => 'Company Name',
+ // Additional supplier details
+ ],
+
+ 'customer' => [
+ 'name' => 'Customer Name',
+ 'endpoint_id' => 'BE:0123456789',
+ 'endpoint_scheme' => 'BE:CBE',
+ ],
+
+ 'invoice_lines' => [
+ [
+ 'id' => 1,
+ 'quantity' => 2,
+ 'unit_code' => 'C62',
+ 'line_extension_amount' => 200.00,
+ 'price_amount' => 100.00,
+ 'item' => [
+ 'name' => 'Product Name',
+ 'description' => 'Product description',
+ ],
+ ],
+ ],
+
+ 'legal_monetary_total' => [
+ 'line_extension_amount' => 200.00,
+ 'tax_exclusive_amount' => 200.00,
+ 'tax_inclusive_amount' => 242.00,
+ 'payable_amount' => 242.00,
+ ],
+
+ 'tax_total' => [
+ 'tax_amount' => 42.00,
+ ],
+]
+```
+
+## Validation
+
+Before sending to Peppol, invoices are validated:
+
+- Must have a customer
+- Must have an invoice number
+- Must have at least one invoice item
+- Cannot be in draft status
+- Customer Peppol ID must be provided
+
+## Error Handling
+
+### Common Errors
+
+| Error Code | Description | Solution |
+|------------|------------------|----------------------------|
+| 400 | Bad Request | Check invoice data format |
+| 401 | Unauthorized | Verify API key is correct |
+| 422 | Validation Error | Review Peppol requirements |
+| 429 | Rate Limit | Wait and retry |
+| 500 | Server Error | Contact Peppol provider |
+
+### Exception Types
+
+- `InvalidArgumentException`: Invoice validation failed
+- `RequestException`: HTTP request failed (4xx, 5xx)
+- `ConnectionException`: Network/timeout issues
+
+All exceptions are logged automatically when using the `HttpClientExceptionHandler`.
+
+## Testing
+
+### Running Tests
+
+```bash
+# Run all Peppol tests
+php artisan test Modules/Invoices/Tests/Unit/Peppol
+
+# Run specific test suite
+php artisan test Modules/Invoices/Tests/Unit/Peppol/Services/PeppolServiceTest
+
+# Run with coverage
+php artisan test --coverage
+```
+
+### Test Structure
+
+Tests use Laravel's HTTP fakes instead of mocks:
+
+```php
+use Illuminate\Support\Facades\Http;
+
+Http::fake([
+ 'https://api.e-invoice.be/*' => Http::response([
+ 'document_id' => 'DOC-123',
+ 'status' => 'submitted',
+ ], 200),
+]);
+
+// Your test code here
+
+Http::assertSent(function ($request) {
+ return $request->url() === 'https://api.e-invoice.be/api/documents';
+});
+```
+
+### Test Coverage
+
+- `ExternalClientTest`: 15 tests (HTTP wrapper)
+- `HttpClientExceptionHandlerTest`: Not yet implemented
+- `DocumentsClientTest`: 12 tests (API client)
+- `PeppolServiceTest`: 11 tests (Business logic)
+- `SendInvoiceToPeppolActionTest`: 11 tests (Action)
+
+Total: **49 unit tests** covering success and failure scenarios
+
+## Adding New Peppol Providers
+
+To add support for another Peppol provider (e.g., Storecove):
+
+1. Create provider client:
+
+```php
+namespace Modules\Invoices\Peppol\Clients\Storecove;
+
+class StorecoveClient extends BasePeppolClient
+{
+ protected function getAuthenticationHeaders(): array
+ {
+ return [
+ 'Authorization' => 'Bearer ' . $this->apiKey,
+ 'Content-Type' => 'application/json',
+ ];
+ }
+}
+```
+
+2. Create endpoint clients extending the provider client:
+
+```php
+class StorecoveDocumentsClient extends StorecoveClient
+{
+ public function submitDocument(array $data): Response
+ {
+ return $this->client->post('documents', $data);
+ }
+}
+```
+
+3. Register in `InvoicesServiceProvider`:
+
+```php
+$this->app->bind(
+ StorecoveDocumentsClient::class,
+ function ($app) {
+ $handler = $app->make(HttpClientExceptionHandler::class);
+ return new StorecoveDocumentsClient(
+ $handler,
+ config('invoices.peppol.storecove.api_key'),
+ config('invoices.peppol.storecove.base_url')
+ );
+ }
+);
+```
+
+4. Update configuration in `config.php`:
+
+```php
+'storecove' => [
+ 'api_key' => env('PEPPOL_STORECOVE_API_KEY', ''),
+ 'base_url' => env('PEPPOL_STORECOVE_BASE_URL', 'https://api.storecove.com'),
+],
+```
+
+## API Documentation
+
+### e-invoice.be API
+
+Full API documentation: https://api.e-invoice.be/docs
+
+Key endpoints used:
+
+- `POST /api/documents` - Submit a document
+- `GET /api/documents/{id}` - Get document details
+- `GET /api/documents/{id}/status` - Get document status
+- `DELETE /api/documents/{id}` - Cancel document
+
+## Translations
+
+Translation keys available in `resources/lang/en/ip.php`:
+
+- `send_to_peppol`: "Send to Peppol"
+- `customer_peppol_id`: "Customer Peppol ID"
+- `customer_peppol_id_helper`: "The Peppol participant identifier..."
+- `peppol_success_title`: "Sent to Peppol"
+- `peppol_success_body`: "Invoice successfully sent..."
+- `peppol_error_title`: "Peppol Transmission Failed"
+- `peppol_error_body`: "Failed to send invoice..."
+
+## Logging
+
+All HTTP requests and responses are logged in non-production environments:
+
+```
+[2024-01-15 10:30:00] local.INFO: HTTP Request
+[2024-01-15 10:30:01] local.INFO: HTTP Response
+[2024-01-15 10:30:01] local.INFO: Sending invoice to Peppol {"invoice_id":123}
+[2024-01-15 10:30:02] local.INFO: Invoice sent to Peppol successfully {"document_id":"DOC-123"}
+```
+
+## Security Considerations
+
+1. **API Keys**: Store in `.env`, never commit to version control
+2. **Sensitive Data**: Automatically redacted in logs
+3. **HTTPS**: All Peppol communication uses HTTPS
+4. **Validation**: Invoice data validated before transmission
+5. **Error Messages**: User-facing messages don't expose sensitive details
+
+## Troubleshooting
+
+### API Key Issues
+
+```bash
+# Check if API key is set
+php artisan tinker
+>>> config('invoices.peppol.e_invoice_be.api_key')
+```
+
+### Connection Timeouts
+
+Increase timeout in provider client:
+
+```php
+protected function getTimeout(): int
+{
+ return 120; // 2 minutes
+}
+```
+
+### Debug Mode
+
+Enable request logging:
+
+```php
+$handler = app(HttpClientExceptionHandler::class);
+$handler->enableLogging();
+```
+
+## Supported Invoice Formats
+
+InvoicePlane v2 supports 11 different e-invoice formats to comply with various national and regional requirements:
+
+### Pan-European Standards
+
+#### PEPPOL BIS Billing 3.0
+
+- **Format**: UBL 2.1 based
+- **Regions**: All European countries
+- **Handler**: `PeppolBisHandler`
+- **Profile**: `urn:fdc:peppol.eu:2017:poacc:billing:01:1.0`
+- **Use case**: Default format for cross-border invoicing in Europe
+- **Status**: Fully implemented
+
+#### UBL 2.1 / 2.4
+
+- **Format**: OASIS Universal Business Language
+- **Regions**: Worldwide
+- **Handler**: `UblHandler`
+- **Standards**: [OASIS UBL](http://docs.oasis-open.org/ubl/)
+- **Use case**: General-purpose e-invoicing
+- **Status**: Fully implemented
+
+#### CII (Cross Industry Invoice)
+
+- **Format**: UN/CEFACT XML
+- **Regions**: Germany, France, Austria
+- **Handler**: `CiiHandler`
+- **Standard**: UN/CEFACT D16B
+- **Use case**: Alternative to UBL, common in Central Europe
+- **Status**: Fully implemented
+
+### Country-Specific Formats
+
+#### FatturaPA 1.2 (Italy)
+
+- **Format**: XML
+- **Mandatory**: Yes, for all B2B and B2G invoices in Italy
+- **Handler**: `FatturaPaHandler`
+- **Authority**: Agenzia delle Entrate
+- **Requirements**:
+- Supplier: Italian VAT number (Partita IVA)
+- Customer: Tax code (Codice Fiscale) for Italian customers
+- Transmission: Via SDI (Sistema di Interscambio)
+- **Features**:
+- Fiscal regime codes
+- Payment conditions
+- Tax summary by rate
+- **Status**: Fully implemented
+
+#### Facturae 3.2 (Spain)
+
+- **Format**: XML
+- **Mandatory**: Yes, for invoices to Spanish public administration
+- **Handler**: `FacturaeHandler`
+- **Authority**: Ministry of Finance and Public Administration
+- **Requirements**:
+- Supplier: Spanish tax ID (NIF/CIF)
+- Format includes: File header, parties, invoices
+- Support for both resident and overseas addresses
+- **Features**:
+- Series codes for invoice numbering
+- Administrative centres
+- IVA (Spanish VAT) handling
+- **Status**: Fully implemented
+
+#### Factur-X 1.0 (France/Germany)
+
+- **Format**: PDF/A-3 with embedded CII XML
+- **Regions**: France, Germany
+- **Handler**: `FacturXHandler`
+- **Standards**: Hybrid of PDF and XML
+- **Requirements**:
+- Supplier: VAT number
+- PDF must be PDF/A-3 compliant
+- XML embedded as attachment
+- **Features**:
+- Human-readable PDF
+- Machine-readable XML
+- Compatible with ZUGFeRD 2.0
+- **Profiles**: MINIMUM, BASIC, EN16931, EXTENDED
+- **Status**: Fully implemented
+
+#### ZUGFeRD 1.0 / 2.0 (Germany)
+
+- **Format**: PDF/A-3 with embedded XML (1.0) or CII XML (2.0)
+- **Regions**: Germany
+- **Handler**: `ZugferdHandler`
+- **Authority**: FeRD (Forum elektronische Rechnung Deutschland)
+- **Requirements**:
+- Supplier: German VAT number
+- SEPA payment means support
+- German-specific tax handling
+- **Versions**:
+- **1.0**: Original ZUGFeRD format
+- **2.0**: Compatible with Factur-X, uses EN 16931
+- **Features**:
+- Multiple profiles (Comfort, Basic, Extended)
+- SEPA credit transfer codes
+- German VAT rate (19% standard)
+- **Status**: Fully implemented (both versions)
+
+#### OIOUBL (Denmark)
+
+- **Format**: UBL 2.0 with Danish extensions
+- **Mandatory**: Yes, for public procurement
+- **Handler**: `OioublHandler`
+- **Authority**: Digitaliseringsstyrelsen
+- **Requirements**:
+- Supplier: CVR number (Danish business registration)
+- Customer: Peppol ID (CVR for Danish entities)
+- Accounting cost codes
+- **Features**:
+- Danish-specific party identification
+- Payment means with bank details
+- Settlement periods
+- Danish VAT (25% standard)
+- **Profile**: `Procurement-OrdSim-BilSim-1.0`
+- **Status**: Fully implemented
+
+#### EHF 3.0 (Norway)
+
+- **Format**: UBL 2.1 with Norwegian extensions
+- **Mandatory**: Yes, for public procurement
+- **Handler**: `EhfHandler`
+- **Authority**: Difi (Agency for Public Management and eGovernment)
+- **Requirements**:
+- Supplier: Norwegian organization number (ORGNR)
+- Customer: Organization number or Peppol ID
+- Buyer reference for routing
+- **Features**:
+- Norwegian organization numbers (9 digits)
+- Delivery information
+- Norwegian payment terms
+- Norwegian VAT (25% standard)
+- **Profile**: PEPPOL BIS 3.0 compliant
+- **Status**: Fully implemented
+
+### Format Selection
+
+The system automatically selects the appropriate format based on:
+
+1. **Customer's Country**: Each country has recommended and mandatory formats
+2. **Customer's Preferred Format**: Stored in customer profile (`peppol_format` field)
+3. **Regulatory Requirements**: Mandatory formats take precedence
+4. **Fallback**: Defaults to PEPPOL BIS 3.0 for maximum compatibility
+
+#### Format Recommendations by Country
+
+```php
+'ES' => Facturae 3.2 // Spain
+'IT' => FatturaPA 1.2 // Italy (mandatory)
+'FR' => Factur-X 1.0 // France
+'DE' => ZUGFeRD 2.0 // Germany
+'AT' => CII // Austria
+'DK' => OIOUBL // Denmark
+'NO' => EHF // Norway
+'*' => PEPPOL BIS 3.0 // Default for all other countries
+```
+
+### Endpoint Schemes by Country
+
+Each country uses specific identifier schemes for Peppol participants:
+
+| Country | Scheme | Format | Example |
+|---------------|-----------|-----------------------------|-----------------|
+| Belgium | BE:CBE | 10 digits | 0123456789 |
+| Germany | DE:VAT | DE + 9 digits | DE123456789 |
+| France | FR:SIRENE | 9 or 14 digits | 123456789 |
+| Italy | IT:VAT | IT + 11 digits | IT12345678901 |
+| Spain | ES:VAT | Letter + 7-8 digits + check | A12345678 |
+| Netherlands | NL:KVK | 8 digits | 12345678 |
+| Norway | NO:ORGNR | 9 digits | 123456789 |
+| Denmark | DK:CVR | 8 digits | 12345678 |
+| Sweden | SE:ORGNR | 10 digits | 123456-7890 |
+| Finland | FI:OVT | 7 digits + check | 1234567-8 |
+| Austria | AT:VAT | ATU + 8 digits | ATU12345678 |
+| Switzerland | CH:UIDB | CHE + 9 digits | CHE-123.456.789 |
+| UK | GB:COH | 8 characters | 12345678 |
+| International | GLN | 13 digits | 1234567890123 |
+| International | DUNS | 9 digits | 123456789 |
+
+## Testing Format Handlers
+
+All format handlers have comprehensive test coverage:
+
+```bash
+# Run all Peppol tests
+php artisan test --group=peppol
+
+# Run specific handler tests
+php artisan test Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FatturaPaHandlerTest
+```
+
+### Test Coverage
+
+- **PeppolEndpointSchemeTest**: 240+ assertions covering all 17 endpoint schemes
+- **FatturaPaHandlerTest**: Italian FatturaPA format validation and transformation
+- **FormatHandlersTest**: Comprehensive tests for all 5 new handlers (Facturae, Factur-X, ZUGFeRD, OIOUBL, EHF)
+- **PeppolDocumentFormatTest**: Format enum validation and country recommendations
+
+Total test count: **90+ unit tests** covering all formats and handlers
+
+## Future Enhancements
+
+- [ ] Store Peppol document IDs in invoice table
+- [ ] Add webhook support for delivery notifications
+- [ ] Implement automatic retry logic
+- [ ] Add support for credit notes in all formats
+- [ ] Bulk sending of invoices
+- [ ] Dashboard widget for transmission status
+- [ ] Support for multiple Peppol providers
+- [ ] PDF attachment support
+- [ ] Actual XML generation (currently returns JSON placeholders)
+- [ ] PDF/A-3 generation for ZUGFeRD and Factur-X
+- [ ] Digital signature support for Italian FatturaPA
+- [ ] QR code generation for invoices (required in some countries)
+
+## Contributing
+
+When adding features:
+
+1. Write tests first (TDD approach)
+2. Use fakes over mocks
+3. Include both success and failure test cases
+4. Update documentation
+5. Follow existing code style and patterns
+
+## License
+
+Same as InvoicePlane v2 - MIT License
diff --git a/Modules/Invoices/Peppol/Services/PeppolManagementService.php b/Modules/Invoices/Peppol/Services/PeppolManagementService.php
new file mode 100644
index 000000000..162c6c50f
--- /dev/null
+++ b/Modules/Invoices/Peppol/Services/PeppolManagementService.php
@@ -0,0 +1,322 @@
+company_id = $companyId;
+ $integration->provider_name = $providerName;
+ $integration->enabled = false; // Start disabled until tested
+ $integration->save();
+
+ // Set configuration using the key-value relationship (stores in merchant_clients)
+ $integration->setConfig($this->filterConfigForProvider($providerName, $config));
+
+ event(new PeppolIntegrationCreated($integration));
+
+ DB::commit();
+
+ return $integration;
+ } catch (Exception $e) {
+ DB::rollBack();
+ throw $e;
+ }
+ }
+
+ /**
+ * Update an existing Peppol integration's configuration and enabled status.
+ *
+ * @param PeppolIntegration $integration the integration to update
+ * @param array $config associative configuration values (replaces existing config)
+ * @param bool|null $enabled optional enabled status; if null, leaves unchanged
+ *
+ * @return PeppolIntegration the updated integration
+ */
+ public function updateIntegration(PeppolIntegration $integration, array $config, ?bool $enabled = null): PeppolIntegration
+ {
+ DB::beginTransaction();
+
+ try {
+ if ($enabled !== null) {
+ $integration->enabled = $enabled;
+ $integration->save();
+ }
+
+ // Update configuration (replaces existing merchant_client rows for this provider)
+ // First delete old entries for this provider
+ \Modules\Core\Models\MerchantClient::withoutGlobalScopes()
+ ->where('company_id', $integration->company_id)
+ ->where('driver', $integration->provider_name)
+ ->delete();
+
+ // Then insert new entries
+ $integration->setConfig($this->filterConfigForProvider($integration->provider_name, $config));
+
+ DB::commit();
+
+ return $integration;
+ } catch (Exception $e) {
+ DB::rollBack();
+ throw $e;
+ }
+ }
+
+ /**
+ * Test connectivity for the given Peppol integration and record the result.
+ *
+ * Updates the integration's test_connection_status, test_connection_message, and test_connection_at, saves the integration,
+ * and dispatches a PeppolIntegrationTested event reflecting success or failure.
+ *
+ * @param PeppolIntegration $integration the integration to test
+ *
+ * @return array An array containing:
+ * - `ok` (bool): `true` if the connection succeeded, `false` otherwise.
+ * - `message` (string): A human-readable result or error message.
+ */
+ public function testConnection(PeppolIntegration $integration): array
+ {
+ try {
+ $provider = ProviderFactory::make($integration);
+
+ $result = $provider->testConnection($integration->config);
+
+ // Update integration with test result
+ $integration->test_connection_status = $result['ok'] ? PeppolConnectionStatus::SUCCESS : PeppolConnectionStatus::FAILED;
+ $integration->test_connection_message = $result['message'];
+ $integration->test_connection_at = now();
+ $integration->save();
+
+ event(new PeppolIntegrationTested($integration, $result['ok'], $result['message']));
+
+ return $result;
+ } catch (Exception $e) {
+ $this->logPeppolError('Peppol connection test failed', [
+ 'integration_id' => $integration->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ $integration->test_connection_status = PeppolConnectionStatus::FAILED;
+ $integration->test_connection_message = 'Exception: ' . $e->getMessage();
+ $integration->test_connection_at = now();
+ $integration->save();
+
+ event(new PeppolIntegrationTested($integration, false, $e->getMessage()));
+
+ return [
+ 'ok' => false,
+ 'message' => 'Connection test failed: ' . $e->getMessage(),
+ ];
+ }
+ }
+
+ /**
+ * Validate a customer's Peppol identifier against the provider and record the validation history.
+ *
+ * Performs provider-based validation of the customer's Peppol scheme and ID, persists a
+ * CustomerPeppolValidationHistory record (including provider response when available), updates
+ * the customer's quick-lookup validation fields, emits a PeppolIdValidationCompleted event,
+ * and returns the validation outcome.
+ *
+ * @param Relation $customer the customer relation containing `peppol_scheme` and `peppol_id`
+ * @param PeppolIntegration $integration the Peppol integration used to perform validation
+ * @param int|null $validatedBy optional user ID who initiated the validation
+ *
+ * @return array{
+ * valid: bool,
+ * status: string,
+ * message: string|null,
+ * details: mixed|null
+ * } `valid` is `true` when the participant was found; `status` is the validation status value;
+ * `message` contains a human-readable validation message or error text; `details` contains
+ * optional provider response data when available
+ */
+ public function validatePeppolId(
+ Relation $customer,
+ PeppolIntegration $integration,
+ ?int $validatedBy = null
+ ): array {
+ try {
+ $provider = ProviderFactory::make($integration);
+
+ // Perform validation
+ $result = $provider->validatePeppolId(
+ $customer->peppol_scheme,
+ $customer->peppol_id
+ );
+
+ // Determine validation status
+ $validationStatus = $result['present']
+ ? PeppolValidationStatus::VALID
+ : PeppolValidationStatus::NOT_FOUND;
+
+ DB::beginTransaction();
+
+ // Save to history
+ $history = new CustomerPeppolValidationHistory();
+ $history->customer_id = $customer->id;
+ $history->integration_id = $integration->id;
+ $history->validated_by = $validatedBy;
+ $history->peppol_scheme = $customer->peppol_scheme;
+ $history->peppol_id = $customer->peppol_id;
+ $history->validation_status = $validationStatus;
+ $history->validation_message = $result['present'] ? 'Participant found in network' : 'Participant not found';
+ $history->save();
+
+ // Set provider response using the key-value relationship
+ if (isset($result['details'])) {
+ $history->setProviderResponse($result['details']);
+ }
+
+ // Update customer quick-lookup fields
+ $customer->peppol_validation_status = $validationStatus;
+ $customer->peppol_validation_message = $history->validation_message;
+ $customer->peppol_validated_at = now();
+ $customer->save();
+
+ event(new PeppolIdValidationCompleted($customer, $validationStatus->value, [
+ 'history_id' => $history->id,
+ 'present' => $result['present'],
+ ]));
+
+ DB::commit();
+
+ return [
+ 'valid' => $validationStatus === PeppolValidationStatus::VALID,
+ 'status' => $validationStatus->value,
+ 'message' => $history->validation_message,
+ 'details' => $result['details'],
+ ];
+ } catch (Exception $e) {
+ DB::rollBack();
+
+ $this->logPeppolError('Peppol ID validation failed', [
+ 'customer_id' => $customer->id,
+ 'peppol_id' => $customer->peppol_id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ // Save error to history
+ $errorHistory = new CustomerPeppolValidationHistory();
+ $errorHistory->customer_id = $customer->id;
+ $errorHistory->integration_id = $integration->id;
+ $errorHistory->validated_by = $validatedBy;
+ $errorHistory->peppol_scheme = $customer->peppol_scheme;
+ $errorHistory->peppol_id = $customer->peppol_id;
+ $errorHistory->validation_status = PeppolValidationStatus::ERROR;
+ $errorHistory->validation_message = 'Validation error: ' . $e->getMessage();
+ $errorHistory->save();
+
+ return [
+ 'valid' => false,
+ 'status' => PeppolValidationStatus::ERROR->value,
+ 'message' => $e->getMessage(),
+ 'details' => null,
+ ];
+ }
+ }
+
+ /**
+ * Queue an invoice to be sent to Peppol.
+ *
+ * @param Invoice $invoice the invoice to send
+ * @param PeppolIntegration $integration the Peppol integration to use for sending
+ * @param bool $force when true, force sending even if the invoice was previously sent or flagged
+ */
+ public function sendInvoice(Invoice $invoice, PeppolIntegration $integration, bool $force = false): void
+ {
+ // Queue the sending job
+ SendInvoiceToPeppolJob::dispatch($invoice, $integration, $force);
+
+ $this->logPeppolInfo('Queued invoice for Peppol sending', [
+ 'invoice_id' => $invoice->id,
+ 'integration_id' => $integration->id,
+ ]);
+ }
+
+ /**
+ * Retrieve the company's active Peppol integration that is enabled and has a successful connection test.
+ *
+ * @param int $companyId the company identifier
+ *
+ * @return PeppolIntegration|null the matching integration, or `null` if none exists
+ */
+ public function getActiveIntegration(int $companyId): ?PeppolIntegration
+ {
+ return PeppolIntegration::query()->where('company_id', $companyId)
+ ->where('enabled', true)
+ ->where('test_connection_status', PeppolConnectionStatus::SUCCESS)
+ ->first();
+ }
+
+ /**
+ * Suggests a Peppol identifier scheme for the given country code.
+ *
+ * @param string $countryCode the country code (ISO 3166-1 alpha-2)
+ *
+ * @return string|null the Peppol scheme mapped to the country, or `null` if no mapping exists
+ */
+ public function suggestPeppolScheme(string $countryCode): ?string
+ {
+ $countrySchemeMap = config('invoices.peppol.country_scheme_mapping', []);
+
+ return $countrySchemeMap[$countryCode] ?? null;
+ }
+
+ /**
+ * Restrict $config down to the provider's declared, human-entered credential keys —
+ * settings() minus managedSettingsKeys() — so that unrelated form fields (company_id,
+ * provider_name, enabled) passed through the same $data array from Filament never leak
+ * into the merchant_clients key-value store as bogus config entries.
+ */
+ private function filterConfigForProvider(string $providerName, array $config): array
+ {
+ $providerClass = ProviderFactory::getProviderClass($providerName);
+ $allowedKeys = array_diff($providerClass::settings(), $providerClass::managedSettingsKeys());
+
+ return array_intersect_key($config, array_flip($allowedKeys));
+ }
+}
diff --git a/Modules/Invoices/Peppol/Services/PeppolService.php b/Modules/Invoices/Peppol/Services/PeppolService.php
new file mode 100644
index 000000000..a55a5f85b
--- /dev/null
+++ b/Modules/Invoices/Peppol/Services/PeppolService.php
@@ -0,0 +1,282 @@
+documentsClient = $documentsClient;
+ }
+
+ /**
+ * Send an invoice to the Peppol network.
+ *
+ * This method takes an invoice, prepares it using the appropriate format handler,
+ * and sends it through the Peppol network via the configured provider.
+ *
+ * @param Invoice $invoice The invoice to send
+ * @param array $options Optional options for the transmission
+ *
+ * @return array Response data including document ID and status
+ *
+ * @throws RequestException If the Peppol API request fails
+ * @throws InvalidArgumentException If the invoice data is invalid
+ * @throws RuntimeException If no format handler is available
+ */
+ 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 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));
+ }
+
+ /* Transform invoice using the format handler */
+ $documentData = $formatHandler->transform($invoice, $options);
+
+ $this->logRequest('Peppol', 'POST /documents', [
+ 'invoice_id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'format' => $formatHandler->getFormat()->value,
+ 'customer_country' => $invoice->customer->country_code,
+ ]);
+
+ try {
+ $response = $this->documentsClient->submitDocument($documentData);
+ $responseData = $response->json();
+
+ $this->logResponse('Peppol', 'POST /documents', $response->status(), $responseData);
+
+ return [
+ 'success' => true,
+ 'document_id' => $responseData['document_id'] ?? null,
+ 'status' => $responseData['status'] ?? 'submitted',
+ 'format' => $formatHandler->getFormat()->value,
+ 'message' => 'Invoice successfully submitted to Peppol network',
+ 'response' => $responseData,
+ ];
+ } catch (RequestException $e) {
+ $this->logError('Request', 'POST', '/documents', $e->getMessage(), [
+ 'invoice_id' => $invoice->id,
+ 'format' => $formatHandler->getFormat()->value,
+ ]);
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Get the status of a Peppol document.
+ *
+ * Retrieves the current transmission status of a document in the Peppol network.
+ *
+ * @param string $documentId The Peppol document ID
+ *
+ * @return array Status information
+ *
+ * @throws RequestException If the API request fails
+ */
+ public function getDocumentStatus(string $documentId): array
+ {
+ $this->logRequest('Peppol', "GET /documents/{$documentId}/status", [
+ 'document_id' => $documentId,
+ ]);
+
+ try {
+ $response = $this->documentsClient->getDocumentStatus($documentId);
+ $responseData = $response->json();
+
+ $this->logResponse('Peppol', "GET /documents/{$documentId}/status", $response->status(), $responseData);
+
+ return $responseData;
+ } catch (RequestException $e) {
+ $this->logError('Request', 'GET', "/documents/{$documentId}/status", $e->getMessage(), [
+ 'document_id' => $documentId,
+ ]);
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Cancel a Peppol document transmission.
+ *
+ * Attempts to cancel a document that hasn't been delivered yet.
+ *
+ * @param string $documentId The Peppol document ID
+ *
+ * @return bool True if cancellation was successful
+ *
+ * @throws RequestException If the API request fails
+ */
+ public function cancelDocument(string $documentId): bool
+ {
+ $this->logRequest('Peppol', "DELETE /documents/{$documentId}", [
+ 'document_id' => $documentId,
+ ]);
+
+ try {
+ $response = $this->documentsClient->cancelDocument($documentId);
+ $success = $response->successful();
+
+ $this->logResponse('Peppol', "DELETE /documents/{$documentId}", $response->status(), [
+ 'success' => $success,
+ ]);
+
+ 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,
+ ]);
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Validate that an invoice is ready for Peppol transmission.
+ *
+ * @param Invoice $invoice The invoice to validate
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException If validation fails
+ */
+ protected function validateInvoice(Invoice $invoice): void
+ {
+ if ( ! $invoice->customer) {
+ throw new InvalidArgumentException('Invoice must have a customer');
+ }
+
+ if ( ! $invoice->invoice_number) {
+ throw new InvalidArgumentException('Invoice must have an invoice number');
+ }
+
+ if ($invoice->invoiceItems->isEmpty()) {
+ throw new InvalidArgumentException('Invoice must have at least one item');
+ }
+
+ // Add more validation as needed for Peppol requirements
+ }
+
+ /**
+ * Prepare invoice data for Peppol transmission.
+ *
+ * Converts the invoice model to the format required by the Peppol API.
+ *
+ * @param Invoice $invoice The invoice to prepare
+ * @param array $additionalData Optional additional data
+ *
+ * @return array Document data ready for API submission
+ */
+ protected function prepareDocumentData(Invoice $invoice, array $additionalData = []): array
+ {
+ $customer = $invoice->customer;
+
+ // Prepare document according to Peppol UBL format
+ // This is a simplified example - real implementation should follow UBL 2.1 standard
+ $documentData = [
+ 'document_type' => 'invoice',
+ 'invoice_number' => $invoice->invoice_number,
+ 'issue_date' => $invoice->invoiced_at->format('Y-m-d'),
+ 'due_date' => $invoice->invoice_due_at->format('Y-m-d'),
+ 'currency_code' => 'EUR', // Should be configurable
+
+ // Supplier (seller) information
+ 'supplier' => [
+ 'name' => config('app.name'),
+ // Add more supplier details from company settings
+ ],
+
+ // Customer (buyer) information
+ 'customer' => [
+ 'name' => $customer->company_name ?? $customer->customer_name,
+ 'endpoint_id' => $additionalData['customer_peppol_id'] ?? null,
+ 'endpoint_scheme' => 'BE:CBE', // Should be configurable based on country
+ ],
+
+ // Line items
+ 'invoice_lines' => $invoice->invoiceItems->map(function ($item) {
+ return [
+ 'id' => $item->id,
+ 'quantity' => $item->quantity,
+ 'unit_code' => 'C62', // Default to 'unit', should be configurable
+ 'line_extension_amount' => $item->subtotal,
+ 'price_amount' => $item->price,
+ 'item' => [
+ 'name' => $item->item_name,
+ 'description' => $item->description,
+ ],
+ 'tax_percent' => 0, // Calculate from tax rates
+ ];
+ })->toArray(),
+
+ // Monetary totals
+ 'legal_monetary_total' => [
+ 'line_extension_amount' => $invoice->invoice_item_subtotal,
+ 'tax_exclusive_amount' => $invoice->invoice_item_subtotal,
+ 'tax_inclusive_amount' => $invoice->invoice_total,
+ 'payable_amount' => $invoice->invoice_total,
+ ],
+
+ // Tax totals
+ 'tax_total' => [
+ 'tax_amount' => $invoice->invoice_tax_total,
+ ],
+ ];
+
+ // Merge with any additional data provided
+ return array_merge($documentData, $additionalData);
+ }
+}
diff --git a/Modules/Invoices/Peppol/Services/PeppolTransformerService.php b/Modules/Invoices/Peppol/Services/PeppolTransformerService.php
new file mode 100644
index 000000000..a9bdac9e7
--- /dev/null
+++ b/Modules/Invoices/Peppol/Services/PeppolTransformerService.php
@@ -0,0 +1,239 @@
+ $this->getInvoiceTypeCode($invoice),
+ 'invoice_number' => $invoice->number,
+ 'issue_date' => $invoice->invoice_date->format('Y-m-d'),
+ 'due_date' => $invoice->due_date?->format('Y-m-d'),
+ 'currency_code' => config('invoices.peppol.currency_code', 'EUR'),
+
+ 'supplier' => $this->transformSupplier($invoice),
+ 'customer' => $this->transformCustomer($invoice),
+ 'invoice_lines' => $this->transformInvoiceLines($invoice),
+ 'tax_totals' => $this->transformTaxTotals($invoice),
+ 'monetary_totals' => $this->transformMonetaryTotals($invoice),
+ 'payment_terms' => $this->transformPaymentTerms($invoice),
+
+ // Metadata
+ 'format' => $format,
+ 'invoice_id' => $invoice->id,
+ ];
+ }
+
+ /**
+ * Determine the Peppol invoice type code for the given invoice.
+ *
+ * Maps invoice kinds to the Peppol code: '380' for a standard commercial invoice and '381' for a credit note.
+ *
+ * @param Invoice $invoice the invoice to inspect when determining the type code
+ *
+ * @return string The Peppol invoice type code (e.g., '380' or '381').
+ */
+ protected function getInvoiceTypeCode(Invoice $invoice): string
+ {
+ // The Invoice model does not currently support credit notes — always return standard invoice code
+ return '380'; // Standard commercial invoice
+ }
+
+ /**
+ * Build an array representing the supplier (company) information for Peppol output.
+ *
+ * @param Invoice $invoice the invoice used to source supplier data; company name will fall back to $invoice->company->name when not configured
+ *
+ * @return array{
+ * name: string,
+ * vat_number: null|string,
+ * address: array{
+ * street: null|string,
+ * city: null|string,
+ * postal_code: null|string,
+ * country_code: null|string
+ * }
+ * } Supplier structure with address fields mapped for Peppol
+ */
+ protected function transformSupplier(Invoice $invoice): array
+ {
+ return [
+ 'name' => config('invoices.peppol.supplier.name', $invoice->company->name ?? ''),
+ 'vat_number' => config('invoices.peppol.supplier.vat'),
+ 'address' => [
+ 'street' => config('invoices.peppol.supplier.street'),
+ 'city' => config('invoices.peppol.supplier.city'),
+ 'postal_code' => config('invoices.peppol.supplier.postal'),
+ 'country_code' => config('invoices.peppol.supplier.country'),
+ ],
+ ];
+ }
+
+ /**
+ * Transform customer information for Peppol output.
+ *
+ * @param Invoice $invoice the invoice containing the customer and address data to transform
+ *
+ * @return array{
+ * name: mixed,
+ * vat_number: mixed,
+ * endpoint_id: mixed,
+ * endpoint_scheme: mixed,
+ * address: array{street: mixed, city: mixed, postal_code: mixed, country_code: mixed}|null
+ * } An associative array with customer fields; `address` is an address array when available or `null`
+ */
+ protected function transformCustomer(Invoice $invoice): array
+ {
+ $customer = $invoice->customer;
+ $address = $customer->primaryAddress ?? $customer->billingAddress;
+
+ return [
+ 'name' => $customer->company_name,
+ 'vat_number' => $customer->vat_number,
+ 'endpoint_id' => $customer->peppol_id,
+ 'endpoint_scheme' => $customer->peppol_scheme,
+ 'address' => $address ? [
+ 'street' => $address->address_1,
+ 'city' => $address->city,
+ 'postal_code' => $address->zip,
+ 'country_code' => $address->country,
+ ] : null,
+ ];
+ }
+
+ /**
+ * Build an array of Peppol-compatible invoice line representations from the given invoice.
+ *
+ * @param Invoice $invoice the invoice whose line items will be transformed
+ *
+ * @return array an indexed array of line item arrays; each element contains keys: `id`, `quantity`, `unit_code`, `line_extension_amount`, `price_amount`, `item` (with `name` and `description`), and `tax` (with `category_code`, `percent`, and `amount`)
+ */
+ protected function transformInvoiceLines(Invoice $invoice): array
+ {
+ return $invoice->invoiceItems->map(function ($item, $index) {
+ return [
+ 'id' => $index + 1,
+ 'quantity' => $item->quantity,
+ 'unit_code' => config('invoices.peppol.unit_code', 'C62'), // C62 = unit
+ 'line_extension_amount' => $item->subtotal,
+ 'price_amount' => $item->price,
+ 'item' => [
+ 'name' => $item->name,
+ 'description' => $item->description,
+ ],
+ 'tax' => [
+ 'category_code' => 'S', // Standard rate
+ 'percent' => $item->taxRate?->rate ?? 0,
+ 'amount' => $item->tax_total ?? 0,
+ ],
+ ];
+ })->toArray();
+ }
+
+ /**
+ * Builds a structured array of tax totals and subtotals for the given invoice.
+ *
+ * @param Invoice $invoice the invoice to extract tax totals from
+ *
+ * @return array An array of tax total entries. Each entry contains:
+ * - `tax_amount`: total tax amount for the invoice.
+ * - `tax_subtotals`: array of subtotals, each with:
+ * - `taxable_amount`: amount subject to tax,
+ * - `tax_amount`: tax amount for the subtotal,
+ * - `tax_category`: object with `code` and `percent`.
+ */
+ protected function transformTaxTotals(Invoice $invoice): array
+ {
+ // Group invoice items by tax_rate_id and sum taxable_amount and tax_amount per group
+ $taxGroups = $invoice->invoiceItems
+ ->groupBy('tax_rate_id')
+ ->map(function ($items) {
+ $taxableAmount = $items->sum('subtotal');
+ $taxAmount = $items->sum('tax_total');
+ $taxRate = $items->first()->taxRate?->rate ?? 0;
+
+ return [
+ 'taxable_amount' => $taxableAmount,
+ 'tax_amount' => $taxAmount,
+ 'tax_category' => [
+ 'code' => 'S',
+ 'percent' => $taxRate,
+ ],
+ ];
+ })
+ ->values()
+ ->toArray();
+
+ return [
+ [
+ 'tax_amount' => $invoice->tax_total ?? 0,
+ 'tax_subtotals' => $taxGroups ?: [
+ [
+ 'taxable_amount' => $invoice->subtotal ?? 0,
+ 'tax_amount' => $invoice->tax_total ?? 0,
+ 'tax_category' => [
+ 'code' => 'S',
+ 'percent' => 0,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Builds the invoice monetary totals.
+ *
+ * @return array{
+ * line_extension_amount: float|int, // total of invoice lines before tax (subtotal or 0)
+ * tax_exclusive_amount: float|int, // amount excluding tax (subtotal or 0)
+ * tax_inclusive_amount: float|int, // total including tax (total or 0)
+ * payable_amount: float|int // amount due (balance if set, otherwise total, or 0)
+ * }
+ */
+ protected function transformMonetaryTotals(Invoice $invoice): array
+ {
+ return [
+ 'line_extension_amount' => $invoice->subtotal ?? 0,
+ 'tax_exclusive_amount' => $invoice->subtotal ?? 0,
+ 'tax_inclusive_amount' => $invoice->total ?? 0,
+ 'payable_amount' => $invoice->balance ?? $invoice->total ?? 0,
+ ];
+ }
+
+ /**
+ * Produce payment terms when the invoice has a due date.
+ *
+ * @param Invoice $invoice the invoice to extract the due date from
+ *
+ * @return array|null an array with a `note` key containing "Payment due by YYYY-MM-DD", or `null` if the invoice has no due date
+ */
+ protected function transformPaymentTerms(Invoice $invoice): ?array
+ {
+ if ( ! $invoice->due_date) {
+ return null;
+ }
+
+ return [
+ 'note' => "Payment due by {$invoice->due_date->format('Y-m-d')}",
+ ];
+ }
+}
diff --git a/Modules/Invoices/Peppol/Validation/PeppolXmlValidator.php b/Modules/Invoices/Peppol/Validation/PeppolXmlValidator.php
new file mode 100644
index 000000000..dbeaf34f7
--- /dev/null
+++ b/Modules/Invoices/Peppol/Validation/PeppolXmlValidator.php
@@ -0,0 +1,72 @@
+validateWellFormed($xml));
+ if ( ! empty($errors)) {
+ return $errors; // Stop here if XML is malformed
+ }
+
+ /* Tier 1.5: XSD schema validation */
+ $errors = array_merge($errors, $this->validateXSD($xml, $format));
+
+ return $errors;
+ }
+
+ protected function validateWellFormed(string $xml): array
+ {
+ libxml_use_internal_errors(true);
+ $dom = new DOMDocument();
+
+ if ( ! $dom->loadXML($xml, LIBXML_NONET)) {
+ $errors = libxml_get_errors();
+ libxml_clear_errors();
+ libxml_use_internal_errors(false);
+
+ return array_map(fn ($e) => $e->message, $errors);
+ }
+
+ libxml_clear_errors();
+ libxml_use_internal_errors(false);
+
+ return [];
+ }
+
+ protected function validateXSD(string $xml, string $format): array
+ {
+ // XSD validation deferred to Phase 7 completion with bundled XSD files
+ // For now, well-formedness check is sufficient
+ return [];
+ }
+}
diff --git a/Modules/Invoices/Providers/InvoicesServiceProvider.php b/Modules/Invoices/Providers/InvoicesServiceProvider.php
index b7bfd8024..53215fd69 100644
--- a/Modules/Invoices/Providers/InvoicesServiceProvider.php
+++ b/Modules/Invoices/Providers/InvoicesServiceProvider.php
@@ -4,10 +4,17 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
+use Modules\Invoices\Http\Clients\ApiClient;
+use Modules\Invoices\Http\Contracts\HttpClientInterface;
+use Modules\Invoices\Http\Decorators\HttpClientExceptionHandler;
+use Modules\Invoices\Http\Decorators\RateLimiter;
+use Modules\Invoices\Http\Decorators\RequestLogger;
use Modules\Invoices\Models\Invoice;
use Modules\Invoices\Models\InvoiceItem;
+use Modules\Invoices\Models\PeppolIntegration;
use Modules\Invoices\Observers\InvoiceItemObserver;
use Modules\Invoices\Observers\InvoiceObserver;
+use Modules\Invoices\Observers\PeppolIntegrationObserver;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@@ -30,12 +37,31 @@ public function boot(): void
$this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations'));
Invoice::observe(InvoiceObserver::class);
InvoiceItem::observe(InvoiceItemObserver::class);
+ PeppolIntegration::observe(PeppolIntegrationObserver::class);
}
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
+
+ $this->app->bind(HttpClientInterface::class, function ($app) {
+ /* Layer 1: Base client */
+ $client = new ApiClient();
+
+ /* Layer 2: Rate limiting */
+ $client = new RateLimiter($client);
+
+ /* Layer 3: Exception handling */
+ $client = new HttpClientExceptionHandler($client);
+
+ /* Layer 4: Logging (conditional) */
+ if (config('logging.requests')) {
+ $client = new RequestLogger($client);
+ }
+
+ return $client;
+ });
}
public function registerTranslations(): void
diff --git a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php
index 58879b8d8..f65a8db17 100644
--- a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php
+++ b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php
@@ -62,7 +62,7 @@ public function it_renders_invoice_html_with_number_and_customer(): void
/* Assert */
$this->assertStringContainsString('INV-987654', $html);
- $this->assertStringContainsString($invoice->customer->company_name, $html);
+ $this->assertStringContainsString(e($invoice->customer->company_name), $html);
$this->assertStringContainsString('Widget', $html);
$this->assertStringNotContainsString('