Skip to content

Commit 0f18ac5

Browse files
nielsdrost7Copilotcoderabbitai[bot]
authored
Copilot/add peppol architecture components (#114)
* Initial plan * Add core PEPPOL database migrations, models, providers, and events Co-authored-by: nielsdrost7 <[email protected]> * Add Jobs, Services, and enhanced configuration for PEPPOL lifecycle management Co-authored-by: nielsdrost7 <[email protected]> * Add audit listener, console commands, and comprehensive architecture documentation Co-authored-by: nielsdrost7 <[email protected]> * Refactor PEPPOL to use enums, remove JSON columns, use key-value config, and logging trait Co-authored-by: nielsdrost7 <[email protected]> * Implement dynamic provider discovery by scanning Providers directory Co-authored-by: nielsdrost7 <[email protected]> * Update Modules/Invoices/Models/CustomerPeppolValidationHistory.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * 📝 Add docstrings to `copilot/add-peppol-architecture-components` (#105) Docstrings generation was requested by @nielsdrost7. * #104 (comment) The following files were modified: * `Modules/Clients/Database/Migrations/2025_10_02_000007_add_peppol_validation_fields_to_relations_table.php` * `Modules/Clients/Models/Relation.php` * `Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php` * `Modules/Invoices/Console/Commands/RetryFailedPeppolTransmissionsCommand.php` * `Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000001_create_peppol_integrations_table.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000002_create_peppol_integration_config_table.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000003_create_peppol_transmissions_table.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000004_create_peppol_transmission_responses_table.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000005_create_customer_peppol_validation_history_table.php` * `Modules/Invoices/Database/Migrations/2025_10_02_000006_create_customer_peppol_validation_responses_table.php` * `Modules/Invoices/Enums/PeppolConnectionStatus.php` * `Modules/Invoices/Enums/PeppolErrorType.php` * `Modules/Invoices/Enums/PeppolTransmissionStatus.php` * `Modules/Invoices/Enums/PeppolValidationStatus.php` * `Modules/Invoices/Events/Peppol/PeppolAcknowledgementReceived.php` * `Modules/Invoices/Events/Peppol/PeppolEvent.php` * `Modules/Invoices/Events/Peppol/PeppolIdValidationCompleted.php` * `Modules/Invoices/Events/Peppol/PeppolIntegrationCreated.php` * `Modules/Invoices/Events/Peppol/PeppolIntegrationTested.php` * `Modules/Invoices/Events/Peppol/PeppolTransmissionCreated.php` * `Modules/Invoices/Events/Peppol/PeppolTransmissionDead.php` * `Modules/Invoices/Events/Peppol/PeppolTransmissionFailed.php` * `Modules/Invoices/Events/Peppol/PeppolTransmissionPrepared.php` * `Modules/Invoices/Events/Peppol/PeppolTransmissionSent.php` * `Modules/Invoices/Jobs/Peppol/PeppolStatusPoller.php` * `Modules/Invoices/Jobs/Peppol/RetryFailedTransmissions.php` * `Modules/Invoices/Jobs/Peppol/SendInvoiceToPeppolJob.php` * `Modules/Invoices/Listeners/Peppol/LogPeppolEventToAudit.php` * `Modules/Invoices/Models/CustomerPeppolValidationHistory.php` * `Modules/Invoices/Models/CustomerPeppolValidationResponse.php` * `Modules/Invoices/Models/PeppolIntegration.php` * `Modules/Invoices/Models/PeppolIntegrationConfig.php` * `Modules/Invoices/Models/PeppolTransmission.php` * `Modules/Invoices/Models/PeppolTransmissionResponse.php` * `Modules/Invoices/Peppol/Contracts/ProviderInterface.php` * `Modules/Invoices/Peppol/FormatHandlers/FormatHandlerFactory.php` * `Modules/Invoices/Peppol/Providers/BaseProvider.php` * `Modules/Invoices/Peppol/Providers/EInvoiceBe/EInvoiceBeProvider.php` * `Modules/Invoices/Peppol/Providers/ProviderFactory.php` * `Modules/Invoices/Peppol/Providers/Storecove/StorecoveProvider.php` * `Modules/Invoices/Peppol/Services/PeppolManagementService.php` * `Modules/Invoices/Peppol/Services/PeppolTransformerService.php` * `Modules/Invoices/Traits/LogsPeppolActivity.php` Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * CodeRabbit Generated Unit Tests: Add PEPPOL PHPUnit test suite and testing documentation (#107) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent a103489 commit 0f18ac5

File tree

55 files changed

+6082
-6
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+6082
-6
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Add Peppol validation columns to the relations table.
11+
*
12+
* Adds nullable columns: `peppol_scheme` (string(50)) for Peppol endpoint scheme,
13+
* `peppol_validation_status` (string(20)) for quick lookup of validation state,
14+
* `peppol_validation_message` (text) for the last validation message, and
15+
* `peppol_validated_at` (timestamp) for when the Peppol ID was last validated.
16+
*/
17+
public function up(): void
18+
{
19+
Schema::table('relations', function (Blueprint $table): void {
20+
$table->string('peppol_scheme', 50)->nullable()->after('peppol_id')
21+
->comment('Peppol endpoint scheme (e.g., BE:CBE, DE:VAT)');
22+
23+
$table->string('peppol_validation_status', 20)->nullable()->after('enable_e_invoicing')
24+
->comment('Quick lookup: valid, invalid, not_found, error, null');
25+
26+
$table->text('peppol_validation_message')->nullable()->after('peppol_validation_status')
27+
->comment('Last validation result message');
28+
29+
$table->timestamp('peppol_validated_at')->nullable()->after('peppol_validation_message')
30+
->comment('When was the Peppol ID last validated');
31+
});
32+
}
33+
34+
/**
35+
* Removes Peppol-related columns from the `relations` table.
36+
*
37+
* Drops the columns: `peppol_scheme`, `peppol_validation_status`, `peppol_validation_message`, and `peppol_validated_at`.
38+
*/
39+
public function down(): void
40+
{
41+
Schema::table('relations', function (Blueprint $table): void {
42+
$table->dropColumn(['peppol_scheme', 'peppol_validation_status', 'peppol_validation_message', 'peppol_validated_at']);
43+
});
44+
}
45+
};

Modules/Clients/Models/Relation.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Modules\Core\Models\User;
1818
use Modules\Core\Traits\BelongsToCompany;
1919
use Modules\Expenses\Models\Expense;
20+
use Modules\Invoices\Enums\PeppolValidationStatus;
21+
use Modules\Invoices\Models\CustomerPeppolValidationHistory;
2022
use Modules\Invoices\Models\Invoice;
2123
use Modules\Payments\Models\Payment;
2224
use Modules\Projects\Models\Project;
@@ -37,8 +39,12 @@
3739
* @property string|null $coc_number
3840
* @property string|null $vat_number
3941
* @property string|null $peppol_id
42+
* @property string|null $peppol_scheme
4043
* @property string|null $peppol_format
4144
* @property bool $enable_e_invoicing
45+
* @property PeppolValidationStatus|null $peppol_validation_status
46+
* @property string|null $peppol_validation_message
47+
* @property Carbon|null $peppol_validated_at
4248
* @property Carbon $registered_at
4349
* @property mixed $created_at
4450
* @property mixed $updated_at
@@ -68,6 +74,8 @@ class Relation extends Model
6874
'relation_type' => RelationType::class,
6975
'relation_status' => RelationStatus::class,
7076
'enable_e_invoicing' => 'boolean',
77+
'peppol_validation_status' => PeppolValidationStatus::class,
78+
'peppol_validated_at' => 'datetime',
7179
];
7280

7381
protected $guarded = [];
@@ -161,11 +169,26 @@ public function tasks(): HasMany
161169
return $this->hasMany(Task::class, 'customer_id');
162170
}
163171

172+
/**
173+
* Define a one-to-many relationship to User models.
174+
*
175+
* @return HasMany The has-many relationship for User models.
176+
*/
164177
public function users(): HasMany
165178
{
166179
return $this->hasMany(User::class);
167180
}
168181

182+
/**
183+
* Get the Peppol validation history records for this relation.
184+
*
185+
* @return \Illuminate\Database\Eloquent\Relations\HasMany Collection of CustomerPeppolValidationHistory models related by `customer_id`.
186+
*/
187+
public function peppolValidationHistory(): HasMany
188+
{
189+
return $this->hasMany(CustomerPeppolValidationHistory::class, 'customer_id');
190+
}
191+
169192
/*
170193
|--------------------------------------------------------------------------
171194
| Accessors
@@ -180,6 +203,19 @@ public function getCustomerEmailAttribute()
180203
{
181204
return mb_trim($this->primary_ontact?->first_name . ' ' . $this->primary_contact?->last_name);
182205
}*/
206+
207+
/**
208+
* Determines whether the relation's Peppol ID has been validated and e-invoicing is enabled.
209+
*
210+
* @return bool `true` if e-invoicing is enabled, the Peppol validation status is `PeppolValidationStatus::VALID`, and `peppol_id` is not null; `false` otherwise.
211+
*/
212+
public function hasPeppolIdValidated(): bool
213+
{
214+
return $this->enable_e_invoicing
215+
&& $this->peppol_validation_status === PeppolValidationStatus::VALID
216+
&& $this->peppol_id !== null;
217+
}
218+
183219
/*
184220
|--------------------------------------------------------------------------
185221
| Scopes
@@ -201,4 +237,4 @@ protected static function newFactory(): Factory
201237
| Subqueries
202238
|--------------------------------------------------------------------------
203239
*/
204-
}
240+
}

Modules/Invoices/Config/config.php

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,81 @@
153153
'enable_webhooks' => env('PEPPOL_ENABLE_WEBHOOKS', false),
154154
'enable_participant_search' => env('PEPPOL_ENABLE_PARTICIPANT_SEARCH', true),
155155
'enable_health_checks' => env('PEPPOL_ENABLE_HEALTH_CHECKS', true),
156-
'auto_retry_failed' => env('PEPPOL_AUTO_RETRY', false),
157-
'max_retries' => env('PEPPOL_MAX_RETRIES', 3),
156+
'auto_retry_failed' => env('PEPPOL_AUTO_RETRY', true),
157+
'max_retries' => env('PEPPOL_MAX_RETRIES', 5),
158+
],
159+
160+
/*
161+
|--------------------------------------------------------------------------
162+
| Country to Scheme Mapping
163+
|--------------------------------------------------------------------------
164+
|
165+
| Mapping of country codes to default Peppol endpoint schemes.
166+
| Used for auto-suggesting the appropriate scheme when onboarding customers.
167+
|
168+
*/
169+
'country_scheme_mapping' => [
170+
'BE' => 'BE:CBE',
171+
'DE' => 'DE:VAT',
172+
'FR' => 'FR:SIRENE',
173+
'IT' => 'IT:VAT',
174+
'ES' => 'ES:VAT',
175+
'NL' => 'NL:KVK',
176+
'NO' => 'NO:ORGNR',
177+
'DK' => 'DK:CVR',
178+
'SE' => 'SE:ORGNR',
179+
'FI' => 'FI:OVT',
180+
'AT' => 'AT:VAT',
181+
'CH' => 'CH:UIDB',
182+
'GB' => 'GB:COH',
183+
],
184+
185+
/*
186+
|--------------------------------------------------------------------------
187+
| Retry Policy
188+
|--------------------------------------------------------------------------
189+
|
190+
| Configuration for automatic retries of failed transmissions.
191+
| Uses exponential backoff strategy.
192+
|
193+
*/
194+
'retry' => [
195+
'max_attempts' => env('PEPPOL_MAX_RETRY_ATTEMPTS', 5),
196+
'backoff_delays' => [60, 300, 1800, 7200, 21600], // 1min, 5min, 30min, 2h, 6h
197+
'retry_transient_errors' => true,
198+
'retry_unknown_errors' => true,
199+
'retry_permanent_errors' => false,
200+
],
201+
202+
/*
203+
|--------------------------------------------------------------------------
204+
| Storage Configuration
205+
|--------------------------------------------------------------------------
206+
|
207+
| Configuration for storing Peppol artifacts (XML, PDF).
208+
|
209+
*/
210+
'storage' => [
211+
'disk' => env('PEPPOL_STORAGE_DISK', 'local'),
212+
'path_template' => 'peppol/{integration_id}/{year}/{month}/{transmission_id}',
213+
'retention_days' => env('PEPPOL_RETENTION_DAYS', 2555), // 7 years default
214+
],
215+
216+
/*
217+
|--------------------------------------------------------------------------
218+
| Monitoring & Alerting
219+
|--------------------------------------------------------------------------
220+
|
221+
| Thresholds and settings for monitoring Peppol operations.
222+
|
223+
*/
224+
'monitoring' => [
225+
'alert_on_dead_transmission' => true,
226+
'dead_transmission_threshold' => 10, // Alert if > 10 dead in 1 hour
227+
'alert_on_auth_failure' => true,
228+
'status_check_interval' => 15, // minutes
229+
'reconciliation_interval' => 60, // minutes
230+
'old_transmission_threshold' => 168, // hours (7 days)
158231
],
159232
],
160233
];
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Modules\Invoices\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Modules\Invoices\Jobs\Peppol\PeppolStatusPoller;
7+
8+
/**
9+
* Console command to poll Peppol transmission statuses
10+
*
11+
* Should be scheduled to run periodically (e.g., every 15 minutes)
12+
* Add to schedule: $schedule->command('peppol:poll-status')->everyFifteenMinutes();
13+
*/
14+
class PollPeppolStatusCommand extends Command
15+
{
16+
protected $signature = 'peppol:poll-status';
17+
protected $description = 'Poll Peppol provider for transmission status updates';
18+
19+
/**
20+
* Triggers a background job to poll Peppol transmission statuses and reports the result.
21+
*
22+
* @return int Exit code: `self::SUCCESS` if the polling job was dispatched successfully, `self::FAILURE` if dispatch failed.
23+
*/
24+
public function handle(): int
25+
{
26+
$this->info('Starting Peppol status polling...');
27+
28+
try {
29+
PeppolStatusPoller::dispatch();
30+
31+
$this->info('Peppol status polling job dispatched successfully.');
32+
33+
return self::SUCCESS;
34+
} catch (\Exception $e) {
35+
$this->error('Failed to dispatch status polling job: ' . $e->getMessage());
36+
37+
return self::FAILURE;
38+
}
39+
}
40+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace Modules\Invoices\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Modules\Invoices\Jobs\Peppol\RetryFailedTransmissions;
7+
8+
/**
9+
* Console command to retry failed Peppol transmissions
10+
*
11+
* Should be scheduled to run frequently (e.g., every minute)
12+
* Add to schedule: $schedule->command('peppol:retry-failed')->everyMinute();
13+
*/
14+
class RetryFailedPeppolTransmissionsCommand extends Command
15+
{
16+
protected $signature = 'peppol:retry-failed';
17+
protected $description = 'Retry failed Peppol transmissions that are ready for retry';
18+
19+
/**
20+
* Dispatches a job to retry failed Peppol transmissions and reports the outcome.
21+
*
22+
* 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.
23+
*
24+
* @return int self::SUCCESS if the job was dispatched successfully, self::FAILURE if an exception occurred while dispatching.
25+
*/
26+
public function handle(): int
27+
{
28+
$this->info('Starting retry of failed Peppol transmissions...');
29+
30+
try {
31+
RetryFailedTransmissions::dispatch();
32+
33+
$this->info('Retry job dispatched successfully.');
34+
35+
return self::SUCCESS;
36+
} catch (\Exception $e) {
37+
$this->error('Failed to dispatch retry job: ' . $e->getMessage());
38+
39+
return self::FAILURE;
40+
}
41+
}
42+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace Modules\Invoices\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Modules\Invoices\Models\PeppolIntegration;
7+
use Modules\Invoices\Peppol\Services\PeppolManagementService;
8+
9+
/**
10+
* Console command to test a Peppol integration connection
11+
*/
12+
class TestPeppolIntegrationCommand extends Command
13+
{
14+
protected $signature = 'peppol:test-integration {integration_id}';
15+
protected $description = 'Test connection to a Peppol integration';
16+
17+
/**
18+
* Execute the console command to test a Peppol integration's connection.
19+
*
20+
* Loads the PeppolIntegration identified by the `integration_id` command argument; if not found,
21+
* outputs an error and returns failure. If found, invokes the PeppolManagementService to test
22+
* the integration's connection, outputs the service message, and returns success on a successful
23+
* test or failure otherwise.
24+
*
25+
* @param PeppolManagementService $service Service used to perform the connection test.
26+
* @return int `self::SUCCESS` on a successful connection test, `self::FAILURE` otherwise.
27+
*/
28+
public function handle(PeppolManagementService $service): int
29+
{
30+
$integrationId = $this->argument('integration_id');
31+
32+
$integration = PeppolIntegration::find($integrationId);
33+
34+
if (!$integration) {
35+
$this->error("Integration {$integrationId} not found.");
36+
return self::FAILURE;
37+
}
38+
39+
$this->info("Testing connection for integration: {$integration->provider_name}...");
40+
41+
$result = $service->testConnection($integration);
42+
43+
if ($result['ok']) {
44+
$this->info('✓ Connection test successful!');
45+
$this->line($result['message']);
46+
return self::SUCCESS;
47+
} else {
48+
$this->error('✗ Connection test failed.');
49+
$this->error($result['message']);
50+
return self::FAILURE;
51+
}
52+
}
53+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Create the peppol_integrations table to store PEPPOL integration settings and connection test metadata.
11+
*
12+
* The table includes company association (foreign key to companies.id with cascade delete), provider identifier,
13+
* encrypted API token, last test connection status/message/timestamp, an enabled flag, and indexes for
14+
* (company_id, enabled) and provider_name.
15+
*/
16+
public function up(): void
17+
{
18+
Schema::create('peppol_integrations', function (Blueprint $table): void {
19+
$table->id();
20+
$table->unsignedBigInteger('company_id');
21+
$table->string('provider_name', 50)->comment('e.g., e_invoice_be, storecove');
22+
$table->text('encrypted_api_token')->nullable()->comment('Encrypted API credentials');
23+
$table->string('test_connection_status', 20)->default('untested')->comment('untested, success, failed');
24+
$table->text('test_connection_message')->nullable()->comment('Last test connection result message');
25+
$table->timestamp('test_connection_at')->nullable();
26+
$table->boolean('enabled')->default(false)->comment('Whether integration is active');
27+
28+
$table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
29+
$table->index(['company_id', 'enabled']);
30+
$table->index('provider_name');
31+
});
32+
}
33+
34+
/**
35+
* Drop the `peppol_integrations` table if it exists.
36+
*/
37+
public function down(): void
38+
{
39+
Schema::dropIfExists('peppol_integrations');
40+
}
41+
};

0 commit comments

Comments
 (0)