359 lines
11 KiB
PHP
359 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Invoice Ninja (https://invoiceninja.com).
|
|
*
|
|
* @link https://github.com/invoiceninja/invoiceninja source repository
|
|
*
|
|
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
|
*
|
|
* @license https://www.elastic.co/licensing/elastic-license
|
|
*/
|
|
|
|
namespace App\Services\EDocument\Standards\Validation\Peppol;
|
|
|
|
use App\Exceptions\PeppolValidationException;
|
|
use App\Models\Quote;
|
|
use App\Models\Client;
|
|
use App\Models\Credit;
|
|
use App\Models\Vendor;
|
|
use App\Models\Company;
|
|
use App\Models\Invoice;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Services\EDocument\Standards\Peppol;
|
|
use App\Services\EDocument\Standards\Validation\XsltDocumentValidator;
|
|
use App\Services\EDocument\Standards\Validation\EntityLevelInterface;
|
|
use Illuminate\Support\Facades\App;
|
|
use XSLTProcessor;
|
|
|
|
class EntityLevel implements EntityLevelInterface
|
|
{
|
|
private array $eu_country_codes = [
|
|
'AT', // Austria
|
|
'BE', // Belgium
|
|
'BG', // Bulgaria
|
|
'CY', // Cyprus
|
|
'CZ', // Czech Republic
|
|
'DE', // Germany
|
|
'DK', // Denmark
|
|
'EE', // Estonia
|
|
'ES', // Spain
|
|
'ES-CN', // Canary Islands
|
|
'ES-CE', // Ceuta
|
|
'ES-ML', // Melilla
|
|
'FI', // Finland
|
|
'FR', // France
|
|
'GR', // Greece
|
|
'HR', // Croatia
|
|
'HU', // Hungary
|
|
'IE', // Ireland
|
|
'IT', // Italy
|
|
'LT', // Lithuania
|
|
'LU', // Luxembourg
|
|
'LV', // Latvia
|
|
'MT', // Malta
|
|
'NL', // Netherlands
|
|
'PL', // Poland
|
|
'PT', // Portugal
|
|
'RO', // Romania
|
|
'SE', // Sweden
|
|
'SI', // Slovenia
|
|
'SK', // Slovakia
|
|
];
|
|
|
|
private array $client_fields = [
|
|
'address1',
|
|
'city',
|
|
// 'state',
|
|
'postal_code',
|
|
'country_id',
|
|
];
|
|
|
|
private array $company_settings_fields = [
|
|
'address1',
|
|
'city',
|
|
// 'state',
|
|
'postal_code',
|
|
'country_id',
|
|
];
|
|
|
|
private array $company_fields = [
|
|
// 'legal_entity_id',
|
|
// 'vat_number IF NOT an individual
|
|
];
|
|
|
|
private array $invoice_fields = [
|
|
// 'number',
|
|
];
|
|
|
|
private array $errors = [];
|
|
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
private function init(string $locale): self
|
|
{
|
|
|
|
App::forgetInstance('translator');
|
|
$t = app('translator');
|
|
App::setLocale($locale);
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
public function checkClient(Client $client): array
|
|
{
|
|
$this->init($client->locale());
|
|
$this->errors['client'] = $this->testClientState($client);
|
|
$this->errors['passes'] = count($this->errors['client']) == 0;
|
|
|
|
return $this->errors;
|
|
|
|
}
|
|
|
|
public function checkCompany(Company $company): array
|
|
{
|
|
|
|
$this->init($company->locale());
|
|
$this->errors['company'] = $this->testCompanyState($company);
|
|
$this->errors['passes'] = count($this->errors['company']) == 0;
|
|
|
|
return $this->errors;
|
|
|
|
}
|
|
|
|
public function checkInvoice(Invoice $invoice): array
|
|
{
|
|
$this->init($invoice->client->locale());
|
|
|
|
$this->errors['invoice'] = [];
|
|
$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) {
|
|
|
|
$this->errors['passes'] = false;
|
|
return $this->errors;
|
|
|
|
}
|
|
|
|
$p = new Peppol($invoice);
|
|
|
|
$xml = false;
|
|
|
|
try {
|
|
$xml = $p->run()->toXml();
|
|
|
|
if (count($p->getErrors()) >= 1) {
|
|
|
|
foreach ($p->getErrors() as $error) {
|
|
$this->errors['invoice'][] = $error;
|
|
}
|
|
}
|
|
|
|
} catch (PeppolValidationException $e) {
|
|
$this->errors['invoice'] = ['field' => $e->getInvalidField(), 'label' => $e->getInvalidField()];
|
|
} catch (\Throwable $th) {
|
|
|
|
}
|
|
|
|
if ($xml) {
|
|
// Second pass through the XSLT validator
|
|
$xslt = new XsltDocumentValidator($xml);
|
|
$errors = $xslt->validate()->getErrors();
|
|
|
|
if (isset($errors['stylesheet']) && count($errors['stylesheet']) > 0) {
|
|
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['stylesheet']);
|
|
}
|
|
|
|
if (isset($errors['general']) && count($errors['general']) > 0) {
|
|
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['general']);
|
|
}
|
|
|
|
if (isset($errors['xsd']) && count($errors['xsd']) > 0) {
|
|
$this->errors['invoice'] = array_merge($this->errors['invoice'], $errors['xsd']);
|
|
}
|
|
|
|
}
|
|
|
|
$this->checkNexus($invoice->client);
|
|
|
|
$this->errors['passes'] = count($this->errors['invoice']) == 0 && count($this->errors['client']) == 0 && count($this->errors['company']) == 0;
|
|
|
|
return $this->errors;
|
|
|
|
}
|
|
|
|
private function testClientState(Client $client): array
|
|
{
|
|
|
|
$errors = [];
|
|
|
|
foreach ($this->client_fields as $field) {
|
|
|
|
if ($this->validString($client->{$field})) {
|
|
continue;
|
|
}
|
|
|
|
if ($field == 'country_id' && $client->country_id >= 1) {
|
|
continue;
|
|
}
|
|
|
|
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
|
|
|
|
}
|
|
|
|
//If not an individual, you MUST have a VAT number if you are in the EU
|
|
if (!in_array($client->classification, ['government', 'individual']) && in_array($client->country->iso_3166_2, $this->eu_country_codes) && !$this->validString($client->vat_number)) {
|
|
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
|
|
}
|
|
|
|
//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];
|
|
|
|
|
|
|
|
return $errors;
|
|
|
|
}
|
|
|
|
private function testCompanyState(mixed $entity): array
|
|
{
|
|
|
|
$client = false;
|
|
$vendor = false;
|
|
$settings_object = false;
|
|
$company = false;
|
|
|
|
if ($entity instanceof Client) {
|
|
$client = $entity;
|
|
$company = $entity->company;
|
|
$settings_object = $client;
|
|
} elseif ($entity instanceof Company) {
|
|
$company = $entity;
|
|
$settings_object = $company;
|
|
} elseif ($entity instanceof Vendor) {
|
|
$vendor = $entity;
|
|
$company = $entity->company;
|
|
$settings_object = $company;
|
|
} elseif ($entity instanceof Invoice || $entity instanceof Credit || $entity instanceof Quote) {
|
|
$client = $entity->client;
|
|
$company = $entity->company;
|
|
$settings_object = $entity->client;
|
|
} elseif ($entity instanceof PurchaseOrder) {
|
|
$vendor = $entity->vendor;
|
|
$company = $entity->company;
|
|
$settings_object = $company;
|
|
}
|
|
|
|
$errors = [];
|
|
|
|
foreach ($this->company_settings_fields as $field) {
|
|
|
|
if ($this->validString($settings_object->getSetting($field))) {
|
|
continue;
|
|
}
|
|
|
|
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
|
|
|
|
}
|
|
|
|
//test legal entity id present
|
|
if (!is_int($company->legal_entity_id)) {
|
|
$errors[] = ['field' => "You have not registered a legal entity id as yet."];
|
|
}
|
|
|
|
//If not an individual, you MUST have a VAT number
|
|
if ($company->getSetting('classification') != 'individual' && !$this->validString($company->getSetting('vat_number'))) {
|
|
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
|
|
} elseif ($company->getSetting('classification') == 'individual' && !$this->validString($company->getSetting('id_number'))) {
|
|
$errors[] = ['field' => 'id_number', 'label' => ctrans("texts.id_number")];
|
|
}
|
|
|
|
|
|
// foreach($this->company_fields as $field)
|
|
// {
|
|
|
|
// }
|
|
|
|
return $errors;
|
|
|
|
}
|
|
|
|
// private function testInvoiceState($entity): array
|
|
// {
|
|
// $errors = [];
|
|
|
|
// foreach($this->invoice_fields as $field)
|
|
// {
|
|
|
|
// }
|
|
|
|
// return $errors;
|
|
// }
|
|
|
|
// private function testVendorState(): array
|
|
// {
|
|
|
|
// }
|
|
|
|
|
|
/************************************ helpers ************************************/
|
|
private function validString(?string $string): bool
|
|
{
|
|
return iconv_strlen($string) >= 1;
|
|
}
|
|
|
|
private function checkNexus(Client $client): self
|
|
{
|
|
|
|
$company_country_code = $client->company->country()->iso_3166_2;
|
|
$client_country_code = $client->country->iso_3166_2;
|
|
$br = new \App\DataMapper\Tax\BaseRule();
|
|
$eu_countries = $br->eu_country_codes;
|
|
|
|
if ($client_country_code == $company_country_code) {
|
|
} elseif (in_array($company_country_code, $eu_countries) && !in_array($client_country_code, $eu_countries)) {
|
|
} elseif (in_array($client_country_code, $eu_countries)) {
|
|
|
|
// First, determine if we're over threshold
|
|
$is_over_threshold = isset($client->company->tax_data->regions->EU->has_sales_above_threshold) &&
|
|
$client->company->tax_data->regions->EU->has_sales_above_threshold;
|
|
|
|
// Is this B2B or B2C?
|
|
$is_b2c = strlen($client->vat_number) < 2 ||
|
|
!($client->has_valid_vat_number ?? false) ||
|
|
$client->classification == 'individual';
|
|
|
|
// B2C, under threshold, no Company VAT Registerd - must charge origin country VAT
|
|
if ($is_b2c && !$is_over_threshold && strlen($client->company->settings->vat_number) < 2) {
|
|
|
|
} elseif ($is_b2c) {
|
|
if ($is_over_threshold) {
|
|
// B2C over threshold - need destination VAT number
|
|
if (!isset($client->company->tax_data->regions->EU->subregions->{$client_country_code}->vat_number)) {
|
|
$this->errors['invoice'][] = "Tax Nexus is client country ({$client_country_code}) - however VAT number not present for this region.";
|
|
}
|
|
}
|
|
|
|
} elseif ($is_over_threshold && !in_array($company_country_code, $eu_countries)) {
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
|
|
}
|