Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('relations', function (Blueprint $table): void {
$table->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');
});
}

public function down(): void
{
Schema::table('relations', function (Blueprint $table): void {
$table->dropColumn(['peppol_scheme', 'peppol_validation_status', 'peppol_validation_message', 'peppol_validated_at']);
});
}
};
24 changes: 24 additions & 0 deletions Modules/Clients/Models/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
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;
Expand All @@ -37,8 +39,12 @@
* @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
Expand Down Expand Up @@ -68,6 +74,8 @@ 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 = [];
Expand Down Expand Up @@ -166,6 +174,11 @@ public function users(): HasMany
return $this->hasMany(User::class);
}

public function peppolValidationHistory(): HasMany
{
return $this->hasMany(CustomerPeppolValidationHistory::class, 'customer_id');
}

/*
|--------------------------------------------------------------------------
| Accessors
Expand All @@ -180,6 +193,17 @@ public function getCustomerEmailAttribute()
{
return mb_trim($this->primary_ontact?->first_name . ' ' . $this->primary_contact?->last_name);
}*/

/**
* Check if customer has valid Peppol ID
*/
public function hasPeppolIdValidated(): bool
{
return $this->enable_e_invoicing
&& $this->peppol_validation_status === PeppolValidationStatus::VALID
&& $this->peppol_id !== null;
}

/*
|--------------------------------------------------------------------------
| Scopes
Expand Down
77 changes: 75 additions & 2 deletions Modules/Invoices/Config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,81 @@
'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', false),
'max_retries' => env('PEPPOL_MAX_RETRIES', 3),
'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)
],
],
];
35 changes: 35 additions & 0 deletions Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Modules\Invoices\Console\Commands;

use Illuminate\Console\Command;
use Modules\Invoices\Jobs\Peppol\PeppolStatusPoller;

/**
* Console command to poll Peppol transmission statuses
*
* Should be scheduled to run periodically (e.g., every 15 minutes)
* Add to schedule: $schedule->command('peppol:poll-status')->everyFifteenMinutes();
*/
class PollPeppolStatusCommand extends Command
{
protected $signature = 'peppol:poll-status';
protected $description = 'Poll Peppol provider for transmission status updates';

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Modules\Invoices\Console\Commands;

use Illuminate\Console\Command;
use Modules\Invoices\Jobs\Peppol\RetryFailedTransmissions;

/**
* Console command to retry failed Peppol transmissions
*
* Should be scheduled to run frequently (e.g., every minute)
* Add to schedule: $schedule->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';

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;
}
}
}
42 changes: 42 additions & 0 deletions Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Modules\Invoices\Console\Commands;

use Illuminate\Console\Command;
use Modules\Invoices\Models\PeppolIntegration;
use Modules\Invoices\Peppol\Services\PeppolManagementService;

/**
* Console command to test a Peppol integration connection
*/
class TestPeppolIntegrationCommand extends Command
{
protected $signature = 'peppol:test-integration {integration_id}';
protected $description = 'Test connection to a Peppol integration';

public function handle(PeppolManagementService $service): int
{
$integrationId = $this->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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('peppol_integrations', function (Blueprint $table): void {
$table->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');
});
}

public function down(): void
{
Schema::dropIfExists('peppol_integrations');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('peppol_integration_config', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('integration_id');
$table->string('config_key', 100);
$table->text('config_value');

$table->foreign('integration_id')->references('id')->on('peppol_integrations')->onDelete('cascade');
$table->index(['integration_id', 'config_key']);
});
}

public function down(): void
{
Schema::dropIfExists('peppol_integration_config');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('peppol_transmissions', function (Blueprint $table): void {
$table->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');
});
}

public function down(): void
{
Schema::dropIfExists('peppol_transmissions');
}
};
Loading