mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-04-18 12:10:50 +00:00
Improvements for sending peppol - discovery() checks for clients
This commit is contained in:
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
41
app/Http/Requests/EInvoice/Peppol/DiscoveryRequest.php
Normal file
41
app/Http/Requests/EInvoice/Peppol/DiscoveryRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
184
app/Jobs/Client/CheckPeppolDiscovery.php
Normal file
184
app/Jobs/Client/CheckPeppolDiscovery.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
995
tests/Feature/EInvoice/PeppolDiscoveryTest.php
Normal file
995
tests/Feature/EInvoice/PeppolDiscoveryTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user