Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,45 @@
<?php

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

return new class extends Migration
{
/**
* Add Peppol validation columns to the relations table.
*
* Adds nullable columns: `peppol_scheme` (string(50)) for Peppol endpoint scheme,
* `peppol_validation_status` (string(20)) for quick lookup of validation state,
* `peppol_validation_message` (text) for the last validation message, and
* `peppol_validated_at` (timestamp) for when the Peppol ID was last validated.
*/
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');
});
}

/**
* 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']);
});
}
};
38 changes: 37 additions & 1 deletion 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 @@ -161,11 +169,26 @@ 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
Expand All @@ -180,6 +203,19 @@ 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
Expand All @@ -201,4 +237,4 @@ protected static function newFactory(): Factory
| Subqueries
|--------------------------------------------------------------------------
*/
}
}
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)
],
],
];
40 changes: 40 additions & 0 deletions Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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';

/**
* 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;
}
}
}
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\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';

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

/**
* Execute the console command to test a Peppol integration's connection.
*
* Loads the PeppolIntegration identified by the `integration_id` command argument; if not found,
* outputs an error and returns failure. If found, invokes the PeppolManagementService to test
* the integration's connection, outputs the service message, and returns success on a successful
* test or failure otherwise.
*
* @param PeppolManagementService $service Service used to perform the connection test.
* @return int `self::SUCCESS` on a successful connection test, `self::FAILURE` otherwise.
*/
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,41 @@
<?php

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

return new class extends Migration
{
/**
* Create the peppol_integrations table to store PEPPOL integration settings and connection test metadata.
*
* The table includes company association (foreign key to companies.id with cascade delete), provider identifier,
* encrypted API token, last test connection status/message/timestamp, an enabled flag, and indexes for
* (company_id, enabled) and provider_name.
*/
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');
});
}

/**
* Drop the `peppol_integrations` table if it exists.
*/
public function down(): void
{
Schema::dropIfExists('peppol_integrations');
}
};
Loading