From 348e9b79fd742ba261a987cfdec6562891eeed94 Mon Sep 17 00:00:00 2001 From: Niels Drost <47660417+nielsdrost7@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:57:58 +0100 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20Implement=20PEPPOL=20e-invoic?= =?UTF-8?q?ing=20with=20dynamic=20provider=20architecture=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3462b27e4b73d63cd2c67e19cf7aea5fe1360236. --- ...l_validation_fields_to_relations_table.php | 45 -- Modules/Clients/Models/Relation.php | 38 +- Modules/Invoices/Config/config.php | 77 +-- .../Commands/PollPeppolStatusCommand.php | 40 -- .../RetryFailedPeppolTransmissionsCommand.php | 42 -- .../Commands/TestPeppolIntegrationCommand.php | 53 --- ...00001_create_peppol_integrations_table.php | 41 -- ...create_peppol_integration_config_table.php | 39 -- ...0003_create_peppol_transmissions_table.php | 56 --- ...te_peppol_transmission_responses_table.php | 37 -- ...stomer_peppol_validation_history_table.php | 46 -- ...omer_peppol_validation_responses_table.php | 40 -- .../Invoices/Enums/PeppolConnectionStatus.php | 57 --- Modules/Invoices/Enums/PeppolErrorType.php | 57 --- .../Enums/PeppolTransmissionStatus.php | 118 ----- .../Invoices/Enums/PeppolValidationStatus.php | 61 --- .../Peppol/PeppolAcknowledgementReceived.php | 41 -- .../Invoices/Events/Peppol/PeppolEvent.php | 48 -- .../Peppol/PeppolIdValidationCompleted.php | 44 -- .../Peppol/PeppolIntegrationCreated.php | 38 -- .../Events/Peppol/PeppolIntegrationTested.php | 44 -- .../Peppol/PeppolTransmissionCreated.php | 43 -- .../Events/Peppol/PeppolTransmissionDead.php | 39 -- .../Peppol/PeppolTransmissionFailed.php | 44 -- .../Peppol/PeppolTransmissionPrepared.php | 38 -- .../Events/Peppol/PeppolTransmissionSent.php | 40 -- .../Jobs/Peppol/PeppolStatusPoller.php | 104 ---- .../Jobs/Peppol/RetryFailedTransmissions.php | 94 ---- .../Jobs/Peppol/SendInvoiceToPeppolJob.php | 447 ------------------ .../Peppol/LogPeppolEventToAudit.php | 94 ---- .../CustomerPeppolValidationHistory.php | 136 ------ .../CustomerPeppolValidationResponse.php | 32 -- Modules/Invoices/Models/PeppolIntegration.php | 145 ------ .../Models/PeppolIntegrationConfig.php | 32 -- .../Invoices/Models/PeppolTransmission.php | 245 ---------- .../Models/PeppolTransmissionResponse.php | 32 -- .../Peppol/Contracts/ProviderInterface.php | 93 ---- .../FormatHandlers/FormatHandlerFactory.php | 23 +- .../Peppol/Providers/BaseProvider.php | 116 ----- .../EInvoiceBe/EInvoiceBeProvider.php | 350 -------------- .../Peppol/Providers/ProviderFactory.php | 143 ------ .../Providers/Storecove/StorecoveProvider.php | 130 ----- .../Services/PeppolManagementService.php | 267 ----------- .../Services/PeppolTransformerService.php | 211 --------- .../Unit/Enums/PeppolConnectionStatusTest.php | 159 ------- .../Tests/Unit/Enums/PeppolErrorTypeTest.php | 133 ------ .../Enums/PeppolTransmissionStatusTest.php | 245 ---------- .../Unit/Enums/PeppolValidationStatusTest.php | 155 ------ .../FormatHandlerFactoryTest.php | 175 ------- .../Peppol/Providers/ProviderFactoryTest.php | 194 -------- .../Invoices/Traits/LogsPeppolActivity.php | 75 --- PEPPOL_ARCHITECTURE.md | 447 ------------------ PEPPOL_TESTS_SUMMARY.md | 367 -------------- RUNNING_TESTS.md | 94 ---- TEST_GENERATION_SUMMARY.md | 84 ---- 55 files changed, 6 insertions(+), 6082 deletions(-) delete mode 100644 Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php delete mode 100644 Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php delete mode 100644 Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php delete mode 100644 Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php delete mode 100644 Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php delete mode 100644 Modules/Invoices/Enums/PeppolConnectionStatus.php delete mode 100644 Modules/Invoices/Enums/PeppolErrorType.php delete mode 100644 Modules/Invoices/Enums/PeppolTransmissionStatus.php delete mode 100644 Modules/Invoices/Enums/PeppolValidationStatus.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolEvent.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php delete mode 100644 Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php delete mode 100644 Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php delete mode 100644 Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php delete mode 100644 Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php delete mode 100644 Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php delete mode 100644 Modules/Invoices/Models/CustomerPeppolValidationHistory.php delete mode 100644 Modules/Invoices/Models/CustomerPeppolValidationResponse.php delete mode 100644 Modules/Invoices/Models/PeppolIntegration.php delete mode 100644 Modules/Invoices/Models/PeppolIntegrationConfig.php delete mode 100644 Modules/Invoices/Models/PeppolTransmission.php delete mode 100644 Modules/Invoices/Models/PeppolTransmissionResponse.php delete mode 100644 Modules/Invoices/Peppol/Contracts/ProviderInterface.php delete mode 100644 Modules/Invoices/Peppol/Providers/BaseProvider.php delete mode 100644 Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php delete mode 100644 Modules/Invoices/Peppol/Providers/ProviderFactory.php delete mode 100644 Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php delete mode 100644 Modules/Invoices/Peppol/Services/PeppolManagementService.php delete mode 100644 Modules/Invoices/Peppol/Services/PeppolTransformerService.php delete mode 100644 Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php delete mode 100644 Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php delete mode 100644 Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php delete mode 100644 Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php delete mode 100644 Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php delete mode 100644 Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php delete mode 100644 Modules/Invoices/Traits/LogsPeppolActivity.php delete mode 100644 PEPPOL_ARCHITECTURE.md delete mode 100644 PEPPOL_TESTS_SUMMARY.md delete mode 100644 RUNNING_TESTS.md delete mode 100644 TEST_GENERATION_SUMMARY.md 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 deleted file mode 100644 index 0fe0b46d..00000000 --- a/Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php +++ /dev/null @@ -1,45 +0,0 @@ -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']); - }); - } -}; \ No newline at end of file diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index 73ee3e15..528d436a 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -17,8 +17,6 @@ use Modules\Core\Models\User; use Modules\Core\Traits\BelongsToCompany; use Modules\Expenses\Models\Expense; -use Modules\Invoices\Enums\PeppolValidationStatus; -use Modules\Invoices\Models\CustomerPeppolValidationHistory; use Modules\Invoices\Models\Invoice; use Modules\Payments\Models\Payment; use Modules\Projects\Models\Project; @@ -39,12 +37,8 @@ * @property string|null $coc_number * @property string|null $vat_number * @property string|null $peppol_id - * @property string|null $peppol_scheme * @property string|null $peppol_format * @property bool $enable_e_invoicing - * @property PeppolValidationStatus|null $peppol_validation_status - * @property string|null $peppol_validation_message - * @property Carbon|null $peppol_validated_at * @property Carbon $registered_at * @property mixed $created_at * @property mixed $updated_at @@ -74,8 +68,6 @@ class Relation extends Model 'relation_type' => RelationType::class, 'relation_status' => RelationStatus::class, 'enable_e_invoicing' => 'boolean', - 'peppol_validation_status' => PeppolValidationStatus::class, - 'peppol_validated_at' => 'datetime', ]; protected $guarded = []; @@ -169,26 +161,11 @@ public function tasks(): HasMany return $this->hasMany(Task::class, 'customer_id'); } - /** - * Define a one-to-many relationship to User models. - * - * @return HasMany The has-many relationship for User models. - */ public function users(): HasMany { return $this->hasMany(User::class); } - /** - * Get the Peppol validation history records for this relation. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany Collection of CustomerPeppolValidationHistory models related by `customer_id`. - */ - public function peppolValidationHistory(): HasMany - { - return $this->hasMany(CustomerPeppolValidationHistory::class, 'customer_id'); - } - /* |-------------------------------------------------------------------------- | Accessors @@ -203,19 +180,6 @@ public function getCustomerEmailAttribute() { return mb_trim($this->primary_ontact?->first_name . ' ' . $this->primary_contact?->last_name); }*/ - - /** - * Determines whether the relation's Peppol ID has been validated and e-invoicing is enabled. - * - * @return bool `true` if e-invoicing is enabled, the Peppol validation status is `PeppolValidationStatus::VALID`, and `peppol_id` is not null; `false` otherwise. - */ - public function hasPeppolIdValidated(): bool - { - return $this->enable_e_invoicing - && $this->peppol_validation_status === PeppolValidationStatus::VALID - && $this->peppol_id !== null; - } - /* |-------------------------------------------------------------------------- | Scopes @@ -237,4 +201,4 @@ protected static function newFactory(): Factory | Subqueries |-------------------------------------------------------------------------- */ -} \ No newline at end of file +} diff --git a/Modules/Invoices/Config/config.php b/Modules/Invoices/Config/config.php index 1569571f..41692635 100644 --- a/Modules/Invoices/Config/config.php +++ b/Modules/Invoices/Config/config.php @@ -153,81 +153,8 @@ 'enable_webhooks' => env('PEPPOL_ENABLE_WEBHOOKS', false), 'enable_participant_search' => env('PEPPOL_ENABLE_PARTICIPANT_SEARCH', true), 'enable_health_checks' => env('PEPPOL_ENABLE_HEALTH_CHECKS', true), - 'auto_retry_failed' => env('PEPPOL_AUTO_RETRY', true), - 'max_retries' => env('PEPPOL_MAX_RETRIES', 5), - ], - - /* - |-------------------------------------------------------------------------- - | Country to Scheme Mapping - |-------------------------------------------------------------------------- - | - | Mapping of country codes to default Peppol endpoint schemes. - | Used for auto-suggesting the appropriate scheme when onboarding customers. - | - */ - 'country_scheme_mapping' => [ - 'BE' => 'BE:CBE', - 'DE' => 'DE:VAT', - 'FR' => 'FR:SIRENE', - 'IT' => 'IT:VAT', - 'ES' => 'ES:VAT', - 'NL' => 'NL:KVK', - 'NO' => 'NO:ORGNR', - 'DK' => 'DK:CVR', - 'SE' => 'SE:ORGNR', - 'FI' => 'FI:OVT', - 'AT' => 'AT:VAT', - 'CH' => 'CH:UIDB', - 'GB' => 'GB:COH', - ], - - /* - |-------------------------------------------------------------------------- - | Retry Policy - |-------------------------------------------------------------------------- - | - | Configuration for automatic retries of failed transmissions. - | Uses exponential backoff strategy. - | - */ - 'retry' => [ - 'max_attempts' => env('PEPPOL_MAX_RETRY_ATTEMPTS', 5), - 'backoff_delays' => [60, 300, 1800, 7200, 21600], // 1min, 5min, 30min, 2h, 6h - 'retry_transient_errors' => true, - 'retry_unknown_errors' => true, - 'retry_permanent_errors' => false, - ], - - /* - |-------------------------------------------------------------------------- - | Storage Configuration - |-------------------------------------------------------------------------- - | - | Configuration for storing Peppol artifacts (XML, PDF). - | - */ - 'storage' => [ - 'disk' => env('PEPPOL_STORAGE_DISK', 'local'), - 'path_template' => 'peppol/{integration_id}/{year}/{month}/{transmission_id}', - 'retention_days' => env('PEPPOL_RETENTION_DAYS', 2555), // 7 years default - ], - - /* - |-------------------------------------------------------------------------- - | Monitoring & Alerting - |-------------------------------------------------------------------------- - | - | Thresholds and settings for monitoring Peppol operations. - | - */ - 'monitoring' => [ - 'alert_on_dead_transmission' => true, - 'dead_transmission_threshold' => 10, // Alert if > 10 dead in 1 hour - 'alert_on_auth_failure' => true, - 'status_check_interval' => 15, // minutes - 'reconciliation_interval' => 60, // minutes - 'old_transmission_threshold' => 168, // hours (7 days) + 'auto_retry_failed' => env('PEPPOL_AUTO_RETRY', false), + 'max_retries' => env('PEPPOL_MAX_RETRIES', 3), ], ], ]; diff --git a/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php b/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php deleted file mode 100644 index 5e436a86..00000000 --- a/Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php +++ /dev/null @@ -1,40 +0,0 @@ -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; - } - } -} \ No newline at end of file diff --git a/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php b/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php deleted file mode 100644 index 0a4e3aa2..00000000 --- a/Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php +++ /dev/null @@ -1,42 +0,0 @@ -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; - } - } -} \ No newline at end of file diff --git a/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php b/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php deleted file mode 100644 index 4462fba8..00000000 --- a/Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php +++ /dev/null @@ -1,53 +0,0 @@ -argument('integration_id'); - - $integration = PeppolIntegration::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; - } else { - $this->error('✗ Connection test failed.'); - $this->error($result['message']); - return self::FAILURE; - } - } -} \ No newline at end of file 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 deleted file mode 100644 index 83ff5d6b..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php +++ /dev/null @@ -1,41 +0,0 @@ -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'); - } -}; \ No newline at end of file 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 deleted file mode 100644 index 6f47f11f..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php +++ /dev/null @@ -1,39 +0,0 @@ -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'); - } -}; \ No newline at end of file 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 deleted file mode 100644 index 5f039b98..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php +++ /dev/null @@ -1,56 +0,0 @@ -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('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(['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'); - } -}; \ No newline at end of file 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 deleted file mode 100644 index f2e9940d..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php +++ /dev/null @@ -1,37 +0,0 @@ -id(); - $table->unsignedBigInteger('transmission_id'); - $table->string('response_key', 100); - $table->text('response_value'); - - $table->foreign('transmission_id')->references('id')->on('peppol_transmissions')->onDelete('cascade'); - $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'); - } -}; \ No newline at end of file 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 deleted file mode 100644 index 9b352de0..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php +++ /dev/null @@ -1,46 +0,0 @@ -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('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(['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'); - } -}; \ No newline at end of file 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 deleted file mode 100644 index df49fe10..00000000 --- a/Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php +++ /dev/null @@ -1,40 +0,0 @@ -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'); - } -}; \ No newline at end of file diff --git a/Modules/Invoices/Enums/PeppolConnectionStatus.php b/Modules/Invoices/Enums/PeppolConnectionStatus.php deleted file mode 100644 index 9a30a5eb..00000000 --- a/Modules/Invoices/Enums/PeppolConnectionStatus.php +++ /dev/null @@ -1,57 +0,0 @@ - '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', - }; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Enums/PeppolErrorType.php b/Modules/Invoices/Enums/PeppolErrorType.php deleted file mode 100644 index 2035e43c..00000000 --- a/Modules/Invoices/Enums/PeppolErrorType.php +++ /dev/null @@ -1,57 +0,0 @@ - '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', - }; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Enums/PeppolTransmissionStatus.php b/Modules/Invoices/Enums/PeppolTransmissionStatus.php deleted file mode 100644 index 9c0a4489..00000000 --- a/Modules/Invoices/Enums/PeppolTransmissionStatus.php +++ /dev/null @@ -1,118 +0,0 @@ - '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; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Enums/PeppolValidationStatus.php b/Modules/Invoices/Enums/PeppolValidationStatus.php deleted file mode 100644 index 2dde96f8..00000000 --- a/Modules/Invoices/Enums/PeppolValidationStatus.php +++ /dev/null @@ -1,61 +0,0 @@ - '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', - }; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php b/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php deleted file mode 100644 index 17165a42..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php +++ /dev/null @@ -1,41 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolEvent.php b/Modules/Invoices/Events/Peppol/PeppolEvent.php deleted file mode 100644 index e695a804..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolEvent.php +++ /dev/null @@ -1,48 +0,0 @@ -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(), - ]); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php b/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php deleted file mode 100644 index a4d11915..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php +++ /dev/null @@ -1,44 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php b/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php deleted file mode 100644 index a73e2110..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php +++ /dev/null @@ -1,38 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php b/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php deleted file mode 100644 index 722e153d..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php +++ /dev/null @@ -1,44 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php deleted file mode 100644 index 046547cc..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php +++ /dev/null @@ -1,43 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php deleted file mode 100644 index 746793d6..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php +++ /dev/null @@ -1,39 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php deleted file mode 100644 index 23e75b9f..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php +++ /dev/null @@ -1,44 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php deleted file mode 100644 index 1f0e6c0b..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php +++ /dev/null @@ -1,38 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php b/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php deleted file mode 100644 index d90c2ad2..00000000 --- a/Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php +++ /dev/null @@ -1,40 +0,0 @@ -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'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php b/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php deleted file mode 100644 index 7531857a..00000000 --- a/Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php +++ /dev/null @@ -1,104 +0,0 @@ -logPeppolInfo('Starting Peppol status polling job'); - - // Get all transmissions awaiting acknowledgement - $transmissions = PeppolTransmission::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 = 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']); - } - } -} \ No newline at end of file diff --git a/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php b/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php deleted file mode 100644 index 30f9f1c7..00000000 --- a/Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php +++ /dev/null @@ -1,94 +0,0 @@ -logPeppolInfo('Starting retry failed transmissions job'); - - // Get transmissions ready for retry - $transmissions = PeppolTransmission::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; - } - - // 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, - ]); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php b/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php deleted file mode 100644 index ff94a951..00000000 --- a/Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php +++ /dev/null @@ -1,447 +0,0 @@ -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 (\Exception $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); - } - - throw $e; - } - } - - /** - * 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->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 - if ($this->transmissionId) { - return PeppolTransmission::findOrFail($this->transmissionId); - } - - // Calculate idempotency key - $idempotencyKey = $this->calculateIdempotencyKey(); - - // Try to find existing transmission - $transmission = PeppolTransmission::where('idempotency_key', $idempotencyKey)->first(); - - if ($transmission) { - $this->logPeppolInfo('Found existing transmission', ['transmission_id' => $transmission->id]); - return $transmission; - } - - // Create new transmission - $transmission = PeppolTransmission::create([ - '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 - { - return hash('sha256', implode('|', [ - $this->invoice->id, - $this->invoice->customer->peppol_id, - $this->integration->id, - $this->invoice->updated_at->timestamp, - ])); - } - - /** - * 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 XML (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 - ); - - // Generate PDF from invoice - // TODO: Implement PDF generation - $pdfContent = ''; // Placeholder - - 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 - $transmissionData = [ - 'transmission_id' => $transmission->id, - 'invoice_id' => $this->invoice->id, - 'customer_peppol_id' => $this->invoice->customer->peppol_id, - 'customer_peppol_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 \Exception $e The exception that caused the failure; its message is recorded on the transmission. - */ - protected function handleFailure(PeppolTransmission $transmission, \Exception $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 - $delays = [60, 300, 1800, 7200, 21600]; - $delay = $delays[$transmission->attempts] ?? 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, - ]); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php b/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php deleted file mode 100644 index 52d91542..00000000 --- a/Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php +++ /dev/null @@ -1,94 +0,0 @@ -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'; - } elseif (str_contains($eventName, 'integration')) { - return 'peppol_integration'; - } elseif (str_contains($eventName, 'validation')) { - return 'peppol_validation'; - } - - return 'peppol_event'; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Models/CustomerPeppolValidationHistory.php b/Modules/Invoices/Models/CustomerPeppolValidationHistory.php deleted file mode 100644 index 20742adc..00000000 --- a/Modules/Invoices/Models/CustomerPeppolValidationHistory.php +++ /dev/null @@ -1,136 +0,0 @@ - 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 $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 deleted file mode 100644 index e9ad189b..00000000 --- a/Modules/Invoices/Models/CustomerPeppolValidationResponse.php +++ /dev/null @@ -1,32 +0,0 @@ -belongsTo(CustomerPeppolValidationHistory::class, 'validation_history_id'); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Models/PeppolIntegration.php b/Modules/Invoices/Models/PeppolIntegration.php deleted file mode 100644 index e51bf15e..00000000 --- a/Modules/Invoices/Models/PeppolIntegration.php +++ /dev/null @@ -1,145 +0,0 @@ - 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. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany Relation to PeppolIntegrationConfig models keyed by `integration_id`. - */ - public function configurations(): HasMany - { - return $this->hasMany(PeppolIntegrationConfig::class, 'integration_id'); - } - - /** - * Return the decrypted API token for the integration. - * - * @return string|null The decrypted API token, or null if no token is stored. - */ - public function getApiTokenAttribute(): ?string - { - return $this->encrypted_api_token ? decrypt($this->encrypted_api_token) : null; - } - - /** - * Store the API token on the model in encrypted form. - * - * If `$value` is null the stored encrypted token will be set to null. - * - * @param string|null $value The plaintext API token to encrypt and store, or null to clear it. - */ - public function setApiTokenAttribute(?string $value): void - { - $this->encrypted_api_token = $value ? encrypt($value) : null; - } - - /** - * Provide integration configurations as an associative array keyed by configuration keys. - * - * @return array Associative array mapping configuration keys (`config_key`) to their values (`config_value`). - */ - public function getConfigAttribute(): array - { - return $this->configurations->pluck('config_value', 'config_key')->toArray(); - } - - /** - * Upserts integration configuration entries from an associative array. - * - * Each array key is saved as `config_key` and its corresponding value as `config_value` - * on the related configurations; 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) { - $this->configurations()->updateOrCreate( - ['config_key' => $key], - ['config_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 = $this->configurations()->where('config_key', $key)->first(); - return $config ? $config->config_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(); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Models/PeppolIntegrationConfig.php b/Modules/Invoices/Models/PeppolIntegrationConfig.php deleted file mode 100644 index 87dcc4b2..00000000 --- a/Modules/Invoices/Models/PeppolIntegrationConfig.php +++ /dev/null @@ -1,32 +0,0 @@ -belongsTo(PeppolIntegration::class, 'integration_id'); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Models/PeppolTransmission.php b/Modules/Invoices/Models/PeppolTransmission.php deleted file mode 100644 index 4e5c7b10..00000000 --- a/Modules/Invoices/Models/PeppolTransmission.php +++ /dev/null @@ -1,245 +0,0 @@ - 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 $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) { - $this->responses()->updateOrCreate( - ['response_key' => $key], - ['response_value' => is_array($value) ? json_encode($value) : $value] - ); - } - } - - /** - * 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, - ]); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Models/PeppolTransmissionResponse.php b/Modules/Invoices/Models/PeppolTransmissionResponse.php deleted file mode 100644 index 638adb9e..00000000 --- a/Modules/Invoices/Models/PeppolTransmissionResponse.php +++ /dev/null @@ -1,32 +0,0 @@ -belongsTo(PeppolTransmission::class, 'transmission_id'); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Contracts/ProviderInterface.php b/Modules/Invoices/Peppol/Contracts/ProviderInterface.php deleted file mode 100644 index 921c2760..00000000 --- a/Modules/Invoices/Peppol/Contracts/ProviderInterface.php +++ /dev/null @@ -1,93 +0,0 @@ -> Array where keys are format values and values are handler class-strings implementing InvoiceFormatHandlerInterface. + * @return array> */ 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}"); - } - } -} \ No newline at end of file +} diff --git a/Modules/Invoices/Peppol/Providers/BaseProvider.php b/Modules/Invoices/Peppol/Providers/BaseProvider.php deleted file mode 100644 index 58101460..00000000 --- a/Modules/Invoices/Peppol/Providers/BaseProvider.php +++ /dev/null @@ -1,116 +0,0 @@ -integration = $integration; - $this->config = $integration?->config ?? []; - } - - /** - * Retrieve the API token for the current provider. - * - * @return string|null The API token for the provider, or `null` if no token is configured. - */ - protected function getApiToken(): ?string - { - return $this->integration?->api_token ?? config("invoices.peppol.{$this->getProviderName()}.api_key"); - } - - /** - * 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(); - } - - /** - * 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; - - /** - * 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, - }; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php b/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php deleted file mode 100644 index 8ff284a6..00000000 --- a/Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php +++ /dev/null @@ -1,350 +0,0 @@ -documentsClient = $documentsClient ?? app(DocumentsClient::class); - $this->participantsClient = $participantsClient ?? app(ParticipantsClient::class); - $this->trackingClient = $trackingClient ?? app(TrackingClient::class); - $this->healthClient = $healthClient ?? app(HealthClient::class); - } - - /** - * 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'; - } - - /** - * 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'; - } - - /** - * 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 The payload sent to the documents API (may include keys such as `invoice_id` used for logging). - * @return array{ - * accepted: bool, - * external_id: string|null, - * status_code: int, - * message: string, - * response: array|null - * } - * @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 { - $response = $this->documentsClient->submitDocument($transmissionData); - - 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 = $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); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Providers/ProviderFactory.php b/Modules/Invoices/Peppol/Providers/ProviderFactory.php deleted file mode 100644 index c4af7796..00000000 --- a/Modules/Invoices/Peppol/Providers/ProviderFactory.php +++ /dev/null @@ -1,143 +0,0 @@ -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 - { - $providers = self::discoverProviders(); - - if (!isset($providers[$providerName])) { - throw new \InvalidArgumentException("Unknown Peppol provider: {$providerName}"); - } - - return app($providers[$providerName], ['integration' => $integration]); - } - - /** - * 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()); - } - - /** - * 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; - } - - /** - * 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; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php b/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php deleted file mode 100644 index 1ee6eae9..00000000 --- a/Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php +++ /dev/null @@ -1,130 +0,0 @@ - false, - 'message' => 'Storecove provider not yet implemented', - ]; - } - - / ** - * Validate a Peppol participant identifier (scheme and id) using the Storecove provider. - * - * @param string $scheme The identifier scheme (for example, a participant scheme code like '0088'). - * @param string $id The participant identifier to validate. - * @return array An associative array with: - * - `present` (bool): `true` if the identifier is valid/present, `false` otherwise. - * - `details` (array): Additional validation metadata or an `error` entry describing why validation failed. - */ - public function validatePeppolId(string $scheme, string $id): array - { - // TODO: Implement Storecove Peppol ID validation - return [ - 'present' => false, - 'details' => ['error' => 'Storecove provider not yet implemented'], - ]; - } - - /** - * Attempts to send an invoice to Storecove (currently a placeholder that reports not implemented). - * - * @param array $transmissionData Transmission payload and metadata required to send the invoice. - * Expected keys vary by provider integration (e.g. invoice XML, sender/recipient identifiers, options). - * @return array { - * Result of the send attempt. - * - * @type bool $accepted Whether the provider accepted the submission. - * @type string|null $external_id Provider-assigned identifier for the transmission, or null if not assigned. - * @type int $status_code Numeric status or HTTP-like code indicating result (0 when not applicable). - * @type string $message Human-readable message describing the result. - * @type mixed|null $response Raw provider response payload when available, or null. - * } - */ - public function sendInvoice(array $transmissionData): array - { - // TODO: Implement Storecove invoice sending - return [ - 'accepted' => false, - 'external_id' => null, - 'status_code' => 0, - 'message' => 'Storecove provider not yet implemented', - 'response' => null, - ]; - } - - /** - * Retrieves the transmission status for a document identified by the provider's external ID. - * - * @param string $externalId The external identifier assigned by the provider for the transmitted document. - * @return array An associative array with: - * - 'status' (string): transmission status (for example 'error', 'accepted', 'pending'). - * - 'ack_payload' (array): provider-specific acknowledgement payload or error details. - */ - public function getTransmissionStatus(string $externalId): array - { - // TODO: Implement Storecove status checking - return [ - 'status' => 'error', - 'ack_payload' => ['error' => 'Storecove provider not yet implemented'], - ]; - } - - / ** - * Attempts to cancel a previously transmitted document identified by the provider's external ID. - * - * @param string $externalId The provider-assigned external identifier of the document to cancel. - * @return array An associative array with keys: - * - `success` (bool): `true` if the cancellation was accepted by the provider, `false` otherwise. - * - `message` (string): A human-readable message describing the result or error. - */ - public function cancelDocument(string $externalId): array - { - // TODO: Implement Storecove document cancellation - return [ - 'success' => false, - 'message' => 'Storecove provider not yet implemented', - ]; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Services/PeppolManagementService.php b/Modules/Invoices/Peppol/Services/PeppolManagementService.php deleted file mode 100644 index 569229fa..00000000 --- a/Modules/Invoices/Peppol/Services/PeppolManagementService.php +++ /dev/null @@ -1,267 +0,0 @@ -company_id = $companyId; - $integration->provider_name = $providerName; - $integration->api_token = $apiToken; // Will be encrypted automatically by model accessor - $integration->enabled = false; // Start disabled until tested - $integration->save(); - - // Set configuration using the key-value relationship - $integration->setConfig($config); - - event(new PeppolIntegrationCreated($integration)); - - 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) { - if (isset($history)) { - 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::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; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Peppol/Services/PeppolTransformerService.php b/Modules/Invoices/Peppol/Services/PeppolTransformerService.php deleted file mode 100644 index 6c7aa9ae..00000000 --- a/Modules/Invoices/Peppol/Services/PeppolTransformerService.php +++ /dev/null @@ -1,211 +0,0 @@ - $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 - { - // TODO: Detect credit note vs invoice - 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'), - ], - ]; - } - - /** - * Build a Peppol-compatible representation of the invoice customer. - * - * @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->tax_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 - { - return [ - [ - 'tax_amount' => $invoice->tax_total ?? 0, - 'tax_subtotals' => [ - [ - 'taxable_amount' => $invoice->subtotal ?? 0, - 'tax_amount' => $invoice->tax_total ?? 0, - 'tax_category' => [ - 'code' => 'S', - 'percent' => 21, // TODO: Calculate from invoice items - ], - ], - ], - ], - ]; - } - - /** - * 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')}", - ]; - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php b/Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php deleted file mode 100644 index 1aa6d668..00000000 --- a/Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php +++ /dev/null @@ -1,159 +0,0 @@ -assertCount(3, $cases); - $this->assertContains(PeppolConnectionStatus::UNTESTED, $cases); - $this->assertContains(PeppolConnectionStatus::SUCCESS, $cases); - $this->assertContains(PeppolConnectionStatus::FAILED, $cases); - } - - #[Test] - #[DataProvider('labelProvider')] - public function it_provides_correct_labels( - PeppolConnectionStatus $status, - string $expectedLabel - ): void { - $this->assertEquals($expectedLabel, $status->label()); - } - - public static function labelProvider(): array - { - return [ - [PeppolConnectionStatus::UNTESTED, 'Untested'], - [PeppolConnectionStatus::SUCCESS, 'Success'], - [PeppolConnectionStatus::FAILED, 'Failed'], - ]; - } - - #[Test] - #[DataProvider('colorProvider')] - public function it_provides_correct_colors( - PeppolConnectionStatus $status, - string $expectedColor - ): void { - $this->assertEquals($expectedColor, $status->color()); - } - - public static function colorProvider(): array - { - return [ - [PeppolConnectionStatus::UNTESTED, 'gray'], - [PeppolConnectionStatus::SUCCESS, 'green'], - [PeppolConnectionStatus::FAILED, 'red'], - ]; - } - - #[Test] - #[DataProvider('iconProvider')] - public function it_provides_correct_icons( - PeppolConnectionStatus $status, - string $expectedIcon - ): void { - $this->assertEquals($expectedIcon, $status->icon()); - } - - public static function iconProvider(): array - { - return [ - [PeppolConnectionStatus::UNTESTED, 'heroicon-o-question-mark-circle'], - [PeppolConnectionStatus::SUCCESS, 'heroicon-o-check-circle'], - [PeppolConnectionStatus::FAILED, 'heroicon-o-x-circle'], - ]; - } - - #[Test] - #[DataProvider('valueProvider')] - public function it_has_correct_enum_values( - PeppolConnectionStatus $status, - string $expectedValue - ): void { - $this->assertEquals($expectedValue, $status->value); - } - - public static function valueProvider(): array - { - return [ - [PeppolConnectionStatus::UNTESTED, 'untested'], - [PeppolConnectionStatus::SUCCESS, 'success'], - [PeppolConnectionStatus::FAILED, 'failed'], - ]; - } - - #[Test] - public function it_can_be_instantiated_from_value(): void - { - $status = PeppolConnectionStatus::from('success'); - - $this->assertEquals(PeppolConnectionStatus::SUCCESS, $status); - } - - #[Test] - public function it_throws_on_invalid_value(): void - { - $this->expectException(\ValueError::class); - PeppolConnectionStatus::from('invalid_status'); - } - - #[Test] - public function it_can_try_from_value_returning_null_on_invalid(): void - { - $status = PeppolConnectionStatus::tryFrom('invalid'); - - $this->assertNull($status); - } - - #[Test] - public function it_can_be_used_in_match_expressions(): void - { - $status = PeppolConnectionStatus::SUCCESS; - - $message = match ($status) { - PeppolConnectionStatus::UNTESTED => 'Not yet tested', - PeppolConnectionStatus::SUCCESS => 'Connection successful', - PeppolConnectionStatus::FAILED => 'Connection failed', - }; - - $this->assertEquals('Connection successful', $message); - } - - #[Test] - public function it_provides_all_cases_for_selection(): void - { - $cases = PeppolConnectionStatus::cases(); - $options = []; - - foreach ($cases as $case) { - $options[$case->value] = $case->label(); - } - - $this->assertArrayHasKey('untested', $options); - $this->assertArrayHasKey('success', $options); - $this->assertArrayHasKey('failed', $options); - $this->assertEquals('Untested', $options['untested']); - $this->assertEquals('Success', $options['success']); - $this->assertEquals('Failed', $options['failed']); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php b/Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php deleted file mode 100644 index a4933c39..00000000 --- a/Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php +++ /dev/null @@ -1,133 +0,0 @@ -assertCount(3, $cases); - $this->assertContains(PeppolErrorType::TRANSIENT, $cases); - $this->assertContains(PeppolErrorType::PERMANENT, $cases); - $this->assertContains(PeppolErrorType::UNKNOWN, $cases); - } - - #[Test] - #[DataProvider('labelProvider')] - public function it_provides_correct_labels( - PeppolErrorType $type, - string $expectedLabel - ): void { - $this->assertEquals($expectedLabel, $type->label()); - } - - public static function labelProvider(): array - { - return [ - [PeppolErrorType::TRANSIENT, 'Transient Error'], - [PeppolErrorType::PERMANENT, 'Permanent Error'], - [PeppolErrorType::UNKNOWN, 'Unknown Error'], - ]; - } - - #[Test] - #[DataProvider('colorProvider')] - public function it_provides_correct_colors( - PeppolErrorType $type, - string $expectedColor - ): void { - $this->assertEquals($expectedColor, $type->color()); - } - - public static function colorProvider(): array - { - return [ - [PeppolErrorType::TRANSIENT, 'yellow'], - [PeppolErrorType::PERMANENT, 'red'], - [PeppolErrorType::UNKNOWN, 'gray'], - ]; - } - - #[Test] - #[DataProvider('iconProvider')] - public function it_provides_correct_icons( - PeppolErrorType $type, - string $expectedIcon - ): void { - $this->assertEquals($expectedIcon, $type->icon()); - } - - public static function iconProvider(): array - { - return [ - [PeppolErrorType::TRANSIENT, 'heroicon-o-arrow-path'], - [PeppolErrorType::PERMANENT, 'heroicon-o-x-circle'], - [PeppolErrorType::UNKNOWN, 'heroicon-o-question-mark-circle'], - ]; - } - - #[Test] - #[DataProvider('valueProvider')] - public function it_has_correct_enum_values( - PeppolErrorType $type, - string $expectedValue - ): void { - $this->assertEquals($expectedValue, $type->value); - } - - public static function valueProvider(): array - { - return [ - [PeppolErrorType::TRANSIENT, 'TRANSIENT'], - [PeppolErrorType::PERMANENT, 'PERMANENT'], - [PeppolErrorType::UNKNOWN, 'UNKNOWN'], - ]; - } - - #[Test] - public function it_can_be_instantiated_from_value(): void - { - $type = PeppolErrorType::from('TRANSIENT'); - - $this->assertEquals(PeppolErrorType::TRANSIENT, $type); - } - - #[Test] - public function it_throws_on_invalid_value(): void - { - $this->expectException(\ValueError::class); - PeppolErrorType::from('INVALID'); - } - - #[Test] - public function it_distinguishes_retryable_vs_permanent_errors(): void - { - $transient = PeppolErrorType::TRANSIENT; - $permanent = PeppolErrorType::PERMANENT; - - // Transient errors typically warrant retry - $this->assertEquals('yellow', $transient->color()); - $this->assertStringContainsString('arrow-path', $transient->icon()); - - // Permanent errors should not be retried - $this->assertEquals('red', $permanent->color()); - $this->assertStringContainsString('x-circle', $permanent->icon()); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php b/Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php deleted file mode 100644 index df67b78a..00000000 --- a/Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php +++ /dev/null @@ -1,245 +0,0 @@ -assertCount(9, $cases); - $this->assertContains(PeppolTransmissionStatus::PENDING, $cases); - $this->assertContains(PeppolTransmissionStatus::QUEUED, $cases); - $this->assertContains(PeppolTransmissionStatus::PROCESSING, $cases); - $this->assertContains(PeppolTransmissionStatus::SENT, $cases); - $this->assertContains(PeppolTransmissionStatus::ACCEPTED, $cases); - $this->assertContains(PeppolTransmissionStatus::REJECTED, $cases); - $this->assertContains(PeppolTransmissionStatus::FAILED, $cases); - $this->assertContains(PeppolTransmissionStatus::RETRYING, $cases); - $this->assertContains(PeppolTransmissionStatus::DEAD, $cases); - } - - #[Test] - #[DataProvider('labelProvider')] - public function it_provides_correct_labels( - PeppolTransmissionStatus $status, - string $expectedLabel - ): void { - $this->assertEquals($expectedLabel, $status->label()); - } - - public static function labelProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, 'Pending'], - [PeppolTransmissionStatus::QUEUED, 'Queued'], - [PeppolTransmissionStatus::PROCESSING, 'Processing'], - [PeppolTransmissionStatus::SENT, 'Sent'], - [PeppolTransmissionStatus::ACCEPTED, 'Accepted'], - [PeppolTransmissionStatus::REJECTED, 'Rejected'], - [PeppolTransmissionStatus::FAILED, 'Failed'], - [PeppolTransmissionStatus::RETRYING, 'Retrying'], - [PeppolTransmissionStatus::DEAD, 'Dead'], - ]; - } - - #[Test] - #[DataProvider('colorProvider')] - public function it_provides_correct_colors( - PeppolTransmissionStatus $status, - string $expectedColor - ): void { - $this->assertEquals($expectedColor, $status->color()); - } - - public static function colorProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, 'gray'], - [PeppolTransmissionStatus::QUEUED, 'blue'], - [PeppolTransmissionStatus::PROCESSING, 'yellow'], - [PeppolTransmissionStatus::SENT, 'indigo'], - [PeppolTransmissionStatus::ACCEPTED, 'green'], - [PeppolTransmissionStatus::REJECTED, 'red'], - [PeppolTransmissionStatus::FAILED, 'orange'], - [PeppolTransmissionStatus::RETRYING, 'purple'], - [PeppolTransmissionStatus::DEAD, 'red'], - ]; - } - - #[Test] - #[DataProvider('iconProvider')] - public function it_provides_correct_icons( - PeppolTransmissionStatus $status, - string $expectedIcon - ): void { - $this->assertEquals($expectedIcon, $status->icon()); - } - - public static function iconProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, 'heroicon-o-clock'], - [PeppolTransmissionStatus::QUEUED, 'heroicon-o-queue-list'], - [PeppolTransmissionStatus::PROCESSING, 'heroicon-o-arrow-path'], - [PeppolTransmissionStatus::SENT, 'heroicon-o-paper-airplane'], - [PeppolTransmissionStatus::ACCEPTED, 'heroicon-o-check-circle'], - [PeppolTransmissionStatus::REJECTED, 'heroicon-o-x-circle'], - [PeppolTransmissionStatus::FAILED, 'heroicon-o-exclamation-triangle'], - [PeppolTransmissionStatus::RETRYING, 'heroicon-o-arrow-path'], - [PeppolTransmissionStatus::DEAD, 'heroicon-o-no-symbol'], - ]; - } - - #[Test] - #[DataProvider('finalStatusProvider')] - public function it_correctly_identifies_final_statuses( - PeppolTransmissionStatus $status, - bool $expectedIsFinal - ): void { - $this->assertEquals($expectedIsFinal, $status->isFinal()); - } - - public static function finalStatusProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, false], - [PeppolTransmissionStatus::QUEUED, false], - [PeppolTransmissionStatus::PROCESSING, false], - [PeppolTransmissionStatus::SENT, false], - [PeppolTransmissionStatus::ACCEPTED, true], - [PeppolTransmissionStatus::REJECTED, true], - [PeppolTransmissionStatus::FAILED, false], - [PeppolTransmissionStatus::RETRYING, false], - [PeppolTransmissionStatus::DEAD, true], - ]; - } - - #[Test] - #[DataProvider('retryableStatusProvider')] - public function it_correctly_identifies_retryable_statuses( - PeppolTransmissionStatus $status, - bool $expectedCanRetry - ): void { - $this->assertEquals($expectedCanRetry, $status->canRetry()); - } - - public static function retryableStatusProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, false], - [PeppolTransmissionStatus::QUEUED, false], - [PeppolTransmissionStatus::PROCESSING, false], - [PeppolTransmissionStatus::SENT, false], - [PeppolTransmissionStatus::ACCEPTED, false], - [PeppolTransmissionStatus::REJECTED, false], - [PeppolTransmissionStatus::FAILED, true], - [PeppolTransmissionStatus::RETRYING, true], - [PeppolTransmissionStatus::DEAD, false], - ]; - } - - #[Test] - #[DataProvider('awaitingAckProvider')] - public function it_correctly_identifies_awaiting_acknowledgement_status( - PeppolTransmissionStatus $status, - bool $expectedIsAwaitingAck - ): void { - $this->assertEquals($expectedIsAwaitingAck, $status->isAwaitingAck()); - } - - public static function awaitingAckProvider(): array - { - return [ - [PeppolTransmissionStatus::PENDING, false], - [PeppolTransmissionStatus::QUEUED, false], - [PeppolTransmissionStatus::PROCESSING, false], - [PeppolTransmissionStatus::SENT, true], - [PeppolTransmissionStatus::ACCEPTED, false], - [PeppolTransmissionStatus::REJECTED, false], - [PeppolTransmissionStatus::FAILED, false], - [PeppolTransmissionStatus::RETRYING, false], - [PeppolTransmissionStatus::DEAD, false], - ]; - } - - #[Test] - public function it_can_be_instantiated_from_value(): void - { - $status = PeppolTransmissionStatus::from('sent'); - - $this->assertEquals(PeppolTransmissionStatus::SENT, $status); - } - - #[Test] - public function it_throws_on_invalid_value(): void - { - $this->expectException(\ValueError::class); - PeppolTransmissionStatus::from('invalid'); - } - - #[Test] - public function it_models_complete_transmission_lifecycle(): void - { - // Test typical successful flow - $pending = PeppolTransmissionStatus::PENDING; - $this->assertFalse($pending->isFinal()); - $this->assertFalse($pending->canRetry()); - - $queued = PeppolTransmissionStatus::QUEUED; - $this->assertFalse($queued->isFinal()); - - $processing = PeppolTransmissionStatus::PROCESSING; - $this->assertFalse($processing->isFinal()); - - $sent = PeppolTransmissionStatus::SENT; - $this->assertTrue($sent->isAwaitingAck()); - $this->assertFalse($sent->isFinal()); - - $accepted = PeppolTransmissionStatus::ACCEPTED; - $this->assertTrue($accepted->isFinal()); - $this->assertFalse($accepted->canRetry()); - } - - #[Test] - public function it_models_failure_and_retry_flow(): void - { - $failed = PeppolTransmissionStatus::FAILED; - $this->assertFalse($failed->isFinal()); - $this->assertTrue($failed->canRetry()); - - $retrying = PeppolTransmissionStatus::RETRYING; - $this->assertFalse($retrying->isFinal()); - $this->assertTrue($retrying->canRetry()); - - $dead = PeppolTransmissionStatus::DEAD; - $this->assertTrue($dead->isFinal()); - $this->assertFalse($dead->canRetry()); - } - - #[Test] - public function it_models_rejection_flow(): void - { - $rejected = PeppolTransmissionStatus::REJECTED; - $this->assertTrue($rejected->isFinal()); - $this->assertFalse($rejected->canRetry()); - $this->assertEquals('red', $rejected->color()); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php b/Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php deleted file mode 100644 index e6780f0f..00000000 --- a/Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php +++ /dev/null @@ -1,155 +0,0 @@ -assertCount(4, $cases); - $this->assertContains(PeppolValidationStatus::VALID, $cases); - $this->assertContains(PeppolValidationStatus::INVALID, $cases); - $this->assertContains(PeppolValidationStatus::NOT_FOUND, $cases); - $this->assertContains(PeppolValidationStatus::ERROR, $cases); - } - - #[Test] - #[DataProvider('labelProvider')] - public function it_provides_correct_labels( - PeppolValidationStatus $status, - string $expectedLabel - ): void { - $this->assertEquals($expectedLabel, $status->label()); - } - - public static function labelProvider(): array - { - return [ - [PeppolValidationStatus::VALID, 'Valid'], - [PeppolValidationStatus::INVALID, 'Invalid'], - [PeppolValidationStatus::NOT_FOUND, 'Not Found'], - [PeppolValidationStatus::ERROR, 'Error'], - ]; - } - - #[Test] - #[DataProvider('colorProvider')] - public function it_provides_correct_colors( - PeppolValidationStatus $status, - string $expectedColor - ): void { - $this->assertEquals($expectedColor, $status->color()); - } - - public static function colorProvider(): array - { - return [ - [PeppolValidationStatus::VALID, 'green'], - [PeppolValidationStatus::INVALID, 'red'], - [PeppolValidationStatus::NOT_FOUND, 'orange'], - [PeppolValidationStatus::ERROR, 'red'], - ]; - } - - #[Test] - #[DataProvider('iconProvider')] - public function it_provides_correct_icons( - PeppolValidationStatus $status, - string $expectedIcon - ): void { - $this->assertEquals($expectedIcon, $status->icon()); - } - - public static function iconProvider(): array - { - return [ - [PeppolValidationStatus::VALID, 'heroicon-o-check-circle'], - [PeppolValidationStatus::INVALID, 'heroicon-o-x-circle'], - [PeppolValidationStatus::NOT_FOUND, 'heroicon-o-question-mark-circle'], - [PeppolValidationStatus::ERROR, 'heroicon-o-exclamation-triangle'], - ]; - } - - #[Test] - #[DataProvider('valueProvider')] - public function it_has_correct_enum_values( - PeppolValidationStatus $status, - string $expectedValue - ): void { - $this->assertEquals($expectedValue, $status->value); - } - - public static function valueProvider(): array - { - return [ - [PeppolValidationStatus::VALID, 'valid'], - [PeppolValidationStatus::INVALID, 'invalid'], - [PeppolValidationStatus::NOT_FOUND, 'not_found'], - [PeppolValidationStatus::ERROR, 'error'], - ]; - } - - #[Test] - public function it_can_be_instantiated_from_value(): void - { - $status = PeppolValidationStatus::from('valid'); - - $this->assertEquals(PeppolValidationStatus::VALID, $status); - } - - #[Test] - public function it_throws_on_invalid_value(): void - { - $this->expectException(\ValueError::class); - PeppolValidationStatus::from('unknown'); - } - - #[Test] - public function it_distinguishes_success_from_error_states(): void - { - $valid = PeppolValidationStatus::VALID; - $this->assertEquals('green', $valid->color()); - - $invalid = PeppolValidationStatus::INVALID; - $this->assertEquals('red', $invalid->color()); - - $notFound = PeppolValidationStatus::NOT_FOUND; - $this->assertEquals('orange', $notFound->color()); - - $error = PeppolValidationStatus::ERROR; - $this->assertEquals('red', $error->color()); - } - - #[Test] - public function it_provides_appropriate_visual_indicators(): void - { - $valid = PeppolValidationStatus::VALID; - $this->assertStringContainsString('check-circle', $valid->icon()); - - $invalid = PeppolValidationStatus::INVALID; - $this->assertStringContainsString('x-circle', $invalid->icon()); - - $notFound = PeppolValidationStatus::NOT_FOUND; - $this->assertStringContainsString('question-mark-circle', $notFound->icon()); - - $error = PeppolValidationStatus::ERROR; - $this->assertStringContainsString('exclamation-triangle', $error->icon()); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php b/Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php deleted file mode 100644 index e578a533..00000000 --- a/Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php +++ /dev/null @@ -1,175 +0,0 @@ -assertInstanceOf(PeppolBisHandler::class, $handler); - $this->assertInstanceOf(InvoiceFormatHandlerInterface::class, $handler); - } - - #[Test] - public function it_creates_ubl_21_handler(): void - { - $handler = FormatHandlerFactory::create(PeppolDocumentFormat::UBL_21); - - $this->assertInstanceOf(UblHandler::class, $handler); - $this->assertInstanceOf(InvoiceFormatHandlerInterface::class, $handler); - } - - #[Test] - public function it_creates_ubl_24_handler(): void - { - $handler = FormatHandlerFactory::create(PeppolDocumentFormat::UBL_24); - - $this->assertInstanceOf(UblHandler::class, $handler); - $this->assertInstanceOf(InvoiceFormatHandlerInterface::class, $handler); - } - - #[Test] - public function it_creates_cii_handler(): void - { - $handler = FormatHandlerFactory::create(PeppolDocumentFormat::CII); - - $this->assertInstanceOf(CiiHandler::class, $handler); - $this->assertInstanceOf(InvoiceFormatHandlerInterface::class, $handler); - } - - #[Test] - public function it_throws_exception_for_unsupported_format(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No handler available for format'); - - FormatHandlerFactory::create(PeppolDocumentFormat::FATTURAPA_12); - } - - #[Test] - public function it_can_check_if_handler_exists(): void - { - $this->assertTrue(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::PEPPOL_BIS_30)); - $this->assertTrue(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::UBL_21)); - $this->assertTrue(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::UBL_24)); - $this->assertTrue(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::CII)); - - $this->assertFalse(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::FATTURAPA_12)); - $this->assertFalse(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::FACTURAE_32)); - } - - #[Test] - public function it_returns_registered_handlers(): void - { - $handlers = FormatHandlerFactory::getRegisteredHandlers(); - - $this->assertIsArray($handlers); - $this->assertArrayHasKey('peppol_bis_3.0', $handlers); - $this->assertArrayHasKey('ubl_2.1', $handlers); - $this->assertArrayHasKey('ubl_2.4', $handlers); - $this->assertArrayHasKey('cii', $handlers); - - $this->assertEquals(PeppolBisHandler::class, $handlers['peppol_bis_3.0']); - $this->assertEquals(UblHandler::class, $handlers['ubl_2.1']); - $this->assertEquals(CiiHandler::class, $handlers['cii']); - } - - #[Test] - public function it_creates_handler_from_format_string(): void - { - $handler = FormatHandlerFactory::make('peppol_bis_3.0'); - - $this->assertInstanceOf(PeppolBisHandler::class, $handler); - } - - #[Test] - public function it_throws_exception_for_invalid_format_string(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Invalid format'); - - FormatHandlerFactory::make('invalid_format_string'); - } - - #[Test] - public function it_can_register_custom_handler(): void - { - $customHandlerClass = new class implements InvoiceFormatHandlerInterface { - public function generateXml(array $invoiceData): string - { - (void)$invoiceData; - return ''; - } - - public function validate(array $_invoiceData): array - { - (void)$_invoiceData; - return ['valid' => true, 'errors' => []]; - } - - public function getFormat(): PeppolDocumentFormat - { - return PeppolDocumentFormat::FATTURAPA_12; - } - }; - - FormatHandlerFactory::register( - PeppolDocumentFormat::FATTURAPA_12, - get_class($customHandlerClass) - ); - - $this->assertTrue(FormatHandlerFactory::hasHandler(PeppolDocumentFormat::FATTURAPA_12)); - } - - #[Test] - public function it_uses_same_handler_for_ubl_versions(): void - { - $handler21 = FormatHandlerFactory::create(PeppolDocumentFormat::UBL_21); - $handler24 = FormatHandlerFactory::create(PeppolDocumentFormat::UBL_24); - - // Both should be UBL handlers - $this->assertInstanceOf(UblHandler::class, $handler21); - $this->assertInstanceOf(UblHandler::class, $handler24); - - // They should be the same class - $this->assertEquals(get_class($handler21), get_class($handler24)); - } - - #[Test] - public function it_resolves_handlers_via_service_container(): void - { - // The factory should use app() to resolve handlers - $handler = FormatHandlerFactory::create(PeppolDocumentFormat::PEPPOL_BIS_30); - - $this->assertInstanceOf(InvoiceFormatHandlerInterface::class, $handler); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php b/Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php deleted file mode 100644 index 88e433c3..00000000 --- a/Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php +++ /dev/null @@ -1,194 +0,0 @@ -assertIsArray($providers); - $this->assertNotEmpty($providers); - - // Should have at least the two included providers - $this->assertArrayHasKey('e_invoice_be', $providers); - $this->assertArrayHasKey('storecove', $providers); - } - - #[Test] - public function it_provides_friendly_provider_names(): void - { - $providers = ProviderFactory::getAvailableProviders(); - - // Names should be human-readable - $this->assertEquals('E Invoice Be', $providers['e_invoice_be']); - $this->assertEquals('Storecove', $providers['storecove']); - } - - #[Test] - public function it_checks_if_provider_is_supported(): void - { - $this->assertTrue(ProviderFactory::isSupported('e_invoice_be')); - $this->assertTrue(ProviderFactory::isSupported('storecove')); - $this->assertFalse(ProviderFactory::isSupported('non_existent_provider')); - } - - #[Test] - public function it_creates_provider_from_name_with_integration(): void - { - $integration = new PeppolIntegration([ - 'provider_name' => 'e_invoice_be', - 'company_id' => 1, - ]); - - $provider = ProviderFactory::make($integration); - - $this->assertInstanceOf(ProviderInterface::class, $provider); - $this->assertInstanceOf(EInvoiceBeProvider::class, $provider); - } - - #[Test] - public function it_creates_provider_from_name_string(): void - { - $provider = ProviderFactory::makeFromName('e_invoice_be'); - - $this->assertInstanceOf(ProviderInterface::class, $provider); - $this->assertInstanceOf(EInvoiceBeProvider::class, $provider); - } - - #[Test] - public function it_creates_storecove_provider(): void - { - $provider = ProviderFactory::makeFromName('storecove'); - - $this->assertInstanceOf(ProviderInterface::class, $provider); - $this->assertInstanceOf(StorecoveProvider::class, $provider); - } - - #[Test] - public function it_throws_exception_for_unknown_provider(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown Peppol provider'); - - ProviderFactory::makeFromName('unknown_provider'); - } - - #[Test] - public function it_caches_discovered_providers(): void - { - // First call discovers providers - $providers1 = ProviderFactory::getAvailableProviders(); - - // Second call should use cache (same result) - $providers2 = ProviderFactory::getAvailableProviders(); - - $this->assertEquals($providers1, $providers2); - } - - #[Test] - public function it_can_clear_provider_cache(): void - { - // Discover providers - $providers1 = ProviderFactory::getAvailableProviders(); - - // Clear cache - ProviderFactory::clearCache(); - - // Re-discover - $providers2 = ProviderFactory::getAvailableProviders(); - - // Should get same providers but through fresh discovery - $this->assertEquals($providers1, $providers2); - } - - #[Test] - public function it_only_discovers_concrete_provider_classes(): void - { - $providers = ProviderFactory::getAvailableProviders(); - - // All discovered providers should be instantiable - foreach (array_keys($providers) as $providerKey) { - $this->assertTrue(ProviderFactory::isSupported($providerKey)); - } - } - - #[Test] - public function it_converts_directory_names_to_snake_case_keys(): void - { - $providers = ProviderFactory::getAvailableProviders(); - - // Directory 'EInvoiceBe' becomes 'e_invoice_be' - $this->assertArrayHasKey('e_invoice_be', $providers); - - // Directory 'Storecove' becomes 'storecove' - $this->assertArrayHasKey('storecove', $providers); - } - - #[Test] - public function it_discovers_providers_implementing_interface(): void - { - $providers = ProviderFactory::getAvailableProviders(); - - foreach (array_keys($providers) as $providerKey) { - $provider = ProviderFactory::makeFromName($providerKey); - $this->assertInstanceOf(ProviderInterface::class, $provider); - } - } - - #[Test] - public function it_passes_integration_to_provider_constructor(): void - { - $integration = new PeppolIntegration([ - 'provider_name' => 'e_invoice_be', - 'company_id' => 1, - 'enabled' => true, - ]); - - $provider = ProviderFactory::make($integration); - - $this->assertInstanceOf(EInvoiceBeProvider::class, $provider); - } - - #[Test] - public function it_handles_null_integration_gracefully(): void - { - $provider = ProviderFactory::makeFromName('e_invoice_be', null); - - $this->assertInstanceOf(ProviderInterface::class, $provider); - } -} \ No newline at end of file diff --git a/Modules/Invoices/Traits/LogsPeppolActivity.php b/Modules/Invoices/Traits/LogsPeppolActivity.php deleted file mode 100644 index 7a5dd66f..00000000 --- a/Modules/Invoices/Traits/LogsPeppolActivity.php +++ /dev/null @@ -1,75 +0,0 @@ - static::class, - ], $context); - - Log::{$level}("[Peppol] {$message}", $context); - } - - /** - * Log a Peppol informational message. - * - * @param string $message The message to record. - * @param array $context Additional context to include in the log entry; merged with the default Peppol context. - */ - protected function logPeppolInfo(string $message, array $context = []): void - { - $this->logPeppol('info', $message, $context); - } - - /** - * Log a Peppol-related error message. - * - * @param string $message The error message to record. - * @param array $context Optional additional context; merged with a default `component` key identifying the implementing class. - */ - protected function logPeppolError(string $message, array $context = []): void - { - $this->logPeppol('error', $message, $context); - } - - /** - * Log a Peppol-related message with warning severity. - * - * The provided context is merged with a `component` entry containing the implementing class name. - * - * @param string $message The log message. - * @param array $context Additional contextual data to include with the log entry. - */ - protected function logPeppolWarning(string $message, array $context = []): void - { - $this->logPeppol('warning', $message, $context); - } - - /** - * Log a Peppol debug message. - * - * The provided context will be merged with a `component` field set to the implementing class. - * - * @param string $message The log message. - * @param array $context Additional context to include with the log entry. - */ - protected function logPeppolDebug(string $message, array $context = []): void - { - $this->logPeppol('debug', $message, $context); - } -} \ No newline at end of file diff --git a/PEPPOL_ARCHITECTURE.md b/PEPPOL_ARCHITECTURE.md deleted file mode 100644 index 696583db..00000000 --- a/PEPPOL_ARCHITECTURE.md +++ /dev/null @@ -1,447 +0,0 @@ -# PEPPOL E-Invoicing Architecture - Implementation Summary - -## Overview - -This document provides a comprehensive summary of the PEPPOL e-invoicing architecture implemented in InvoicePlane v2. The implementation follows the detailed specification provided and includes all major components for a production-ready PEPPOL integration. - -## Architecture Components Implemented - -### 1. Database Layer ✅ - -#### Migrations Created: -- `2025_10_02_000001_create_peppol_integrations_table.php` -- `2025_10_02_000002_create_peppol_transmissions_table.php` -- `2025_10_02_000003_create_customer_peppol_validation_history_table.php` -- `2025_10_02_000004_add_peppol_validation_fields_to_relations_table.php` - -#### Models Created: -- `PeppolIntegration` - Manages provider configurations with encrypted API tokens -- `PeppolTransmission` - Tracks invoice transmission lifecycle with state machine methods -- `CustomerPeppolValidationHistory` - Audits all customer Peppol ID validations -- Updated `Relation` (Customer) model with Peppol fields and validation status - -### 2. Provider Abstraction Layer ✅ - -#### Core Interfaces & Factories: -- `ProviderInterface` - Contract that all providers must implement -- `ProviderFactory` - Factory pattern for creating provider instances -- `BaseProvider` - Abstract base with common functionality - -#### Provider Implementations: -- `EInvoiceBeProvider` - Complete e-invoice.be integration using existing clients -- `StorecoveProvider` - Placeholder for Storecove (ready for implementation) - -**Provider Methods:** -- `testConnection()` - Validate provider credentials -- `validatePeppolId()` - Check if participant exists in network -- `sendInvoice()` - Submit invoice to Peppol network -- `getTransmissionStatus()` - Poll for acknowledgements -- `cancelDocument()` - Cancel pending transmissions -- `classifyError()` - Categorize errors as TRANSIENT/PERMANENT/UNKNOWN - -### 3. Events & Audit Trail ✅ - -**Events Implemented:** -- `PeppolIntegrationCreated` -- `PeppolIntegrationTested` -- `PeppolIdValidationCompleted` -- `PeppolTransmissionCreated` -- `PeppolTransmissionPrepared` -- `PeppolTransmissionSent` -- `PeppolTransmissionFailed` -- `PeppolAcknowledgementReceived` -- `PeppolTransmissionDead` - -**Audit Logging:** -- `LogPeppolEventToAudit` listener logs all events to `audit_log` table -- Complete event payload preserved for compliance - -### 4. Background Jobs & Queue Processing ✅ - -**Jobs Implemented:** -- `SendInvoiceToPeppolJob` - Main orchestration job for sending invoices - - Pre-send validation - - Idempotency guards - - Artifact generation (XML/PDF) - - Provider transmission - - Retry scheduling with exponential backoff - -- `PeppolStatusPoller` - Polls providers for acknowledgements - - Batch processes transmissions awaiting ACK - - Updates status to accepted/rejected - -- `RetryFailedTransmissions` - Retry scheduler - - Respects max attempts limit - - Marks as dead when exceeded - -**Console Commands:** -- `peppol:poll-status` - Dispatch status polling job -- `peppol:retry-failed` - Dispatch retry job -- `peppol:test-integration` - Test connection for an integration - -### 5. Services & Business Logic ✅ - -**PeppolManagementService:** -- `createIntegration()` - Create new provider integration -- `testConnection()` - Test provider connectivity -- `validatePeppolId()` - Validate customer Peppol ID with provider -- `sendInvoice()` - Queue invoice for sending -- `getActiveIntegration()` - Get enabled integration for company -- `suggestPeppolScheme()` - Auto-suggest scheme from country - -**PeppolTransformerService:** -- Transforms Invoice models to Peppol-compatible data structures -- Extracts supplier, customer, line items, tax totals -- Formats dates, amounts, and codes per Peppol requirements - -### 6. State Machine Implementation ✅ - -**Transmission States:** -``` -pending → queued → processing → sent → accepted - ↘ rejected - ↘ failed → retrying → (back to processing or dead) -``` - -**State Machine Methods on PeppolTransmission:** -- `markAsSent()` - Transition to sent state -- `markAsAccepted()` - Final success state -- `markAsRejected()` - Final rejection state -- `markAsFailed()` - Temporary failure -- `scheduleRetry()` - Schedule next retry attempt -- `markAsDead()` - Permanent failure after max retries - -**State Checks:** -- `isFinal()` - Check if in terminal state -- `canRetry()` - Check if retry is allowed -- `isAwaitingAck()` - Check if waiting for acknowledgement - -### 7. Error Handling & Classification ✅ - -**Error Types:** -- `TRANSIENT` - 5xx errors, timeouts, rate limits (retryable) -- `PERMANENT` - 4xx errors, invalid data, auth failures (not retryable) -- `UNKNOWN` - Ambiguous errors (retry with caution) - -**Retry Policy:** -- Exponential backoff: 1min, 5min, 30min, 2h, 6h -- Configurable max attempts (default: 5) -- Automatic dead-letter marking after max attempts -- Manual retry capability via UI actions - -### 8. Configuration ✅ - -**Comprehensive Config in `Modules/Invoices/Config/config.php`:** - -- Provider settings (e-invoice.be, Storecove) -- Document settings (currency, unit codes) -- Supplier (company) defaults -- Format configuration -- Validation rules -- Feature flags -- **Country-to-Scheme mapping** for auto-suggestion -- **Retry policy** configuration -- **Storage** settings for artifacts -- **Monitoring** thresholds and alerts - -### 9. Storage & Artifacts ✅ - -**Storage Structure:** -``` -peppol/{integration_id}/{year}/{month}/{transmission_id}/ - - invoice.xml - - invoice.pdf -``` - -**Implemented in SendInvoiceToPeppolJob:** -- Generates XML using format handlers -- Stores XML and PDF to configured disk -- Records paths in transmission record -- Configurable retention period - -### 10. Idempotency & Concurrency ✅ - -**Idempotency:** -- Unique idempotency key calculated from: `hash(invoice_id|customer_peppol_id|integration_id|updated_at)` -- Prevents duplicate transmissions -- Database unique constraint on `idempotency_key` - -**Implemented in:** -- `SendInvoiceToPeppolJob::calculateIdempotencyKey()` -- `SendInvoiceToPeppolJob::getOrCreateTransmission()` - -## Architecture Patterns Used - -1. **Strategy Pattern** - Format handlers (via existing FormatHandlerFactory) -2. **Factory Pattern** - Provider creation (ProviderFactory) -3. **Repository Pattern** - Eloquent models with business logic methods -4. **Event Sourcing** - Complete audit trail via events -5. **State Machine** - Transmission lifecycle management -6. **Job Queue Pattern** - Async processing with retry logic -7. **Service Layer Pattern** - Business logic encapsulation - -## Key Design Decisions - -### 1. Two-Level Storage for Validation Results -- **Quick lookup:** `peppol_validation_status` on customer table -- **Full audit:** `CustomerPeppolValidationHistory` table -- Rationale: UI performance + compliance requirements - -### 2. Idempotency at Job Level -- Prevents race conditions -- Safe to retry jobs -- Deterministic key based on invoice content - -### 3. Provider Abstraction -- Easy to add new providers -- Normalized error handling -- Uniform interface for UI - -### 4. Event-Driven Architecture -- Decoupled components -- Complete audit trail -- Easy to add notifications/webhooks - -### 5. Exponential Backoff -- Respects provider rate limits -- Improves success rate -- Prevents thundering herd - -## Implementation Status - -### ✅ Completed - -- [x] Database migrations (4 tables) -- [x] Models (3 new + 1 updated) -- [x] Provider abstraction (interface + factory + base + 1 complete implementation) -- [x] Events (9 lifecycle events) -- [x] Jobs (3 background jobs) -- [x] Services (2 services) -- [x] Console commands (3 commands) -- [x] Audit listener -- [x] Configuration -- [x] State machine -- [x] Error classification -- [x] Retry policy -- [x] Idempotency -- [x] Storage structure - -### 🚧 Partial / Needs UI Integration - -- [ ] Filament Resources (PeppolIntegration CRUD) -- [ ] Customer Peppol validation UI -- [ ] Invoice send action -- [ ] Transmission status dashboard -- [ ] Webhook receiver endpoint -- [ ] Dashboard widgets - -### 📝 TODO (Additional Enhancements) - -- [ ] Additional provider implementations (Storecove, Peppol Connect, etc.) -- [ ] PDF generation for Factur-X embedded invoices -- [ ] Webhook signature verification -- [ ] Metrics collection (Prometheus/StatsD) -- [ ] Alert notifications (Slack/Email) -- [ ] Bulk sending capability -- [ ] Credit note support -- [ ] Reconciliation reports -- [ ] Rate limiting per provider - -## Usage Examples - -### Creating an Integration - -```php -use Modules\Invoices\Peppol\Services\PeppolManagementService; - -$service = app(PeppolManagementService::class); - -$integration = $service->createIntegration( - companyId: 1, - providerName: 'e_invoice_be', - config: ['base_url' => 'https://api.e-invoice.be'], - apiToken: 'your-api-key' -); - -// Test connection -$result = $service->testConnection($integration); -if ($result['ok']) { - $integration->update(['enabled' => true]); -} -``` - -### Validating Customer Peppol ID - -```php -$result = $service->validatePeppolId( - customer: $customer, - integration: $integration, - validatedBy: auth()->id() -); - -if ($result['valid']) { - // Customer can receive Peppol invoices -} -``` - -### Sending an Invoice - -```php -$integration = $service->getActiveIntegration($invoice->company_id); - -if ($integration && $invoice->customer->hasPeppolIdValidated()) { - $service->sendInvoice($invoice, $integration); - // Job is queued, will execute asynchronously -} -``` - -### Checking Transmission Status - -```php -$transmission = PeppolTransmission::where('invoice_id', $invoice->id)->first(); - -if ($transmission->status === PeppolTransmission::STATUS_ACCEPTED) { - // Invoice delivered successfully -} elseif ($transmission->status === PeppolTransmission::STATUS_DEAD) { - // Manual intervention required -} -``` - -## Scheduled Tasks Setup - -Add to `app/Console/Kernel.php`: - -```php -protected function schedule(Schedule $schedule) -{ - // Poll for status updates every 15 minutes - $schedule->command('peppol:poll-status') - ->everyFifteenMinutes() - ->withoutOverlapping(); - - // Retry failed transmissions every minute - $schedule->command('peppol:retry-failed') - ->everyMinute() - ->withoutOverlapping(); -} -``` - -## Security Considerations - -1. **API Keys** - Encrypted at rest using Laravel's encryption -2. **Webhook Verification** - TODO: Implement signature verification -3. **Storage Encryption** - Can be enabled via Laravel filesystem config -4. **Access Control** - TODO: Implement Filament policies -5. **Audit Trail** - All actions logged with user attribution - -## Performance Considerations - -1. **Queue Processing** - All heavy operations are queued -2. **Batch Operations** - Status polling and retries process in batches (50-100) -3. **Database Indexes** - Strategic indexes on status, external_id, next_retry_at -4. **Caching** - Can add integration caching to reduce DB queries -5. **Storage** - Uses Laravel's filesystem abstraction (can use S3, etc.) - -## Monitoring & Alerting - -**Metrics to Track:** -- Transmissions per hour/day -- Success rate by provider -- Average time to acknowledgement -- Dead transmission count -- Retry rate -- Provider response times - -**Alert Triggers:** -- Integration connection test failures -- More than 10 dead transmissions in 1 hour -- Provider authentication failures -- Transmissions stuck in "sent" > 7 days - -## Next Steps for Full Production Readiness - -1. **UI Development** - Build Filament resources and actions -2. **Webhook Implementation** - Add signed webhook receiver -3. **Additional Providers** - Implement Storecove, Peppol Connect -4. **Testing** - Unit and integration tests for critical paths -5. **Monitoring** - Integrate with application monitoring (New Relic, Datadog, etc.) -6. **Documentation** - API documentation, deployment guide -7. **DevOps** - Queue worker configuration, scaling strategy - -## File Structure - -``` -Modules/Invoices/ -├── Models/ -│ ├── PeppolIntegration.php -│ ├── PeppolTransmission.php -│ └── CustomerPeppolValidationHistory.php -├── Peppol/ -│ ├── Contracts/ -│ │ └── ProviderInterface.php -│ ├── Providers/ -│ │ ├── BaseProvider.php -│ │ ├── ProviderFactory.php -│ │ ├── EInvoiceBe/ -│ │ │ └── EInvoiceBeProvider.php -│ │ └── Storecove/ -│ │ └── StorecoveProvider.php -│ └── Services/ -│ ├── PeppolManagementService.php -│ └── PeppolTransformerService.php -├── Events/Peppol/ -│ ├── PeppolEvent.php (base) -│ ├── PeppolIntegrationCreated.php -│ ├── PeppolIntegrationTested.php -│ ├── PeppolIdValidationCompleted.php -│ ├── PeppolTransmissionCreated.php -│ ├── PeppolTransmissionPrepared.php -│ ├── PeppolTransmissionSent.php -│ ├── PeppolTransmissionFailed.php -│ ├── PeppolAcknowledgementReceived.php -│ └── PeppolTransmissionDead.php -├── Jobs/Peppol/ -│ ├── SendInvoiceToPeppolJob.php -│ ├── PeppolStatusPoller.php -│ └── RetryFailedTransmissions.php -├── Listeners/Peppol/ -│ └── LogPeppolEventToAudit.php -├── Console/Commands/ -│ ├── PollPeppolStatusCommand.php -│ ├── RetryFailedPeppolTransmissionsCommand.php -│ └── TestPeppolIntegrationCommand.php -└── Database/Migrations/ - ├── 2025_10_02_000001_create_peppol_integrations_table.php - ├── 2025_10_02_000002_create_peppol_transmissions_table.php - └── 2025_10_02_000003_create_customer_peppol_validation_history_table.php - -Modules/Clients/Database/Migrations/ -└── 2025_10_02_000004_add_peppol_validation_fields_to_relations_table.php -``` - -## Total Lines of Code - -- **Production Code**: ~4,500 lines -- **Migrations**: ~200 lines -- **Configuration**: ~230 lines -- **Events**: ~600 lines -- **Jobs**: ~400 lines -- **Commands**: ~120 lines - -**Total**: ~6,000+ lines of production-ready code - -## Conclusion - -This implementation provides a comprehensive, production-ready PEPPOL e-invoicing architecture following all specifications from the problem statement. It includes: - -- Complete database schema with proper relationships -- Robust state machine for transmission lifecycle -- Provider abstraction supporting multiple access points -- Comprehensive error handling and retry logic -- Full audit trail via events -- Background job processing with queues -- Idempotency and concurrency safety -- Extensive configuration options -- Console commands for operations - -The architecture is modular, testable, and ready for extension with additional providers, UI components, and monitoring integrations. diff --git a/PEPPOL_TESTS_SUMMARY.md b/PEPPOL_TESTS_SUMMARY.md deleted file mode 100644 index 75c442de..00000000 --- a/PEPPOL_TESTS_SUMMARY.md +++ /dev/null @@ -1,367 +0,0 @@ -# PEPPOL Architecture Components - Unit Tests Summary - -This document summarizes the comprehensive unit tests generated for the PEPPOL architecture components added in this branch. - -## Test Coverage Overview - -### ✅ Enum Tests (5 files) - -#### 1. PeppolConnectionStatusTest -**Location:** `Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php` - -**Coverage:** -- All 3 enum cases (UNTESTED, SUCCESS, FAILED) -- Label generation for UI display -- Color coding (gray, green, red) -- Icon mapping (Heroicon identifiers) -- Enum value validation -- Match expression compatibility -- Selection option generation - -**Key Test Scenarios:** -- ✓ Correct case enumeration -- ✓ Human-readable labels -- ✓ UI color assignments -- ✓ Icon identifiers -- ✓ Value-based instantiation -- ✓ Invalid value handling -- ✓ Try-from with null return -- ✓ Match expression usage - -#### 2. PeppolErrorTypeTest -**Location:** `Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php` - -**Coverage:** -- All 3 error types (TRANSIENT, PERMANENT, UNKNOWN) -- Error classification for retry logic -- Visual indicators for error severity -- Upper-case enum values - -**Key Test Scenarios:** -- ✓ Error type enumeration -- ✓ Transient vs permanent distinction -- ✓ Retry-ability indication through colors -- ✓ Warning vs error icon mapping - -#### 3. PeppolTransmissionStatusTest -**Location:** `Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php` - -**Coverage:** -- All 9 transmission statuses -- Lifecycle state methods (isFinal, canRetry, isAwaitingAck) -- Complete transmission flow modeling -- Failure and retry logic -- Rejection handling - -**Key Test Scenarios:** -- ✓ Full status enumeration (9 cases) -- ✓ Final status identification (ACCEPTED, REJECTED, DEAD) -- ✓ Retryable status identification (FAILED, RETRYING) -- ✓ Acknowledgement-waiting status (SENT) -- ✓ Successful transmission lifecycle -- ✓ Failure and retry flow -- ✓ Rejection flow -- ✓ Color and icon appropriateness - -#### 4. PeppolValidationStatusTest -**Location:** `Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php` - -**Coverage:** -- All 4 validation statuses -- Success vs error state distinction -- Visual feedback for validation results - -**Key Test Scenarios:** -- ✓ Validation status enumeration -- ✓ Success (green) vs error (red) distinction -- ✓ Not found (orange) warning state -- ✓ Appropriate icon selection -- ✓ Clear visual indicators - -#### 5. PeppolEndpointSchemeTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/Enums/PeppolEndpointSchemeTest.php` - -**Coverage:** -- All 17 participant identifier schemes -- Country-to-scheme mapping -- Format validation for each scheme -- Identifier formatting rules - -**Key Test Scenarios:** -- ✓ Complete scheme enumeration (17 schemes) -- ✓ Country code mapping (BE→BE_CBE, IT→IT_VAT, etc.) -- ✓ Default to ISO_6523 for unknown countries -- ✓ Belgian CBE validation (10 digits) -- ✓ German VAT validation (DE + 9 digits) -- ✓ French SIRENE validation (9 or 14 digits) -- ✓ Italian VAT validation (IT + 11 digits) -- ✓ Italian Codice Fiscale (16 alphanumeric) -- ✓ Spanish NIF format (letter + digits + letter/digit) -- ✓ Swiss UID with flexible separators -- ✓ UK Companies House alphanumeric -- ✓ GLN (13 digits), DUNS (9 digits) -- ✓ Swedish formatting (adds hyphen) -- ✓ Finnish formatting (adds hyphen) -- ✓ ISO 6523 flexible validation -- ✓ Case-insensitive country handling - -### ✅ Factory Tests (2 files) - -#### 6. FormatHandlerFactoryTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php` - -**Coverage:** -- Handler creation for supported formats -- Handler existence checking -- Custom handler registration -- String-based format instantiation -- Service container integration - -**Key Test Scenarios:** -- ✓ PEPPOL BIS 3.0 handler creation -- ✓ UBL 2.1 handler creation -- ✓ UBL 2.4 handler creation (same as 2.1) -- ✓ CII handler creation -- ✓ Exception for unsupported formats -- ✓ hasHandler() validation -- ✓ getRegisteredHandlers() enumeration -- ✓ make() from format string -- ✓ Invalid format string exception -- ✓ Custom handler registration -- ✓ Service container resolution - -#### 7. ProviderFactoryTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php` - -**Coverage:** -- Provider discovery -- Provider instantiation -- Cache management -- Integration model passing - -**Key Test Scenarios:** -- ✓ Automatic provider discovery -- ✓ Friendly provider name generation -- ✓ isSupported() check -- ✓ EInvoiceBe provider creation -- ✓ Storecove provider creation -- ✓ Integration model passing -- ✓ String-based provider creation -- ✓ Unknown provider exception -- ✓ Provider cache functionality -- ✓ Cache clearing -- ✓ Directory-to-snake_case conversion -- ✓ Interface implementation verification -- ✓ Null integration handling - -### ✅ Existing Tests (Already in Repository) - -#### 8. PeppolDocumentFormatTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/Enums/PeppolDocumentFormatTest.php` - -**Coverage:** -- All 11 document formats -- Country-based recommendations -- Mandatory format detection -- Format values and labels - -#### 9. SendInvoiceToPeppolActionTest -**Location:** `Modules/Invoices/Tests/Unit/Actions/SendInvoiceToPeppolActionTest.php` - -**Coverage:** -- Invoice transmission action -- HTTP response handling -- Validation and error handling - -#### 10. ApiClientTest -**Location:** `Modules/Invoices/Tests/Unit/Http/Clients/ApiClientTest.php` - -**Coverage:** -- HTTP client wrapper -- Request/response handling - -#### 11. HttpClientExceptionHandlerTest -**Location:** `Modules/Invoices/Tests/Unit/Http/Decorators/HttpClientExceptionHandlerTest.php` - -**Coverage:** -- Exception handling decorator -- Error transformation - -#### 12. DocumentsClientTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/Clients/DocumentsClientTest.php` - -**Coverage:** -- Document submission client -- API endpoint integration - -#### 13. PeppolServiceTest -**Location:** `Modules/Invoices/Tests/Unit/Peppol/Services/PeppolServiceTest.php` - -**Coverage:** -- Core Peppol service operations -- Integration orchestration - -## Test Statistics - -### Total Test Files Created: 7 new files - -### Total Test Methods: ~150+ test methods - -### Coverage by Category: -- **Enums:** 5 test files, ~95 test methods -- **Factories:** 2 test files, ~30 test methods -- **Actions:** 1 existing file -- **HTTP Clients:** 2 existing files -- **Services:** 2 existing files - -## Testing Best Practices Applied - -### 1. **Data Providers** -All tests use PHPUnit's `#[DataProvider]` attribute for parameterized testing: -```php -#[Test] -#[DataProvider('labelProvider')] -public function it_provides_correct_labels( - PeppolConnectionStatus $status, - string $expectedLabel -): void { - $this->assertEquals($expectedLabel, $status->label()); -} -``` - -### 2. **Group Tags** -All Peppol tests are tagged with `#[Group('peppol')]` for selective execution: -```php -#[Group('peppol')] -class PeppolConnectionStatusTest extends TestCase -``` - -### 3. **Descriptive Test Names** -Following "it_should" convention for clarity: -- `it_has_all_expected_cases()` -- `it_provides_correct_labels()` -- `it_validates_correct_identifiers()` -- `it_throws_exception_for_unsupported_format()` - -### 4. **Comprehensive Documentation** -Each test class includes PHPDoc explaining: -- Purpose and scope -- What's being tested -- Package namespace - -### 5. **Edge Case Coverage** -Tests include: -- Valid inputs -- Invalid inputs -- Null handling -- Empty strings -- Boundary conditions -- Case sensitivity - -### 6. **Business Logic Testing** -Tests verify: -- Transmission lifecycle (pending → sent → accepted) -- Retry logic (failed → retrying → dead) -- Error classification (transient vs permanent) -- Country-specific rules -- Format validation patterns - -## Running the Tests - -### Run All Peppol Tests -```bash -./vendor/bin/phpunit --group=peppol -``` - -### Run Specific Test Suite -```bash -# Enum tests only -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/ - -# Factory tests only -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/ -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/Providers/ - -# All Peppol-related tests -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/ -``` - -### Run Single Test File -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php -``` - -### Run with Coverage -```bash -./vendor/bin/phpunit --group=peppol --coverage-html coverage/ -``` - -## Test Quality Metrics - -### Assertions per Test -- Average: 3-5 assertions per test method -- Range: 1-10 assertions - -### Test Method Length -- Average: 5-15 lines per method -- Focus on single responsibility - -### Code Coverage Goals -- **Enums:** ~100% coverage (pure functions, no external dependencies) -- **Factories:** ~90% coverage (some discovery logic difficult to mock) -- **Overall Peppol Components:** Target 80%+ coverage - -## Future Test Enhancements - -### Recommended Additional Tests - -1. **Model Tests** (Not yet created) - - PeppolIntegration model - - PeppolTransmission model - - CustomerPeppolValidationHistory model - -2. **Job Tests** (Not yet created) - - SendInvoiceToPeppolJob - - PeppolStatusPoller - - RetryFailedTransmissions - -3. **Service Tests** (Partially covered) - - PeppolManagementService - - PeppolTransformerService - -4. **Event Tests** (Not yet created) - - All Peppol events with payload validation - -5. **Integration Tests** - - End-to-end transmission flow - - Provider integration - - Database persistence - -## Test Maintenance Notes - -### When Adding New Enum Cases -1. Add case to enum -2. Add to test's case count assertion -3. Add to label/color/icon data providers -4. Add to business logic tests if applicable - -### When Adding New Formats -1. Register in FormatHandlerFactory -2. Add to FormatHandlerFactoryTest -3. Update handler count assertions - -### When Adding New Providers -1. Create provider class -2. Provider will be auto-discovered -3. Add specific tests for new provider in ProviderFactoryTest - -## Conclusion - -The test suite provides comprehensive coverage for: -- ✅ All PEPPOL enum types with business logic -- ✅ Factory pattern implementations -- ✅ Validation rules for international identifiers -- ✅ Format handler selection -- ✅ Provider discovery and instantiation - -The tests follow Laravel and PHPUnit best practices, use modern PHP 8 attributes, and provide excellent documentation for future maintainers. \ No newline at end of file diff --git a/RUNNING_TESTS.md b/RUNNING_TESTS.md deleted file mode 100644 index 4251e954..00000000 --- a/RUNNING_TESTS.md +++ /dev/null @@ -1,94 +0,0 @@ -# Quick Reference: Running PEPPOL Tests - -## Prerequisites -```bash -cd /home/jailuser/git -composer install -``` - -## Run All Tests -```bash -# All tests in the project -./vendor/bin/phpunit - -# All Peppol tests only -./vendor/bin/phpunit --group=peppol - -# Run with ParaTest (parallel execution) -./vendor/bin/paratest --group=peppol -``` - -## Run Specific Test Suites - -### Enum Tests -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/ -``` - -### Factory Tests -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/FormatHandlerFactoryTest.php -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/Providers/ProviderFactoryTest.php -``` - -### Individual Test Files -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/PeppolConnectionStatusTest.php -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/PeppolErrorTypeTest.php -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/PeppolTransmissionStatusTest.php -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/PeppolValidationStatusTest.php -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/Enums/PeppolEndpointSchemeTest.php -``` - -## Run with Filters - -### Run specific test method -```bash -./vendor/bin/phpunit --filter it_has_all_expected_cases -./vendor/bin/phpunit --filter it_validates_correct_identifiers -``` - -### Run tests matching pattern -```bash -./vendor/bin/phpunit --filter "Test.*label" -./vendor/bin/phpunit --filter "Test.*validation" -``` - -## Coverage Reports - -### HTML Coverage Report -```bash -./vendor/bin/phpunit --group=peppol --coverage-html coverage/ -open coverage/index.html -``` - -### Text Coverage Summary -```bash -./vendor/bin/phpunit --group=peppol --coverage-text -``` - -### Coverage for Specific Directory -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/ --coverage-text -``` - -## Debugging Tests - -### Stop on Failure -```bash -./vendor/bin/phpunit --stop-on-failure --group=peppol -``` - -### Verbose Output -```bash -./vendor/bin/phpunit --verbose --group=peppol -``` - -### Debug Mode -```bash -./vendor/bin/phpunit --debug --group=peppol -``` - -## Test Results - -### Successful Test Output Example \ No newline at end of file diff --git a/TEST_GENERATION_SUMMARY.md b/TEST_GENERATION_SUMMARY.md deleted file mode 100644 index c34efaae..00000000 --- a/TEST_GENERATION_SUMMARY.md +++ /dev/null @@ -1,84 +0,0 @@ -# PEPPOL Unit Tests - Generation Summary - -## ✅ Tests Successfully Generated - -### New Test Files Created: 7 - -1. **PeppolConnectionStatusTest.php** - Tests connection status enum (3 cases, ~13 tests) - - Location: `Modules/Invoices/Tests/Unit/Enums/` - -2. **PeppolErrorTypeTest.php** - Tests error type classification (3 cases, ~10 tests) - - Location: `Modules/Invoices/Tests/Unit/Enums/` - -3. **PeppolTransmissionStatusTest.php** - Tests transmission lifecycle (9 cases, ~25 tests) - - Location: `Modules/Invoices/Tests/Unit/Enums/` - -4. **PeppolValidationStatusTest.php** - Tests validation status (4 cases, ~12 tests) - - Location: `Modules/Invoices/Tests/Unit/Enums/` - -5. **PeppolEndpointSchemeTest.php** - Tests participant identifiers (17 schemes, ~30 tests) - - Location: `Modules/Invoices/Tests/Unit/Peppol/Enums/` - -6. **FormatHandlerFactoryTest.php** - Tests format handler factory (~15 tests) - - Location: `Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/` - -7. **ProviderFactoryTest.php** - Tests provider factory (~18 tests) - - Location: `Modules/Invoices/Tests/Unit/Peppol/Providers/` - -## Test Coverage Summary - -- **Total New Tests:** ~125+ test methods -- **Enum Tests:** 5 files covering all PEPPOL enums -- **Factory Tests:** 2 files covering factory patterns -- **Existing Tests:** 6 files already present in repository - -## Key Features of Generated Tests - -✅ Data Provider pattern for parameterized testing -✅ Group tagging with #[Group('peppol')] -✅ Descriptive test names (it_should pattern) -✅ Comprehensive edge case coverage -✅ PHPUnit 10+ attributes (#[Test], #[DataProvider]) -✅ Proper documentation and comments - -## Running the Tests - -### Run all PEPPOL tests: -```bash -./vendor/bin/phpunit --group=peppol -``` - -### Run enum tests: -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Enums/ -``` - -### Run factory tests: -```bash -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/FormatHandlers/ -./vendor/bin/phpunit Modules/Invoices/Tests/Unit/Peppol/Providers/ -``` - -## Test Quality - -- Modern PHP 8+ syntax -- Laravel best practices -- Clear, maintainable code -- Comprehensive coverage of business logic -- Edge cases and error handling tested - -## Files Modified - -No existing files were modified. All tests are new additions. - -## Next Steps - -Consider adding tests for: -- Model classes (PeppolIntegration, PeppolTransmission, etc.) -- Job classes (SendInvoiceToPeppolJob, PeppolStatusPoller, etc.) -- Service classes (PeppolManagementService, PeppolTransformerService) -- Event classes (All Peppol events) - ---- - -Generated: $(date) \ No newline at end of file