Skip to content
Draft
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,34 @@
<?php

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

return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('relations', function (Blueprint $table) {
$table->string('peppol_id', 100)->nullable()->after('vat_number')
->comment('Peppol participant identifier (e.g., BE:0123456789)');

$table->string('peppol_format', 50)->nullable()->after('peppol_id')
->comment('Preferred Peppol document format (matches PeppolDocumentFormat values)');

$table->boolean('enable_e_invoicing')->default(false)->after('peppol_format')
->comment('Whether e-invoicing via Peppol is enabled for this customer');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('relations', function (Blueprint $table) {
$table->dropColumn(['peppol_id', 'peppol_format', 'enable_e_invoicing']);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?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']);
});
}
};
114 changes: 114 additions & 0 deletions Modules/Invoices/Actions/SendInvoiceToPeppolAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace Modules\Invoices\Actions;

use Illuminate\Http\Client\RequestException;
use InvalidArgumentException;
use Modules\Invoices\Models\Invoice;
use Modules\Invoices\Peppol\Services\PeppolService;

/**
* SendInvoiceToPeppolAction - Action for sending invoices to Peppol network.
*
* This action handles the process of gathering invoice information and
* sending it to the Peppol network through the PeppolService. It provides
* a clean interface for both the EditInvoice page and the ListInvoices table.
*/
class SendInvoiceToPeppolAction
{
/**
* The Peppol service instance.
*
* @var PeppolService
*/
protected PeppolService $peppolService;

/**
* Constructor.
*
* @param PeppolService $peppolService The Peppol service
*/
public function __construct(PeppolService $peppolService)
{
$this->peppolService = $peppolService;
}

/**
* Execute the action to send an invoice to Peppol.
*
* This method gathers all necessary information from the invoice and
* submits it to the Peppol network. It returns the result of the operation.
*
* @param Invoice $invoice The invoice to send
* @param array<string, mixed> $additionalData Optional additional data (e.g., Peppol ID)
*
* @return array<string, mixed> The result of the operation
*
* @throws RequestException If the Peppol API request fails
* @throws InvalidArgumentException If the invoice data is invalid
*/
public function execute(Invoice $invoice, array $additionalData = []): array
{
// Load necessary relationships
$invoice->load(['customer', 'invoiceItems']);

// Validate that invoice is in a state that can be sent
$this->validateInvoiceState($invoice);

// Send to Peppol
$result = $this->peppolService->sendInvoiceToPeppol($invoice, $additionalData);

// Optionally, you could update the invoice record here
// to track that it was sent to Peppol (e.g., add a peppol_document_id field)
// $invoice->update(['peppol_document_id' => $result['document_id']]);

return $result;
}

/**
* Get the status of a previously sent invoice from Peppol.
*
* @param string $documentId The Peppol document ID
*
* @return array<string, mixed> Status information
*
* @throws RequestException If the API request fails
*/
public function getStatus(string $documentId): array
{
return $this->peppolService->getDocumentStatus($documentId);
}

/**
* Cancel a Peppol document transmission.
*
* @param string $documentId The Peppol document ID
*
* @return bool True if cancellation was successful
*
* @throws RequestException If the API request fails
*/
public function cancel(string $documentId): bool
{
return $this->peppolService->cancelDocument($documentId);
}

/**
* Validate that the invoice is in a valid state for Peppol transmission.
*
* @param Invoice $invoice The invoice to validate
*
* @return void
*
* @throws InvalidArgumentException If validation fails
*/
protected function validateInvoiceState(Invoice $invoice): void
{
// Check if invoice is in draft status - drafts should not be sent
if ($invoice->invoice_status === 'draft') {
throw new InvalidArgumentException('Cannot send draft invoices to Peppol');
Comment on lines +108 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect Invoice model status fields/casts/accessors and draft references.
fd -i 'Invoice.php' Modules/Invoices/Models -t f
rg -n -C3 '\binvoice_status\b|\binvoice_status_id\b|casts|Attribute::make|draft' Modules/Invoices/Models/Invoice.php Modules/Invoices

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 46310


Replace string literal with enum constant for invoice status check.

Line 108 compares $invoice->invoice_status to the string 'draft', but the field is cast to the InvoiceStatus enum in the Invoice model. This comparison will always fail, silently allowing draft invoices to pass validation. Use InvoiceStatus::DRAFT instead:

if ($invoice->invoice_status === InvoiceStatus::DRAFT) {
    throw new InvalidArgumentException('Cannot send draft invoices to Peppol');
}

See InvoiceService.php line 183 for the correct pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Invoices/Actions/SendInvoiceToPeppolAction.php` around lines 108 -
109, In the SendInvoiceToPeppolAction class, the condition that checks if the
invoice status is draft compares the invoice_status field to the string literal
'draft', but since invoice_status is cast to the InvoiceStatus enum in the
Invoice model, this comparison will always fail. Replace the string literal
'draft' with the enum constant InvoiceStatus::DRAFT in the if condition to
properly validate that draft invoices cannot be sent to Peppol.

}

// Additional business logic validation can be added here
}
}
42 changes: 42 additions & 0 deletions Modules/Invoices/Console/Commands/PollPeppolStatusCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Modules\Invoices\Console\Commands;

use Exception;
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,44 @@
<?php

namespace Modules\Invoices\Console\Commands;

use Exception;
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;
}
}
}
57 changes: 57 additions & 0 deletions Modules/Invoices/Console/Commands/TestPeppolIntegrationCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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::query()->find($integrationId);

if ( ! $integration) {
$this->error("Integration {$integrationId} not found.");

return self::FAILURE;
}

$this->info("Testing connection for integration: {$integration->provider_name}...");

$result = $service->testConnection($integration);

if ($result['ok']) {
$this->info('✓ Connection test successful!');
$this->line($result['message']);

return self::SUCCESS;
}
$this->error('✗ Connection test failed.');
$this->error($result['message']);

return self::FAILURE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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
Loading