347 lines
11 KiB
PHP
347 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\Verifactu;
|
|
|
|
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 Illuminate\Support\Facades\App;
|
|
use App\Services\EDocument\Standards\Validation\EntityLevelInterface;
|
|
|
|
//@todo - need to implement a rule set for verifactu for validation
|
|
class EntityLevel implements EntityLevelInterface
|
|
{
|
|
private array $errors = [];
|
|
|
|
private array $client_fields = [
|
|
// 'address1',
|
|
// 'city',
|
|
// 'state',
|
|
// 'postal_code',
|
|
// 'vat_number',
|
|
'country_id',
|
|
];
|
|
|
|
private array $company_settings_fields = [
|
|
// 'address1',
|
|
// 'city',
|
|
// 'state',
|
|
// 'postal_code',
|
|
'vat_number',
|
|
'country_id',
|
|
];
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
$_invoice = (new \App\Services\EDocument\Standards\Verifactu\RegistroAlta($invoice))->run()->getInvoice();
|
|
$xml = $_invoice->toXmlString();
|
|
|
|
$xslt = new \App\Services\EDocument\Standards\Validation\VerifactuDocumentValidator($xml);
|
|
$xslt->validate();
|
|
$errors = $xslt->getVerifactuErrors();
|
|
nlog($errors);
|
|
|
|
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->errors['invoice'][] = 'test error';
|
|
|
|
$this->errors['passes'] = count($this->errors['invoice']) === 0 && count($this->errors['company']) === 0; //no need to check client as we are using client level settings
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// if($field == 'vat_number' && $client->classification == 'individual') {
|
|
// continue;
|
|
// }
|
|
|
|
$errors[] = ['field' => $field, 'label' => ctrans("texts.{$field}")];
|
|
|
|
}
|
|
|
|
/** Spanish Client Validation requirements */
|
|
if ($client->country_id == 724) {
|
|
|
|
if (in_array($client->classification, ['','individual']) && strlen($client->id_number ?? '') == 0 && strlen($client->vat_number ?? '') == 0) {
|
|
$errors[] = ['field' => 'id_number', 'label' => ctrans("texts.id_number")];
|
|
} elseif (!in_array($client->classification, ['','individual']) && strlen($client->vat_number ?? '')) {
|
|
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
|
|
}
|
|
|
|
}
|
|
|
|
// else{
|
|
// //If not an individual, you MUST have a VAT number if you are in the EU
|
|
// if (!in_array($client->classification,['','individual']) && !$this->validString($client->vat_number)) {
|
|
// $errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
|
|
// }
|
|
|
|
// }
|
|
|
|
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}")];
|
|
|
|
}
|
|
|
|
//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")];
|
|
}
|
|
|
|
if(!$this->isValidSpanishVAT($company->getSetting('vat_number'))) {
|
|
$errors[] = ['field' => 'vat_number', 'label' => ctrans("texts.vat_number")];
|
|
}
|
|
|
|
return $errors;
|
|
|
|
}
|
|
|
|
private function validString(?string $string): bool
|
|
{
|
|
return iconv_strlen($string) >= 1;
|
|
}
|
|
|
|
public function isValidSpanishVAT(string $vat): bool
|
|
{
|
|
$vat = strtoupper(trim($vat));
|
|
|
|
// Quick format check
|
|
if (!preg_match('/^[A-Z]\d{7}[A-Z0-9]$|^\d{8}[A-Z]$|^[XYZ]\d{7}[A-Z]$/', $vat)) {
|
|
return false;
|
|
}
|
|
|
|
// NIF (individuals)
|
|
if (preg_match('/^\d{8}[A-Z]$/', $vat)) {
|
|
$number = (int)substr($vat, 0, 8);
|
|
$letter = substr($vat, -1);
|
|
$letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
|
|
return $letter === $letters[$number % 23];
|
|
}
|
|
|
|
// NIE (foreigners)
|
|
if (preg_match('/^[XYZ]\d{7}[A-Z]$/', $vat)) {
|
|
$replace = ['X' => '0', 'Y' => '1', 'Z' => '2'];
|
|
$number = (int)($replace[$vat[0]] . substr($vat, 1, 7));
|
|
$letter = substr($vat, -1);
|
|
$letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
|
|
return $letter === $letters[$number % 23];
|
|
}
|
|
|
|
// CIF (companies)
|
|
if (preg_match('/^[ABCDEFGHJKLMNPQRSUVW]\d{7}[0-9A-J]$/', $vat)) {
|
|
$controlLetter = substr($vat, -1);
|
|
$digits = substr($vat, 1, 7);
|
|
|
|
$sumEven = 0;
|
|
$sumOdd = 0;
|
|
for ($i = 0; $i < 7; $i++) {
|
|
$n = (int)$digits[$i];
|
|
if ($i % 2 === 0) { // Odd positions (0-based index)
|
|
$n = $n * 2;
|
|
if ($n > 9) {
|
|
$n = floor($n / 10) + ($n % 10);
|
|
}
|
|
$sumOdd += $n;
|
|
} else {
|
|
$sumEven += $n;
|
|
}
|
|
}
|
|
|
|
$total = $sumEven + $sumOdd;
|
|
$controlDigit = (10 - ($total % 10)) % 10;
|
|
$controlChar = 'JABCDEFGHI'[$controlDigit];
|
|
|
|
$firstLetter = $vat[0];
|
|
if (strpos('PQRSW', $firstLetter) !== false) {
|
|
return $controlLetter === $controlChar; // Must be letter
|
|
} elseif (strpos('ABEH', $firstLetter) !== false) {
|
|
return $controlLetter == $controlDigit; // Must be digit
|
|
} else {
|
|
return ($controlLetter == $controlDigit || $controlLetter === $controlChar);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// // Example usage:
|
|
// var_dump(isValidSpanishVAT("12345678Z")); // true
|
|
// var_dump(isValidSpanishVAT("B12345674")); // true (CIF example)
|
|
// var_dump(isValidSpanishVAT("X1234567L")); // true (NIE)
|
|
|
|
} |