Handle DE => DE

This commit is contained in:
David Bomba
2026-03-26 12:21:00 +11:00
parent 7b43fac720
commit 2e32f321a5
10 changed files with 438 additions and 26 deletions

View File

@@ -121,7 +121,7 @@ class CreatePeppolTestData extends Command
'gov_id' => '003798765432', 'individual_id' => '', 'individual_vat' => '',
],
'FR' => [
'vat' => 'FR82345678911', 'id_number' => '82345678911', 'tax_rate' => 20, 'tax_name' => 'TVA',
'vat' => 'FR82345678911', 'id_number' => '823456789', 'tax_rate' => 20, 'tax_name' => 'TVA',
'city' => 'Paris', 'state' => 'Ile-de-France', 'postal_code' => '75001', 'currency' => '3',
'address1' => 'Rue de Rivoli 1',
'gov_id' => '12345678901234', 'individual_id' => '', 'individual_vat' => '',
@@ -321,7 +321,7 @@ class CreatePeppolTestData extends Command
// ── Oceania ──
'AU' => [
'vat' => '12345678901', 'id_number' => 'ABN12345678901', 'tax_rate' => 10, 'tax_name' => 'GST',
'vat' => '12345678901', 'id_number' => '12345678901', 'tax_rate' => 10, 'tax_name' => 'GST',
'city' => 'Sydney', 'state' => 'NSW', 'postal_code' => '2000', 'currency' => '12',
'address1' => 'George Street 1',
'gov_id' => 'ABN98765432100', 'individual_id' => '', 'individual_vat' => '',
@@ -347,7 +347,7 @@ class CreatePeppolTestData extends Command
'gov_id' => 'T9876543210987', 'individual_id' => '', 'individual_vat' => '',
],
'MY' => [
'vat' => 'MY123456789012', 'id_number' => 'C12345678', 'tax_rate' => 8, 'tax_name' => 'SST',
'vat' => 'MY123456789012', 'id_number' => 'C1234567890', 'tax_rate' => 8, 'tax_name' => 'SST',
'city' => 'Kuala Lumpur', 'state' => 'WP Kuala Lumpur', 'postal_code' => '50000', 'currency' => '19',
'address1' => 'Jalan Bukit Bintang 1',
'gov_id' => 'GOV-MY-001', 'individual_id' => '', 'individual_vat' => '',

View File

@@ -33,7 +33,7 @@ class ClientFactory
$client->is_deleted = false;
$client->client_hash = Str::random(40);
$client->settings = ClientSettings::defaults();
$client->classification = '';
$client->classification = 'business';
return $client;
}

View File

@@ -19,6 +19,7 @@ use App\Http\Requests\EInvoice\UpdateEInvoiceConfiguration;
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Http;
use App\Services\EDocument\Gateway\Storecove\StorecoveRouter;
use InvoiceNinja\EInvoice\Models\Peppol\BranchType\FinancialInstitutionBranch;
use InvoiceNinja\EInvoice\Models\Peppol\FinancialInstitutionType\FinancialInstitution;
use InvoiceNinja\EInvoice\Models\Peppol\FinancialAccountType\PayeeFinancialAccount;
@@ -198,6 +199,19 @@ class EInvoiceController extends BaseController
]);
}
/**
* Returns the static Peppol delivery map for the UI.
*
* Per country: which classifications are routable and
* what client identifiers are required for each.
*/
public function deliveryMap(): JsonResponse
{
$router = new StorecoveRouter();
return response()->json($router->getDeliveryMap());
}
public function healthcheck(HealthcheckRequest $request): JsonResponse
{
/** @var \App\Models\User $user */

View File

@@ -1060,18 +1060,20 @@ class Client extends BaseModel implements HasLocalePreference
}
$br = new \App\DataMapper\Tax\BaseRule();
$supported_countries = array_unique(array_merge($br->peppol_business_countries, $br->peppol_government_countries));
$country_code = $this->country->iso_3166_2;
$government_countries = array_merge($br->peppol_business_countries, $br->peppol_government_countries);
if (in_array($this->country->iso_3166_2, $government_countries) && $this->classification == 'government') {
return null;
if (!in_array($country_code, $supported_countries)) {
return "Country {$this->country->full_name} ( {$country_code} ) is not supported by the PEPPOL network for e-delivery.";
}
if (in_array($this->country->iso_3166_2, $br->peppol_business_countries)) {
return null;
$router = new \App\Services\EDocument\Gateway\Storecove\StorecoveRouter();
if (!$router->isClassificationRoutable($country_code, $this->classification)) {
return ucfirst($this->classification) . " clients in {$this->country->full_name} ( {$country_code} ) are not routable on the Peppol network.";
}
return "Country {$this->country->full_name} ( {$this->country->iso_3166_2} ) is not supported by the PEPPOL network for e-delivery.";
return null;
}

View File

@@ -253,11 +253,16 @@ class Mutator implements MutatorInterface
$identifier = false;
// Non-VAT routing schemes (DK:DIGST, SE:ORGNR, FI:OVT, EE:CC, NO:ORG, LT:LEC, etc.)
// use id_number (org/registry number), not vat_number.
// IT:CUUO uses routing_id (SDI code).
$is_vat_scheme = str_contains($code, ':VAT') || str_contains($code, ':IVA') || str_contains($code, ':CF');
if ($this->invoice->client->country->iso_3166_2 == 'FR') {
$identifier = $this->invoice->client->id_number;
} elseif ($this->invoice->client->country->iso_3166_2 == 'DK') {
$identifier = $this->invoice->client->id_number;
} elseif ($this->invoice->client->country->iso_3166_2 == 'SE' && strlen($this->invoice->client->id_number ?? '') > 2) {
} elseif (str_contains($code, ':CUUO') && strlen($this->invoice->client->routing_id ?? '') > 1) {
$identifier = $this->invoice->client->routing_id;
} elseif (!$is_vat_scheme && strlen($this->invoice->client->id_number ?? '') > 1) {
$identifier = $this->invoice->client->id_number;
} else {
$identifier = $this->invoice->client->vat_number;
@@ -274,6 +279,11 @@ class Mutator implements MutatorInterface
$country_prefix = $this->invoice->client->country->iso_3166_2;
$identifier = preg_replace("/[^a-zA-Z0-9]/", "", $identifier);
// DK:DIGST expects DK prefix on the CVR number — ensure it's present
if ($code === 'DK:DIGST' && !str_starts_with(strtoupper($identifier), 'DK')) {
$identifier = 'DK' . $identifier;
}
//Check the recipient is on the network, and can be delivered the correct document.
if($this->invoice->client->country->iso_3166_2 == "BE"){

View File

@@ -26,8 +26,8 @@ class StorecoveRouter
["B","DUNS, GLN, LEI","US:EIN","DUNS, GLN, LEI"],
// ["B","DUNS, GLN, LEI","US:SSN","DUNS, GLN, LEI"],
],
"CA" => ["B","CA:CBN",false,"CA:CBN"],
"MX" => ["B","MX:RFC",false,"MX:RFC"],
"CA" => ["B","CA:CBN","CA:CBN","CA:CBN"],
"MX" => ["B","MX:RFC","MX:RFC","MX:RFC"],
"AU" => ["B+G","AU:ABN","AU:ABN","AU:ABN"],
"NZ" => ["B+G","GLN","NZ:GST","GLN"],
"CH" => ["B+G","CH:UIDB","CH:VAT","CH:UIDB"],
@@ -70,7 +70,7 @@ class StorecoveRouter
["G","","IT:IVA","IT:CUUO"],// (SDI)
],
"LT" => ["B+G","LT:LEC","LT:VAT","LT:LEC"],
"LU" => ["B+G","LU:MAT","LU:VAT","LU:VAT"],
"LU" => ["B+G","LU:VAT","LU:VAT","LU:VAT"],
"LV" => ["B+G","","LV:VAT","LV:VAT"],
"MC" => ["B+G","","MC:VAT","MC:VAT"],
"ME" => ["B+G","","ME:VAT","ME:VAT"],
@@ -165,7 +165,7 @@ class StorecoveRouter
'SE:ORGNR' => '/^\d{10}$/',
'NO:ORG' => '/^\d{9}$/',
'BE:EN' => '/^(BE)?\d{10}$/i',
'DK:DIGST' => '/^\d{8,10}$/',
'DK:DIGST' => '/^(DK)?\d{8}$/i',
'EE:CC' => '/^\d{8}$/',
'FI:OVT' => '/^\d{12,13}$/',
'FR:SIRENE' => '/^\d{9}$/',
@@ -316,6 +316,43 @@ class StorecoveRouter
return $rules[0][2];
}
/**
* Checks whether a classification (business/government/individual) is routable
* on the Peppol network for a given country.
*
* @param string $country ISO 3166-2 country code
* @param string $classification business|government|individual
* @return bool
*/
public function isClassificationRoutable(string $country, string $classification): bool
{
$rules = $this->routing_rules[$country] ?? null;
if (!$rules) {
return false;
}
$code = match ($classification) {
'government' => 'G',
'individual' => 'C',
default => 'B',
};
// Single-array country (e.g. ["B+G", ...])
if (is_array($rules) && !is_array($rules[0])) {
return stripos($rules[0], $code) !== false;
}
// Multi-array — check if any rule matches this classification
foreach ($rules as $r) {
if (stripos($r[0], $code) !== false) {
return true;
}
}
return false;
}
/**
* Returns the required client fields for a given country/classification.
*
@@ -520,6 +557,42 @@ class StorecoveRouter
return $map[$scheme] ?? $scheme;
}
/**
* Returns a static delivery map for all supported countries.
*
* Each entry contains routability by classification and the required
* client identifiers, so the UI can determine sendability without
* calling the validation endpoint.
*
* @return array<string, array{
* classifications: array<string, bool>,
* required_fields: array<string, array<string, string>>
* }>
*/
public function getDeliveryMap(): array
{
$map = [];
foreach ($this->routing_rules as $country => $rules) {
$entry = [
'classifications' => [
'business' => $this->isClassificationRoutable($country, 'business'),
'government' => $this->isClassificationRoutable($country, 'government'),
'individual' => $this->isClassificationRoutable($country, 'individual'),
],
'required_fields' => [
'business' => $this->resolveRequiredClientFields($country, 'business'),
'government' => $this->resolveRequiredClientFields($country, 'government'),
'individual' => $this->resolveRequiredClientFields($country, 'individual'),
],
];
$map[$country] = $entry;
}
return $map;
}
public function resolveIdentifierTypeByValue(string $identifier): string
{
$parts = explode(":", $identifier);

View File

@@ -102,7 +102,7 @@ class SendEDocument implements ShouldQueue
'e_invoicing_token' => $model->company->account->e_invoicing_token,
];
nlog("payload", $payload);
// nlog("payload", $payload);
//Self Hosted Sending Code Path
if (Ninja::isSelfHost() && ($model instanceof Invoice || $model instanceof Credit) && $model->company->peppolSendingEnabled()) {

View File

@@ -176,6 +176,7 @@ class EntityLevel implements EntityLevelInterface
public function checkClient(Client $client): array
{
$this->init($client->locale());
$this->errors['client'] = $this->testClientState($client);
$this->errors['passes'] = count($this->errors['client']) == 0;
@@ -204,7 +205,7 @@ class EntityLevel implements EntityLevelInterface
$this->init($invoice->client->locale());
$this->errors['invoice'] = [];
$this->errors['client'] = $this->testClientState($invoice->client);
$this->errors['client'] = $this->testClientState($invoice->client);
$this->errors['company'] = $this->testCompanyState($invoice->client); // uses client level settings which is what we want
if (count($this->errors['client']) > 0) {
@@ -312,21 +313,21 @@ class EntityLevel implements EntityLevelInterface
}
}
//Primary contact email is present.
if ($client->present()->email() == 'No Email Set') {
$errors[] = ['field' => 'email', 'label' => ctrans("texts.email")];
}
$delivery_network_supported = $client->checkDeliveryNetwork();
if (is_string($delivery_network_supported)) {
$errors[] = ['field' => ctrans("texts.country"), 'label' => $delivery_network_supported];
if ($client->country_id && $client->country) {
$non_routable = $client->checkDeliveryNetwork();
if (is_string($non_routable)) {
$errors[] = ['field' => 'classification', 'label' => $non_routable];
}
}
return $errors;
}

View File

@@ -258,6 +258,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::post('einvoice/token/update', EInvoiceTokenController::class)->name('einvoice.token.update');
Route::get('einvoice/quota', [EInvoiceController::class, 'quota'])->name('einvoice.quota');
Route::get('einvoice/health_check', [EInvoiceController::class, 'healthcheck'])->name('einvoice.healthcheck');
Route::get('einvoice/delivery_map', [EInvoiceController::class, 'deliveryMap'])->name('einvoice.delivery_map');
Route::post('emails', [EmailController::class, 'send'])->name('email.send')->middleware('user_verified');
Route::post('emails/clientHistory/{client}', [EmailHistoryController::class, 'clientHistory'])->name('email.clientHistory');

View File

@@ -0,0 +1,311 @@
<?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 Illuminate\Foundation\Testing\DatabaseTransactions;
class CheckDeliveryNetworkTest extends TestCase
{
use MockAccountData;
use DatabaseTransactions;
private Company $testCompany;
protected function setUp(): void
{
parent::setUp();
$this->makeTestData();
$this->testCompany = Company::factory()->create([
'account_id' => $this->account->id,
]);
}
private function makeClient(int $countryId, string $classification): Client
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->testCompany->id,
'country_id' => $countryId,
'classification' => $classification,
]);
ClientContact::factory()->create([
'user_id' => $this->user->id,
'client_id' => $client->id,
'company_id' => $this->testCompany->id,
'is_primary' => 1,
'email' => 'test@example.com',
]);
return $client->fresh();
}
// ────────────────────────────<E29480><E29480><EFBFBD>──────────────────────<E29480><E29480><EFBFBD>──
// No country set
// ───────────────────────────<E29480><E29480>──────────────────────────
public function testNoCountryReturnsError(): void
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->testCompany->id,
'country_id' => null,
'classification' => 'business',
]);
$result = $client->fresh()->checkDeliveryNetwork();
$this->assertIsString($result);
$this->assertStringContainsString('country', strtolower($result));
}
// ───────────────────────<E29480><E29480><EFBFBD>─────────────────────<E29480><E29480><EFBFBD>────────
// Peppol Business Countries — B+G routable, C blocked
// AT, BE, DK, EE, FI, DE, IS, LT, LU, NL, NO, SE, IE
// ──────────────────────────────────<E29480><E29480>───────────────────
/**
* @dataProvider peppolBusinessCountryProvider
*/
public function testBusinessCountryBusinessIsRoutable(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'business');
$this->assertNull($client->checkDeliveryNetwork(), "$countryCode business should be routable");
}
/**
* @dataProvider peppolBusinessCountryProvider
*/
public function testBusinessCountryGovernmentIsRoutable(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'government');
$this->assertNull($client->checkDeliveryNetwork(), "$countryCode government should be routable");
}
/**
* @dataProvider peppolBusinessCountryProvider
*/
public function testBusinessCountryIndividualIsBlocked(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'individual');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "$countryCode individual should be blocked");
$this->assertStringContainsStringIgnoringCase('individual', $result);
}
public static function peppolBusinessCountryProvider(): array
{
return [
'AT' => [40, 'AT'],
'BE' => [56, 'BE'],
'DK' => [208, 'DK'],
'EE' => [233, 'EE'],
'FI' => [246, 'FI'],
'DE' => [276, 'DE'],
'IS' => [352, 'IS'],
'LT' => [440, 'LT'],
'LU' => [442, 'LU'],
'NL' => [528, 'NL'],
'NO' => [578, 'NO'],
'SE' => [752, 'SE'],
'IE' => [372, 'IE'],
];
}
// ───────────────────────────────────<E29480><E29480><EFBFBD>──────────────────
// Peppol Government Countries — G routable, C blocked
// FR, GR, PT, RO, SI, ES, GB
// ──────────────────────────────────────────────────────
/**
* @dataProvider peppolGovernmentCountryProvider
*/
public function testGovernmentCountryGovernmentIsRoutable(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'government');
$this->assertNull($client->checkDeliveryNetwork(), "$countryCode government should be routable");
}
/**
* @dataProvider peppolGovernmentCountryProvider
*/
public function testGovernmentCountryIndividualIsBlocked(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'individual');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "$countryCode individual should be blocked");
}
public static function peppolGovernmentCountryProvider(): array
{
return [
'FR' => [250, 'FR'],
'GR' => [300, 'GR'],
'PT' => [620, 'PT'],
'RO' => [642, 'RO'],
'SI' => [705, 'SI'],
];
}
// ──────────────────────────────────────────────────────
// ES and GB — business in routing rules
// ─────────────────────────────────<E29480><E29480>────────────────────
public function testEsBusinessIsRoutable(): void
{
$client = $this->makeClient(724, 'business');
$this->assertNull($client->checkDeliveryNetwork(), "ES business should be routable");
}
public function testGbBusinessIsRoutable(): void
{
$client = $this->makeClient(826, 'business');
$this->assertNull($client->checkDeliveryNetwork(), "GB business should be routable");
}
public function testEsGovernmentIsBlocked(): void
{
$client = $this->makeClient(724, 'government');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "ES government should be blocked - routing rules only support B");
}
public function testGbGovernmentIsBlocked(): void
{
$client = $this->makeClient(826, 'government');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "GB government should be blocked - routing rules only support B");
}
// ───<E29480><E29480><EFBFBD>──────────────────────────────────────────────────
// Unsupported countries — all classifications blocked
// ───────────────────<E29480><E29480>─────────────────────────────<E29480><E29480>────
/**
* @dataProvider unsupportedCountryProvider
*/
public function testUnsupportedCountryBusinessIsBlocked(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'business');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "$countryCode business should be blocked");
}
/**
* @dataProvider unsupportedCountryProvider
*/
public function testUnsupportedCountryGovernmentIsBlocked(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'government');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "$countryCode government should be blocked");
}
/**
* @dataProvider unsupportedCountryProvider
*/
public function testUnsupportedCountryIndividualIsBlocked(int $countryId, string $countryCode): void
{
$client = $this->makeClient($countryId, 'individual');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "$countryCode individual should be blocked");
}
public static function unsupportedCountryProvider(): array
{
return [
'US' => [840, 'US'],
'AU' => [36, 'AU'],
'JP' => [392, 'JP'],
'BR' => [76, 'BR'],
'IN' => [356, 'IN'],
'CN' => [156, 'CN'],
];
}
// ─────<E29480><E29480><EFBFBD>──────────────────────<E29480><E29480>─────────────────────────
// IT — commented out of peppol_business_countries
// ───────────────────────────<E29480><E29480><EFBFBD>──────────────────────────
public function testItBusinessIsBlocked(): void
{
$client = $this->makeClient(380, 'business');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "IT should be blocked — not in supported country lists");
}
public function testItGovernmentIsBlocked(): void
{
$client = $this->makeClient(380, 'government');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "IT government should be blocked — not in supported country lists");
}
public function testItIndividualIsBlocked(): void
{
$client = $this->makeClient(380, 'individual');
$result = $client->checkDeliveryNetwork();
$this->assertIsString($result, "IT individual should be blocked — not in supported country lists");
}
// ──────────────<E29480><E29480>───────────────────────<E29480><E29480><EFBFBD>───────────────
// Null classification — must NOT silently pass
// ──────────────────────────<E29480><E29480>───────────────────────────
public function testNullClassificationBeIsBlocked(): void
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->testCompany->id,
'country_id' => 56, // BE
'classification' => null,
]);
$result = $client->fresh()->checkDeliveryNetwork();
$this->assertIsString($result, "BE with null classification should be blocked");
}
public function testNullClassificationDeIsBlocked(): void
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->testCompany->id,
'country_id' => 276, // DE
'classification' => null,
]);
$result = $client->fresh()->checkDeliveryNetwork();
$this->assertIsString($result, "DE with null classification should be blocked");
}
// ──────────────────────────────────────────────────────
// peppolSendingEnabled cross-check
// <20><><EFBFBD>────────────<E29480><E29480><EFBFBD>────────────────────────────────────────
public function testPeppolSendingEnabledFalseForIndividualBe(): void
{
$client = $this->makeClient(56, 'individual');
$this->assertFalse($client->peppolSendingEnabled(), "BE individual peppolSendingEnabled should be false");
}
public function testPeppolSendingEnabledFalseForUnsupportedCountry(): void
{
$client = $this->makeClient(840, 'business');
$this->assertFalse($client->peppolSendingEnabled(), "US business peppolSendingEnabled should be false");
}
}