Improvements for sending peppol - discovery() checks for clients

This commit is contained in:
David Bomba
2026-03-27 11:48:56 +11:00
parent 626ed5229a
commit 9f8ebcdaa2
13 changed files with 1334 additions and 12 deletions

View File

@@ -33,6 +33,7 @@ class ClientSyncCast implements CastsAttributes
$is = new ClientSync();
$is->qb_id = $data['qb_id'] ?? '';
$is->dn_dirty = $data['dn_dirty'] ?? false;
$is->peppol_discovery = array_key_exists('peppol_discovery', $data) ? $data['peppol_discovery'] : null;
return $is;
}
@@ -49,6 +50,7 @@ class ClientSyncCast implements CastsAttributes
$key => json_encode([
'qb_id' => $value->qb_id,
'dn_dirty' => $value->dn_dirty,
'peppol_discovery' => $value->peppol_discovery,
]),
];
}

View File

@@ -24,10 +24,14 @@ class ClientSync implements Castable
public bool $dn_dirty = false;
/** @var ?bool null = not yet checked, true = found on network, false = checked and not found */
public ?bool $peppol_discovery = null;
public function __construct(array $attributes = [])
{
$this->qb_id = $attributes['qb_id'] ?? '';
$this->dn_dirty = $attributes['dn_dirty'] ?? false;
$this->peppol_discovery = array_key_exists('peppol_discovery', $attributes) ? $attributes['peppol_discovery'] : null;
}
/**
* Get the name of the caster class to use when casting from / to this cast target.

View File

@@ -22,11 +22,27 @@ use App\Http\Requests\EInvoice\Peppol\ShowEntityRequest;
use App\Http\Requests\EInvoice\Peppol\StoreEntityRequest;
use App\Http\Requests\EInvoice\Peppol\UpdateEntityRequest;
use App\Services\EDocument\Standards\Verifactu\SendToAeat;
use App\Http\Requests\EInvoice\Peppol\DiscoveryRequest;
use App\Http\Requests\EInvoice\Peppol\AddTaxIdentifierRequest;
use App\Http\Requests\EInvoice\Peppol\RemoveTaxIdentifierRequest;
class EInvoicePeppolController extends BaseController
{
/**
* Check whether a recipient is discoverable on the PEPPOL network.
*
* Used by self-hosted instances proxying through the hosted server.
*/
public function discovery(DiscoveryRequest $request, Storecove $storecove): JsonResponse
{
$discovered = $storecove->discovery(
$request->validated('identifier'),
$request->validated('scheme'),
);
return response()->json(['discovered' => $discovered]);
}
/**
* Returns the legal entity ID
*

View File

@@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\EInvoice\Peppol;
use Illuminate\Foundation\Http\FormRequest;
class DiscoveryRequest extends FormRequest
{
public function authorize(): bool
{
/** @var \App\Models\User $user */
$user = auth()->user();
if (config('ninja.app_env') == 'local') {
return true;
}
return $user->isAdmin() && $user->company()->legal_entity_id != null;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'identifier' => 'required|string|min:2',
'scheme' => 'required|string|min:2',
];
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Client;
use App\DataMapper\ClientSync;
use App\Libraries\MultiDB;
use App\Models\Client;
use App\Models\Company;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use App\Services\EDocument\Gateway\Storecove\StorecoveProxy;
use App\Services\EDocument\Gateway\Storecove\StorecoveRouter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class CheckPeppolDiscovery implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $tries = 1;
public function __construct(public Client $client, protected Company $company) {}
public function handle(): void
{
MultiDB::setDb($this->company->db);
/** @var Storecove $storecove */
$storecove = app(Storecove::class);
$proxy = $storecove->proxy->setCompany($this->company);
$discovered = false;
foreach ($this->resolveDiscoveryCandidates() as $candidate) {
if ($proxy->discovery($candidate['identifier'], $candidate['scheme'])) {
$discovered = true;
break;
}
}
$sync = $this->client->sync ?? new ClientSync();
$sync->peppol_discovery = $discovered;
$this->client->sync = $sync;
$this->client->saveQuietly();
}
/**
* Build scheme/identifier pairs that are identical to what
* Mutator::setClientRoutingCode() would produce at send time.
*
* The routing_rules matrix in StorecoveRouter is the single source of
* truth for both the scheme and which client field holds the identifier.
*
* @return array<int, array{scheme: string, identifier: string}>
*/
private function resolveDiscoveryCandidates(): array
{
$candidates = [];
$country = $this->client->country->iso_3166_2 ?? null;
$classification = $this->client->classification ?? 'business';
if (!$country) {
return $candidates;
}
// Mutator line 219: early exit when no identifiers present
if (strlen($this->client->vat_number ?? '') < 2 && strlen($this->client->id_number ?? '') < 2) {
return $candidates;
}
// Mutator lines 226-244: explicit routing_id override (scheme_code:identifier_value)
if (stripos($this->client->routing_id ?? '', ':') !== false) {
$parts = explode(':', $this->client->routing_id);
if (count($parts) === 2) {
$candidates[] = [
'scheme' => $parts[0],
'identifier' => $parts[1],
];
}
}
// Mutator line 247: resolve routing scheme from StorecoveRouter
$router = new StorecoveRouter();
$code = $router->resolveRouting($country, $classification);
if (!$code || str_contains($code, ',')) {
return $this->deduplicate($candidates);
}
// Mutator lines 249-272: resolve identifier matching the Mutator's exact order
$is_vat_scheme = str_contains($code, ':VAT') || str_contains($code, ':IVA') || str_contains($code, ':CF');
$identifier = false;
if ($country === 'FR') {
$identifier = $this->client->id_number;
} elseif (str_contains($code, ':CUUO') && strlen($this->client->routing_id ?? '') > 1) {
$identifier = $this->client->routing_id;
} elseif (!$is_vat_scheme && strlen($this->client->id_number ?? '') > 1) {
$identifier = $this->client->id_number;
} else {
$identifier = $this->client->vat_number;
}
// Mutator line 266-268: DE government override
if ($country === 'DE' && $classification === 'government') {
$identifier = $this->client->routing_id;
}
if (!$identifier || strlen($identifier) < 2) {
return $this->deduplicate($candidates);
}
// Mutator line 275: clean identifier
$identifier = preg_replace('/[^a-zA-Z0-9]/', '', $identifier);
// Mutator lines 278-280: DK:DIGST expects DK prefix
if ($code === 'DK:DIGST' && !str_starts_with(strtoupper($identifier), 'DK')) {
$identifier = 'DK' . $identifier;
}
// Mutator lines 283-302: BE tries BE:EN then BE:VAT
if ($country === 'BE') {
$stripped = preg_replace("/^{$country}/i", '', $identifier);
$candidates[] = [
'scheme' => 'BE:EN',
'identifier' => $stripped,
];
$candidates[] = [
'scheme' => 'BE:VAT',
'identifier' => 'BE' . $stripped,
];
return $this->deduplicate($candidates);
}
// Mutator lines 305-307: standard path — single scheme + identifier
$candidates[] = [
'scheme' => $code,
'identifier' => $identifier,
];
return $this->deduplicate($candidates);
}
private function deduplicate(array $candidates): array
{
$seen = [];
$unique = [];
foreach ($candidates as $c) {
$key = $c['scheme'] . '|' . $c['identifier'];
if (!isset($seen[$key]) && strlen($c['identifier']) >= 1) {
$seen[$key] = true;
$unique[] = $c;
}
}
return $unique;
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->client->client_hash))->dontRelease()];
}
}

View File

@@ -12,6 +12,7 @@
namespace App\Observers;
use App\Jobs\Client\CheckPeppolDiscovery;
use App\Jobs\Client\CheckVat;
use App\Jobs\Client\UpdateTaxData;
use App\Jobs\Util\WebhookHandler;
@@ -110,6 +111,11 @@ class ClientObserver
CheckVat::dispatch($client, $client->company);
}
/** Check PEPPOL discovery when routing-relevant fields change */
if ($this->shouldCheckPeppolDiscovery($client)) {
CheckPeppolDiscovery::dispatch($client, $client->company);
}
$event = Webhook::EVENT_UPDATE_CLIENT;
if ($client->getOriginal('deleted_at') && !$client->deleted_at) {
@@ -164,4 +170,38 @@ class ClientObserver
}
}
/**
* Determines whether a PEPPOL discovery check should be dispatched.
*
* Fires when: a routing-relevant field changed AND the company is
* PEPPOL-enabled AND the client is on a routable delivery network.
*/
private function shouldCheckPeppolDiscovery(Client $client): bool
{
$peppolFields = ['id_number', 'vat_number', 'routing_id'];
$changed = false;
foreach ($peppolFields as $field) {
if ($client->getOriginal($field) != $client->{$field}) {
$changed = true;
break;
}
}
if (!$changed) {
return false;
}
if (!$client->company->peppolSendingEnabled()) {
return false;
}
if (!is_null($client->checkDeliveryNetwork())) {
return false;
}
return true;
}
}

View File

@@ -216,18 +216,13 @@ class Mutator implements MutatorInterface
public function setClientRoutingCode(): self
{
if ($this->invoice->client->classification == 'individual' || (strlen($this->invoice->client->vat_number ?? '') < 2 && strlen($this->invoice->client->id_number ?? '') < 2)) {
return $this->setEmailRouting($this->getIndividualEmailRoute());
if (strlen($this->invoice->client->vat_number ?? '') < 2 && strlen($this->invoice->client->id_number ?? '') < 2) {
if ($this->invoice->client->classification == 'individual') {
return $this->setEmailRouting($this->getIndividualEmailRoute());
}
return $this;
}
//Regardless, always include the client email address as a route - Storecove will only use this as a fallback.
$client_email = $this->getIndividualEmailRoute();
if (strlen($client_email) > 2) {
$this->setEmailRouting($client_email);
}
if (stripos($this->invoice->client->routing_id ?? '', ":") !== false) {
$parts = explode(":", $this->invoice->client->routing_id);

View File

@@ -225,6 +225,34 @@ class StorecoveProxy
return $error;
}
/**
* Check if a recipient is discoverable on the PEPPOL network.
*
* Hosted: calls Storecove directly.
* Self-hosted: proxies through the hosted Ninja server.
*/
public function discovery(string $identifier, string $scheme): bool
{
if (Ninja::isHosted()) {
return $this->storecove->discovery($identifier, $scheme);
}
$payload = [
'identifier' => $identifier,
'scheme' => $scheme,
];
$response = Http::baseUrl(config('ninja.hosted_ninja_url'))
->withHeaders($this->getHeaders())
->post('/api/einvoice/peppol/discovery', $payload);
if ($response->successful()) {
return ($response->json()['discovered'] ?? false) === true;
}
return false;
}
private function remoteRequest(string $uri, array $payload = []): array
{

View File

@@ -76,8 +76,9 @@ class SendEDocument implements ShouldQueue
return; //Bad Actor present.
}
if ($model->client && $model->client->checkDeliveryNetwork()) {
nlog("Client is not routable on the Peppol network.");
if ($model->client && ($error = $model->client->checkDeliveryNetwork())) {
nlog("Client is not routable on the Peppol network: {$error}");
$this->writeActivity($model, Activity::EINVOICE_DELIVERY_FAILURE, $error);
return;
}

View File

@@ -327,6 +327,20 @@ class EntityLevel implements EntityLevelInterface
}
}
// Check PEPPOL network discovery — the client must be identifiable on the network
// null = not yet checked (allow, but dispatch async check to populate for next time)
// true = found on network
// false = checked and not found (block)
if ($client->classification !== 'individual') {
$discovery = $client->sync?->peppol_discovery;
if ($discovery === false) {
$errors[] = ['field' => 'peppol_discovery', 'label' => ctrans('texts.client_not_found_on_peppol_network')];
} elseif ($discovery === null && $client->company->peppolSendingEnabled() && is_null($client->checkDeliveryNetwork())) {
\App\Jobs\Client\CheckPeppolDiscovery::dispatch($client, $client->company);
}
}
return $errors;

View File

@@ -5948,6 +5948,7 @@ $lang = array(
'invalid_vat_number_format' => 'Invalid VAT number format',
'invalid_routing_id_format' => 'Invalid routing ID format',
'sign_the_document' => 'Sign the document',
'client_not_found_on_peppol_network' => 'Client could not be identified on the PEPPOL network. Please verify their identifiers.',
);
return $lang;

View File

@@ -247,6 +247,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::post('einvoice/validateEntity', [EInvoiceController::class, 'validateEntity'])->name('einvoice.validateEntity');
Route::post('einvoice/configurations', [EInvoiceController::class, 'configurations'])->name('einvoice.configurations');
Route::post('einvoice/peppol/discovery', [EInvoicePeppolController::class, 'discovery'])->name('einvoice.peppol.discovery');
Route::post('einvoice/peppol/legal_entity', [EInvoicePeppolController::class, 'show'])->name('einvoice.peppol.legal_entity');
Route::post('einvoice/peppol/setup', [EInvoicePeppolController::class, 'setup'])->name('einvoice.peppol.setup');
Route::post('einvoice/peppol/disconnect', [EInvoicePeppolController::class, 'disconnect'])->name('einvoice.peppol.disconnect');

View File

@@ -0,0 +1,995 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Tests\Feature\EInvoice;
use Tests\TestCase;
use App\Models\Client;
use App\Models\Company;
use Tests\MockAccountData;
use App\Models\ClientContact;
use App\DataMapper\ClientSync;
use App\Jobs\Client\CheckPeppolDiscovery;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use App\Services\EDocument\Gateway\Storecove\StorecoveProxy;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class PeppolDiscoveryTest extends TestCase
{
use MockAccountData;
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->makeTestData();
}
private function makeClient(int $countryId, string $classification, array $extra = []): Client
{
$client = Client::factory()->create(array_merge([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
'country_id' => $countryId,
'classification' => $classification,
], $extra));
ClientContact::factory()->create([
'user_id' => $this->user->id,
'client_id' => $client->id,
'company_id' => $this->company->id,
'is_primary' => 1,
'email' => 'test@example.com',
]);
return $client->fresh();
}
/**
* Helper: run the discovery job with a mocked StorecoveProxy.
*
* The job calls StorecoveProxy::discovery() (not Storecove directly),
* so we mock the proxy and wire it into the Storecove instance.
*
* @param Client $client
* @param callable $callback Receives ($identifier, $scheme) and returns bool
*/
private function runDiscoveryWithMock(Client $client, callable $callback): void
{
$proxyMock = $this->createMock(StorecoveProxy::class);
$proxyMock->method('discovery')->willReturnCallback($callback);
$proxyMock->method('setCompany')->willReturnSelf();
$storecove = new Storecove();
$storecove->proxy = $proxyMock;
$this->app->instance(Storecove::class, $storecove);
(new CheckPeppolDiscovery($client, $client->company))->handle();
}
// ──────────────────────────────────────────────────────
// ClientSync property
// ──────────────────────────────────────────────────────
public function testClientSyncDefaultsToNull(): void
{
$sync = new ClientSync();
$this->assertNull($sync->peppol_discovery);
}
public function testClientSyncAcceptsPeppolDiscoveryTrue(): void
{
$sync = new ClientSync(['peppol_discovery' => true]);
$this->assertTrue($sync->peppol_discovery);
}
public function testClientSyncFromArrayIncludesPeppolDiscovery(): void
{
$sync = ClientSync::fromArray(['qb_id' => 'QB-99', 'peppol_discovery' => true]);
$this->assertTrue($sync->peppol_discovery);
$this->assertEquals('QB-99', $sync->qb_id);
}
// ──────────────────────────────────────────────────────
// Cast persistence (round-trip through DB)
// ──────────────────────────────────────────────────────
public function testPeppolDiscoveryPersistsTrueViaCast(): void
{
$client = $this->makeClient(276, 'business');
$sync = $client->sync ?? new ClientSync();
$sync->peppol_discovery = true;
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertNotNull($client->sync);
$this->assertTrue($client->sync->peppol_discovery);
}
public function testPeppolDiscoveryPersistsNullViaCast(): void
{
$client = $this->makeClient(276, 'business');
$sync = new ClientSync();
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertNull($client->sync->peppol_discovery);
}
public function testPeppolDiscoveryPersistsExplicitFalseViaCast(): void
{
$client = $this->makeClient(276, 'business');
$sync = new ClientSync(['peppol_discovery' => false]);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertFalse($client->sync->peppol_discovery);
}
public function testExistingQbSyncDataDefaultsToNull(): void
{
// Simulate a client with pre-existing QB sync data that lacks peppol_discovery
$client = $this->makeClient(276, 'business');
$sync = new ClientSync(['qb_id' => 'QB-OLD-123']);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertNull($client->sync->peppol_discovery, 'Existing sync without peppol_discovery should default to null, not false');
$this->assertEquals('QB-OLD-123', $client->sync->qb_id);
}
public function testCastPreservesOtherSyncFields(): void
{
$client = $this->makeClient(276, 'business');
$sync = new ClientSync(['qb_id' => 'QB-123', 'dn_dirty' => true, 'peppol_discovery' => true]);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertEquals('QB-123', $client->sync->qb_id);
$this->assertTrue($client->sync->dn_dirty);
$this->assertTrue($client->sync->peppol_discovery);
}
public function testNullSyncReturnsNull(): void
{
$client = $this->makeClient(276, 'business');
// Don't set sync at all — the column should be null
$this->assertNull($client->sync);
}
// ──────────────────────────────────────────────────────
// Job — discovery succeeds
// ──────────────────────────────────────────────────────
public function testJobSetsTrueWhenDiscoverySucceeds(): void
{
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE123456789']);
$this->runDiscoveryWithMock($client, fn () => true);
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
}
public function testJobSetsFalseWhenAllDiscoveryFails(): void
{
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE000000000']);
// Pre-set to true to prove it flips
$sync = new ClientSync(['peppol_discovery' => true]);
$client->sync = $sync;
$client->saveQuietly();
$this->runDiscoveryWithMock($client->fresh(), fn () => false);
$client->refresh();
$this->assertFalse($client->sync->peppol_discovery);
}
public function testJobPreservesExistingSyncData(): void
{
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE123456789']);
$sync = new ClientSync(['qb_id' => 'QB-12345', 'dn_dirty' => true, 'peppol_discovery' => false]);
$client->sync = $sync;
$client->saveQuietly();
$this->runDiscoveryWithMock($client->fresh(), fn () => true);
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
$this->assertEquals('QB-12345', $client->sync->qb_id);
$this->assertTrue($client->sync->dn_dirty);
}
public function testJobCreatesSyncWhenNull(): void
{
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE123456789']);
$this->assertNull($client->sync);
$this->runDiscoveryWithMock($client, fn () => true);
$client->refresh();
$this->assertNotNull($client->sync);
$this->assertTrue($client->sync->peppol_discovery);
}
// ──────────────────────────────────────────────────────
// Job — no country / no identifiers
// ──────────────────────────────────────────────────────
public function testJobSkipsClientWithNoCountry(): void
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
'country_id' => null,
'classification' => 'business',
'vat_number' => 'DE123456789',
]);
$proxyMock = $this->createMock(StorecoveProxy::class);
$proxyMock->expects($this->never())->method('discovery');
$proxyMock->method('setCompany')->willReturnSelf();
$storecove = new Storecove();
$storecove->proxy = $proxyMock;
$this->app->instance(Storecove::class, $storecove);
(new CheckPeppolDiscovery($client->fresh(), $client->company))->handle();
$client->refresh();
$this->assertFalse($client->sync->peppol_discovery);
}
public function testJobSetsFalseWhenNoIdentifiers(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => '',
'id_number' => '',
'routing_id' => '',
]);
$proxyMock = $this->createMock(StorecoveProxy::class);
$proxyMock->expects($this->never())->method('discovery');
$proxyMock->method('setCompany')->willReturnSelf();
$storecove = new Storecove();
$storecove->proxy = $proxyMock;
$this->app->instance(Storecove::class, $storecove);
(new CheckPeppolDiscovery($client, $client->company))->handle();
$client->refresh();
$this->assertFalse($client->sync->peppol_discovery);
}
// ──────────────────────────────────────────────────────
// Job — country-specific candidate resolution
// ──────────────────────────────────────────────────────
public function testJobTriesDeVatScheme(): void
{
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE123456789']);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('DE:VAT', $triedSchemes, 'DE business should try DE:VAT');
}
public function testJobTriesSeOrgnrScheme(): void
{
$client = $this->makeClient(752, 'business', [
'id_number' => '1234567890',
'vat_number' => 'SE123456789012',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
// SE routing scheme is SE:ORGNR with id_number — matches Mutator
$this->assertContains('SE:ORGNR', $triedSchemes, 'SE business should try SE:ORGNR');
}
public function testJobTriesDkDigstSchemeWithPrefix(): void
{
$client = $this->makeClient(208, 'business', [
'id_number' => '12345678',
'vat_number' => 'DK12345678',
]);
$seen = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$seen) {
$seen[] = ['scheme' => $scheme, 'identifier' => $identifier];
return false;
});
$dkDigst = array_filter($seen, fn ($s) => $s['scheme'] === 'DK:DIGST');
$this->assertNotEmpty($dkDigst, 'DK business should try DK:DIGST');
// DK prefix should be present on identifier
$dkEntry = array_values($dkDigst)[0];
$this->assertTrue(str_starts_with(strtoupper($dkEntry['identifier']), 'DK'), 'DK:DIGST identifier should have DK prefix');
}
public function testJobTriesNoOrgScheme(): void
{
$client = $this->makeClient(578, 'business', [
'id_number' => '123456789',
'vat_number' => 'NO123456789MVA',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('NO:ORG', $triedSchemes, 'NO business should try NO:ORG');
}
public function testJobTriesEeCcScheme(): void
{
$client = $this->makeClient(233, 'business', [
'id_number' => '12345678',
'vat_number' => 'EE123456789',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('EE:CC', $triedSchemes, 'EE business should try EE:CC');
}
public function testJobTriesFiOvtScheme(): void
{
$client = $this->makeClient(246, 'business', [
'id_number' => '003712345678',
'vat_number' => 'FI12345678',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('FI:OVT', $triedSchemes, 'FI business should try FI:OVT');
}
// ──────────────────────────────────────────────────────
// Job — BE special handling (both BE:EN and BE:VAT)
// ──────────────────────────────────────────────────────
public function testJobTriesBothBeEnAndBeVat(): void
{
$client = $this->makeClient(56, 'business', [
'vat_number' => 'BE0123456789',
'id_number' => '0123456789',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('BE:EN', $triedSchemes, 'BE should try BE:EN');
$this->assertContains('BE:VAT', $triedSchemes, 'BE should try BE:VAT');
}
public function testJobBeEnSucceedsStopsEarly(): void
{
$client = $this->makeClient(56, 'business', [
'vat_number' => 'BE0123456789',
'id_number' => '0123456789',
]);
$callCount = 0;
$proxyMock = $this->createMock(StorecoveProxy::class);
$proxyMock->method('discovery')->willReturnCallback(function ($identifier, $scheme) use (&$callCount) {
$callCount++;
return $scheme === 'BE:EN';
});
$proxyMock->method('setCompany')->willReturnSelf();
$storecove = new Storecove();
$storecove->proxy = $proxyMock;
$this->app->instance(Storecove::class, $storecove);
(new CheckPeppolDiscovery($client, $client->company))->handle();
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
$this->assertGreaterThanOrEqual(1, $callCount);
}
public function testJobBeVatAddsPrefixWhenMissing(): void
{
$client = $this->makeClient(56, 'business', [
'vat_number' => '0123456789', // No BE prefix
'id_number' => '',
]);
$seen = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$seen) {
$seen[] = ['scheme' => $scheme, 'identifier' => $identifier];
return false;
});
$beVat = array_values(array_filter($seen, fn ($s) => $s['scheme'] === 'BE:VAT'));
$this->assertNotEmpty($beVat);
$this->assertEquals('BE0123456789', $beVat[0]['identifier'], 'BE:VAT should have BE prefix');
$beEn = array_values(array_filter($seen, fn ($s) => $s['scheme'] === 'BE:EN'));
$this->assertNotEmpty($beEn);
$this->assertEquals('0123456789', $beEn[0]['identifier'], 'BE:EN should have prefix stripped');
}
public function testJobBeVatDoesNotDoublePrefixWhenAlreadyPresent(): void
{
$client = $this->makeClient(56, 'business', [
'vat_number' => 'BE0123456789', // Already has BE prefix
'id_number' => '',
]);
$seen = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$seen) {
$seen[] = ['scheme' => $scheme, 'identifier' => $identifier];
return false;
});
$beVat = array_values(array_filter($seen, fn ($s) => $s['scheme'] === 'BE:VAT'));
$this->assertNotEmpty($beVat);
$this->assertEquals('BE0123456789', $beVat[0]['identifier'], 'BE:VAT should not double-prefix');
$this->assertStringStartsNotWith('BEBE', $beVat[0]['identifier'], 'Must not have double BE prefix');
$beEn = array_values(array_filter($seen, fn ($s) => $s['scheme'] === 'BE:EN'));
$this->assertNotEmpty($beEn);
$this->assertEquals('0123456789', $beEn[0]['identifier'], 'BE:EN should strip prefix');
}
// ──────────────────────────────────────────────────────
// Job — explicit routing_id with scheme
// ──────────────────────────────────────────────────────
public function testJobExplicitRoutingIdSplitsAsSchemeAndIdentifier(): void
{
// routing_id "9915:b" → scheme="9915", identifier="b" — matches Mutator pattern
$client = $this->makeClient(40, 'government', [
'id_number' => 'ATGOV12345',
'routing_id' => '9915:b',
]);
$seen = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$seen) {
$seen[] = ['scheme' => $scheme, 'identifier' => $identifier];
return false;
});
// Should contain the explicit routing_id split
$explicit = array_filter($seen, fn ($s) => $s['scheme'] === '9915' && $s['identifier'] === 'b');
$this->assertNotEmpty($explicit, 'Explicit routing_id should split as scheme_code:identifier_value');
}
public function testJobExplicitRoutingIdSucceedsFirst(): void
{
$client = $this->makeClient(40, 'government', [
'id_number' => 'ATGOV12345',
'routing_id' => '9915:b',
]);
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) {
return $scheme === '9915';
});
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
}
// ──────────────────────────────────────────────────────
// Job — AT government uses AT:GOV
// ──────────────────────────────────────────────────────
public function testJobTriesAtGovForGovernment(): void
{
$client = $this->makeClient(40, 'government', [
'id_number' => 'AT-GOV-12345',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('9915:b', $triedSchemes, 'AT government should try 9915:b routing scheme');
}
public function testJobTriesAtVatForBusiness(): void
{
$client = $this->makeClient(40, 'business', [
'vat_number' => 'ATU12345678',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('AT:VAT', $triedSchemes, 'AT business should try AT:VAT');
}
// ──────────────────────────────────────────────────────
// Job — DE government uses DE:LWID
// ──────────────────────────────────────────────────────
public function testJobTriesDeLwidForGovernment(): void
{
$client = $this->makeClient(276, 'government', [
'id_number' => 'DE-LWID-12345',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('DE:LWID', $triedSchemes, 'DE government should try DE:LWID');
}
// ──────────────────────────────────────────────────────
// Job — only one success needed
// ──────────────────────────────────────────────────────
public function testJobSucceedsOnSecondCandidate(): void
{
// BE produces 2 candidates (BE:EN and BE:VAT) — first fails, second succeeds
$client = $this->makeClient(56, 'business', [
'vat_number' => 'BE0123456789',
]);
$callIndex = 0;
$this->runDiscoveryWithMock($client, function () use (&$callIndex) {
$callIndex++;
return $callIndex === 2;
});
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
}
// ──────────────────────────────────────────────────────
// Job — deduplication
// ──────────────────────────────────────────────────────
public function testJobDeduplicatesCandidates(): void
{
// LU uses LU:VAT for both routing and tax — should not duplicate
$client = $this->makeClient(442, 'business', [
'vat_number' => 'LU12345678',
]);
$calls = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$calls) {
$key = "$scheme|$identifier";
$calls[] = $key;
return false;
});
// No duplicate scheme+identifier pairs
$this->assertCount(count(array_unique($calls)), $calls, 'Candidates should be deduplicated');
}
// ──────────────────────────────────────────────────────
// Job — identifier cleaning
// ──────────────────────────────────────────────────────
public function testJobCleansSpecialCharactersFromIdentifiers(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE 123.456-789',
]);
$identifiersSeen = [];
$this->runDiscoveryWithMock($client, function ($identifier) use (&$identifiersSeen) {
$identifiersSeen[] = $identifier;
return false;
});
foreach ($identifiersSeen as $id) {
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9]+$/', $id, "Identifier '$id' should be cleaned");
}
}
// ──────────────────────────────────────────────────────
// Observer — shouldCheckPeppolDiscovery guard
// ──────────────────────────────────────────────────────
public function testObserverDoesNotFireWhenNoPeppolFieldsChanged(): void
{
// Test via the private method logic — update a non-peppol field
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
]);
// Simulate updating a non-PEPPOL field
$client->address1 = '123 New Street';
$original = $client->getOriginal();
// vat_number/id_number/routing_id unchanged
$this->assertEquals($original['vat_number'], $client->vat_number);
$this->assertEquals($original['id_number'] ?? '', $client->id_number ?? '');
$this->assertEquals($original['routing_id'] ?? '', $client->routing_id ?? '');
}
public function testObserverDetectsVatNumberChange(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
]);
$client->vat_number = 'DE999999999';
$this->assertNotEquals($client->getOriginal('vat_number'), $client->vat_number);
}
public function testObserverDetectsIdNumberChange(): void
{
$client = $this->makeClient(752, 'business', [
'id_number' => '1234567890',
]);
$client->id_number = '0987654321';
$this->assertNotEquals($client->getOriginal('id_number'), $client->id_number);
}
public function testObserverDetectsRoutingIdChange(): void
{
$client = $this->makeClient(276, 'business', [
'routing_id' => 'DE:VAT',
]);
$client->routing_id = 'DE:LWID';
$this->assertNotEquals($client->getOriginal('routing_id'), $client->routing_id);
}
// ──────────────────────────────────────────────────────
// testClientState — peppol_discovery validation
// ──────────────────────────────────────────────────────
public function testTestClientStatePassesWhenSyncIsNull(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
$this->assertNull($client->sync);
$result = (new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel())->checkClient($client);
$errors = $result['client'] ?? [];
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertEmpty($discoveryErrors, 'Null sync should not trigger discovery error');
}
public function testTestClientStatePassesWhenDiscoveryIsNull(): void
{
// Sync exists (e.g. QB user) but peppol_discovery is null → should NOT block
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
$sync = new ClientSync(['qb_id' => 'QB-123']);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$this->assertNull($client->sync->peppol_discovery);
$result = (new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel())->checkClient($client);
$errors = $result['client'] ?? [];
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertEmpty($discoveryErrors, 'Null peppol_discovery should not trigger discovery error');
}
public function testTestClientStateFailsWhenDiscoveryFalse(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
$sync = new ClientSync(['peppol_discovery' => false]);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$entityLevel = new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
$method = new \ReflectionMethod($entityLevel, 'testClientState');
$method->setAccessible(true);
$initMethod = new \ReflectionMethod($entityLevel, 'init');
$initMethod->setAccessible(true);
$initMethod->invoke($entityLevel, 'en');
$errors = $method->invoke($entityLevel, $client);
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertNotEmpty($discoveryErrors, 'Discovery false should produce peppol_discovery error');
}
public function testTestClientStatePassesWhenDiscoveryTrue(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
$sync = new ClientSync(['peppol_discovery' => true]);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$entityLevel = new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
$method = new \ReflectionMethod($entityLevel, 'testClientState');
$method->setAccessible(true);
$initMethod = new \ReflectionMethod($entityLevel, 'init');
$initMethod->setAccessible(true);
$initMethod->invoke($entityLevel, 'en');
$errors = $method->invoke($entityLevel, $client);
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertEmpty($discoveryErrors, 'Discovery true should not produce peppol_discovery error');
}
public function testTestClientStateSkipsDiscoveryCheckForIndividual(): void
{
$client = $this->makeClient(276, 'individual', [
'vat_number' => '',
'id_number' => '',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
// Set discovery to false — should NOT block individuals
$sync = new ClientSync(['peppol_discovery' => false]);
$client->sync = $sync;
$client->saveQuietly();
$client->refresh();
$entityLevel = new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
$method = new \ReflectionMethod($entityLevel, 'testClientState');
$method->setAccessible(true);
$initMethod = new \ReflectionMethod($entityLevel, 'init');
$initMethod->setAccessible(true);
$initMethod->invoke($entityLevel, 'en');
$errors = $method->invoke($entityLevel, $client);
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertEmpty($discoveryErrors, 'Individuals should skip peppol_discovery check');
}
// ──────────────────────────────────────────────────────
// End-to-end: job updates sync, validation reads it
// ──────────────────────────────────────────────────────
public function testEndToEndDiscoveryTruePassesValidation(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
// Run job with successful discovery
$this->runDiscoveryWithMock($client, fn () => true);
$client->refresh();
$entityLevel = new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
$method = new \ReflectionMethod($entityLevel, 'testClientState');
$method->setAccessible(true);
$initMethod = new \ReflectionMethod($entityLevel, 'init');
$initMethod->setAccessible(true);
$initMethod->invoke($entityLevel, 'en');
$errors = $method->invoke($entityLevel, $client);
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertEmpty($discoveryErrors, 'Successful discovery should pass validation');
}
public function testEndToEndDiscoveryFalseFailsValidation(): void
{
$client = $this->makeClient(276, 'business', [
'vat_number' => 'DE123456789',
'address1' => '123 Main St',
'city' => 'Berlin',
'state' => 'Berlin',
'postal_code' => '10115',
]);
// Run job with failed discovery
$this->runDiscoveryWithMock($client, fn () => false);
$client->refresh();
$entityLevel = new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
$method = new \ReflectionMethod($entityLevel, 'testClientState');
$method->setAccessible(true);
$initMethod = new \ReflectionMethod($entityLevel, 'init');
$initMethod->setAccessible(true);
$initMethod->invoke($entityLevel, 'en');
$errors = $method->invoke($entityLevel, $client);
$discoveryErrors = array_filter($errors, fn ($e) => ($e['field'] ?? '') === 'peppol_discovery');
$this->assertNotEmpty($discoveryErrors, 'Failed discovery should fail validation');
}
// ──────────────────────────────────────────────────────
// Job — uses saveQuietly (no observer loop)
// ──────────────────────────────────────────────────────
public function testJobUsesSaveQuietly(): void
{
// Verify the job saves the client without triggering the observer again.
// We do this by checking that re-running the job doesn't cause infinite recursion.
$client = $this->makeClient(276, 'business', ['vat_number' => 'DE123456789']);
$callCount = 0;
$proxyMock = $this->createMock(StorecoveProxy::class);
$proxyMock->method('discovery')->willReturnCallback(function () use (&$callCount) {
$callCount++;
return true;
});
$proxyMock->method('setCompany')->willReturnSelf();
$storecove = new Storecove();
$storecove->proxy = $proxyMock;
$this->app->instance(Storecove::class, $storecove);
// Run job twice — second run should not cause issues
(new CheckPeppolDiscovery($client, $client->company))->handle();
$client->refresh();
(new CheckPeppolDiscovery($client, $client->company))->handle();
$client->refresh();
$this->assertTrue($client->sync->peppol_discovery);
}
// ──────────────────────────────────────────────────────
// Job — LT uses LT:LEC for id_number
// ──────────────────────────────────────────────────────
public function testJobTriesLtLecScheme(): void
{
$client = $this->makeClient(440, 'business', [
'id_number' => '1234567',
'vat_number' => 'LT123456789',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('LT:LEC', $triedSchemes, 'LT business should try LT:LEC');
}
// ──────────────────────────────────────────────────────
// Job — IE uses IE:VAT
// ──────────────────────────────────────────────────────
public function testJobTriesIeVatScheme(): void
{
$client = $this->makeClient(372, 'business', [
'vat_number' => 'IE1234567T',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('IE:VAT', $triedSchemes, 'IE business should try IE:VAT');
}
// ──────────────────────────────────────────────────────
// Job — NL uses NL:KVK for id_number, NL:VAT for routing
// ──────────────────────────────────────────────────────
public function testJobTriesNlSchemesForBusiness(): void
{
$client = $this->makeClient(528, 'business', [
'id_number' => '12345678',
'vat_number' => 'NL123456789B01',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('NL:VAT', $triedSchemes, 'NL business should try NL:VAT');
}
// ──────────────────────────────────────────────────────
// Job — IS uses IS:KTNR for id_number
// ──────────────────────────────────────────────────────
public function testJobTriesIsKtnrScheme(): void
{
$client = $this->makeClient(352, 'business', [
'id_number' => '123456',
'vat_number' => 'IS12345',
]);
$triedSchemes = [];
$this->runDiscoveryWithMock($client, function ($identifier, $scheme) use (&$triedSchemes) {
$triedSchemes[] = $scheme;
return false;
});
$this->assertContains('IS:KTNR', $triedSchemes, 'IS business should try IS:KTNR');
}
}