Add IDOtro class

This commit is contained in:
David Bomba 2025-08-14 10:07:32 +10:00
parent 1252cdf7ae
commit 0a7744a70e
13 changed files with 308 additions and 56 deletions

View File

@ -41,7 +41,7 @@ class EInvoiceController extends BaseController
*/
public function validateEntity(ValidateEInvoiceRequest $request)
{
$el = new EntityLevel();
$el = $request->getValidatorClass();
$data = [];

View File

@ -205,9 +205,9 @@ class UpdateCompanyRequest extends Request
}
}
if($this->company->settings->e_invoice_type == 'verifactu') {
if($this->company->settings->e_invoice_type == 'VERIFACTU') {
$settings['lock_invoices'] = 'when_sent';
$settings['e_invoice_type'] = 'verifactu';
$settings['e_invoice_type'] = 'VERIFACTU';
}
}

View File

@ -16,6 +16,7 @@ use App\Models\Client;
use App\Models\Company;
use App\Models\Invoice;
use App\Http\Requests\Request;
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
use Illuminate\Validation\Rule;
class ValidateEInvoiceRequest extends Request
@ -91,4 +92,25 @@ class ValidateEInvoiceRequest extends Request
return $class::withTrashed()->find(is_string($this->entity_id) ? $this->decodePrimaryKey($this->entity_id) : $this->entity_id);
}
/**
* getValidatorClass
*
* Return the validator class based on the EInvoicing Standard
*
* @return \App\Services\EDocument\Standards\Validation\EntityLevelInterface
*/
public function getValidatorClass()
{
$user = auth()->user();
if($user->company()->settings->e_invoice_type == 'VERIFACTU') {
return new \App\Services\EDocument\Standards\Validation\Verifactu\EntityLevel();
}
// if($user->company()->settings->e_invoice_type == 'PEPPOL') {
return new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
// }
}
}

View File

@ -43,10 +43,6 @@ class VerifactuAmountCheck implements ValidationRule
$client = Client::withTrashed()->find($this->input['client_id']);
if($client->country->iso_3166_2 !== 'ES') { // Client level check if client is in Spain
return;
}
$invoice = false;
$child_invoices = false;
$child_invoice_totals = 0;
@ -97,7 +93,10 @@ class VerifactuAmountCheck implements ValidationRule
$total = $items->sum() - $total_discount;
if($total < 0 && !$invoice) {
if($total > 0 && $invoice) {
$fail("Only negative invoices can be linked to existing invoices {$total}");
}
elseif($total < 0 && !$invoice) {
$fail("Negative invoices {$total} can only be linked to existing invoices");
}
elseif($invoice && ($total + $child_invoice_totals + $invoice->amount) < 0) {

View File

@ -1041,7 +1041,7 @@ class Company extends BaseModel
public function verifactuEnabled(): bool
{
return once(function () {
return $this->getSetting('e_invoice_type') == 'verifactu';
return $this->getSetting('e_invoice_type') == 'VERIFACTU';
});
}
}

View File

@ -330,7 +330,7 @@ class BaseRepository
}
/** Verifactu modified invoice check */
if($model->company->verifactuEnabled() && $client->country->iso_3166_2 === 'ES') {
if($model->company->verifactuEnabled()) {
$model->service()->modifyVerifactuWorkflow($data, $this->new_model)->save();
}
}

View File

@ -23,18 +23,20 @@ class EntityLevel implements EntityLevelInterface
private array $errors = [];
private array $client_fields = [
'address1',
'city',
// 'address1',
// 'city',
// 'state',
'postal_code',
// 'postal_code',
'vat_number',
'country_id',
];
private array $company_settings_fields = [
'address1',
'city',
// 'address1',
// 'city',
// 'state',
'postal_code',
// 'postal_code',
'vat_number',
'country_id',
];
@ -181,4 +183,72 @@ class EntityLevel implements EntityLevelInterface
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)
}

View File

@ -0,0 +1,125 @@
<?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\Verifactu\Models;
use App\Services\EDocument\Standards\Verifactu\Models\BaseXmlModel;
class IDOtro extends BaseXmlModel
{
private const VALID_ID_TYPES = [
'01', // NIF IVA (EU operator with VAT number, non-Spanish)
'02', // NIF in Spain
'03', // VAT number (EU operator without Spanish NIF)
'04', // Passport
'05', // Official ID document
'06', // Residence certificate
'07', // Person without identification code
'08', // Other supporting document
'09', // Tax ID from third country
];
/**
* __construct
*
* @param string $codigoPais ISO 3166-1 alpha-2 country code (e.g., ES, FR, US)
* @param string $idType AEAT ID type code (e.g., '07' = Person without identification code)
* @param string $id Identifier value, e.g., passport number, tax ID, or placeholder
* @return void
*/
public function __construct(private string $codigoPais = 'ES', private string $idType = '07', private string $id = 'NO_DISPONIBLE')
{
}
public function setCodigoPais(string $codigoPais): self
{
$this->codigoPais = strtoupper($codigoPais);
return $this;
}
public function setIdType(string $idType): self
{
$this->idType = $idType;
return $this;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
/**
* Returns the array structure for serialization to XML
*/
public function toArray(): array
{
return [
'CodigoPais' => $this->codigoPais,
'IDType' => $this->idType,
'ID' => $this->id,
];
}
/**
* Returns the XML fragment for IDOtro
*/
public function toXml(\DOMDocument $doc): \DOMElement
{
$root = $this->createElement($doc, 'IDOtro');
$root->appendChild($this->createElement($doc, 'CodigoPais', $this->codigoPais));
$root->appendChild($this->createElement($doc, 'IDType', $this->idType));
$root->appendChild($this->createElement($doc, 'ID', $this->id));
return $root;
}
/**
* Create a PersonaFisicaJuridica instance from XML string or DOMElement
*/
public static function fromXml($xml): BaseXmlModel
{
if (is_string($xml)) {
$doc = new \DOMDocument();
$doc->loadXML($xml);
$element = $doc->documentElement;
} else {
$element = $xml;
}
return self::fromDOMElement($element);
}
public static function fromDOMElement(\DOMElement $element): self
{
$idOtro = new self();
$codigoPaisElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'CodigoPais')->item(0);
if ($codigoPaisElement) {
$idOtro->setCodigoPais($codigoPaisElement->nodeValue);
}
$idTypeElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDType')->item(0);
if ($idTypeElement) {
$idOtro->setIdType($idTypeElement->nodeValue);
}
$idElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'ID')->item(0);
if ($idElement) {
$idOtro->setId($idElement->nodeValue);
}
return $idOtro;
}
}

View File

@ -1,5 +1,15 @@
<?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\Verifactu\Models;
use App\Services\EDocument\Standards\Verifactu\Models\BaseXmlModel;
@ -12,7 +22,7 @@ class PersonaFisicaJuridica extends BaseXmlModel
protected ?string $nombre = null;
protected ?string $razonSocial = null;
protected ?string $tipoIdentificacion = null;
protected ?string $idOtro = null;
protected ?IDOtro $idOtro = null;
protected ?string $pais = null;
public function getNif(): ?string
@ -84,12 +94,12 @@ class PersonaFisicaJuridica extends BaseXmlModel
return $this;
}
public function getIdOtro(): ?string
public function getIdOtro(): IDOtro
{
return $this->idOtro;
}
public function setIdOtro(?string $idOtro): self
public function setIdOtro(IDOtro $idOtro): self
{
$this->idOtro = $idOtro;
return $this;
@ -135,7 +145,7 @@ class PersonaFisicaJuridica extends BaseXmlModel
}
if ($this->idOtro !== null) {
$root->appendChild($this->createElement($doc, 'IDOtro', $this->idOtro));
$root->appendChild($this->idOtro->toXml($doc));
}
if ($this->pais !== null) {

View File

@ -15,21 +15,22 @@ namespace App\Services\EDocument\Standards\Verifactu;
use App\Models\Company;
use App\Models\Invoice;
use App\Models\Product;
use App\Models\VerifactuLog;
use App\Helpers\Invoice\Taxer;
use App\Utils\Traits\MakesHash;
use App\DataMapper\Tax\BaseRule;
use App\Services\AbstractService;
use App\Helpers\Invoice\InvoiceSum;
use App\Utils\Traits\NumberFormatter;
use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Services\EDocument\Standards\Verifactu\Models\IDOtro;
use App\Services\EDocument\Standards\Verifactu\Models\Desglose;
use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento;
use App\Services\EDocument\Standards\Verifactu\Models\IDFactura;
use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento;
use App\Services\EDocument\Standards\Verifactu\Models\RegistroAnterior;
use App\Services\EDocument\Standards\Verifactu\Models\SistemaInformatico;
use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica;
use App\Services\EDocument\Standards\Verifactu\Models\Invoice as VerifactuInvoice;
use App\Models\VerifactuLog;
use App\Utils\Traits\MakesHash;
class RegistroAlta
{
@ -130,15 +131,28 @@ class RegistroAlta
$emisor->setNif($this->company->settings->vat_number)
->setNombreRazon($this->invoice->company->present()->name());
// $this->v_invoice->setTercero($emisor);
/** The business entity (Client) that is receiving the invoice */
$destinatarios = [];
$destinatario = new PersonaFisicaJuridica();
$destinatario
->setNif($this->invoice->client->vat_number)
->setNombreRazon($this->invoice->client->present()->name());
if($this->invoice->client->country_id == 724) {
$destinatario
->setNif($this->invoice->client->vat_number)
->setNombreRazon($this->invoice->client->present()->name());
}
else {
$locationData = $this->invoice->location();
$destinatario = new IDOtro();
$destinatario->setCodigoPais($locationData['country_code']);
$br = new \App\DataMapper\Tax\BaseRule();
if(in_array($locationData['country_code'], $br->eu_country_codes) && strlen($this->invoice->client->vat_number ?? '') > 0) {
$destinatario->setIdType('03');
$destinatario->setId($this->invoice->client->vat_number);
}
}
$destinatarios[] = $destinatario;

View File

@ -72,11 +72,11 @@ class SendToAeat implements ShouldQueue
$invoice = Invoice::withTrashed()->find($this->invoice_id);
if($invoice->client->country->iso_3166_2 != 'ES') {
$invoice->backup->guid = 'NOT_ES';
$invoice->saveQuietly();
return;
}
// if($invoice->client->country->iso_3166_2 != 'ES') {
// $invoice->backup->guid = 'NOT_ES';
// $invoice->saveQuietly();
// return;
// }
switch($this->action) {
case 'create':

View File

@ -5580,6 +5580,7 @@ $lang = array(
'verifactu_invoice_sent_failure' => 'Invoice :invoice for :client failed to send to AEAT :notes',
'verifactu_cancellation_send_success' => 'Invoice cancellation for :invoice sent to AEAT successfully',
'verifactu_cancellation_send_failure' => 'Invoice cancellation for :invoice failed to send to AEAT :notes',
'verifactu' => 'Verifactu',
);
return $lang;

View File

@ -85,7 +85,8 @@ class VerifactuApiTest extends TestCase
]);
$invoice->backup->document_type = 'F1';
$invoice->backup->adjustable_amount = 121;
$repo = new InvoiceRepository();
$invoice = $repo->save([], $invoice);
@ -97,7 +98,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$settings->is_locked = 'when_sent';
$this->company->settings = $settings;
@ -117,6 +118,7 @@ class VerifactuApiTest extends TestCase
$this->assertEquals('F1', $invoice->backup->document_type);
$this->assertEquals('R2', $invoice2->backup->document_type);
$this->assertEquals($invoice->hashed_id, $invoice2->backup->parent_invoice_id);
$this->assertCount(1, $invoice->backup->child_invoice_ids);
$data = [
@ -153,7 +155,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$settings->is_locked = 'when_sent';
$this->company->settings = $settings;
@ -192,7 +194,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -219,6 +221,16 @@ class VerifactuApiTest extends TestCase
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals('R2', $arr['data']['backup']['document_type']);
$this->assertEquals($invoice->hashed_id, $arr['data']['backup']['parent_invoice_id']);
$invoice = $invoice->fresh();
$this->assertEquals('F1', $invoice->backup->document_type);
$this->assertCount(1, $invoice->backup->child_invoice_ids);
$data = [
'action' => 'delete',
'ids' => [$invoice->hashed_id]
@ -237,7 +249,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -279,7 +291,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -313,7 +325,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -346,7 +358,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -355,7 +367,6 @@ class VerifactuApiTest extends TestCase
$invoice->service()->markSent()->save();
$this->assertEquals(121, $invoice->amount);
$data = $invoice->toArray();
unset($data['client']);
@ -381,7 +392,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -445,7 +456,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -486,7 +497,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -526,7 +537,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -556,7 +567,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -589,7 +600,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -619,7 +630,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -647,7 +658,7 @@ class VerifactuApiTest extends TestCase
$this->assertEquals(10, $this->client->balance);
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -743,7 +754,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -780,7 +791,7 @@ class VerifactuApiTest extends TestCase
$this->assertEquals($invoice->amount, 121);
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -819,7 +830,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -859,7 +870,7 @@ class VerifactuApiTest extends TestCase
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -910,7 +921,7 @@ class VerifactuApiTest extends TestCase
Config::set('ninja.environment', 'hosted');
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$settings->e_invoice_type = 'VERIFACTU';
$this->company->settings = $settings;
$this->company->save();
@ -935,7 +946,7 @@ class VerifactuApiTest extends TestCase
$arr = $response->json();
$this->assertEquals($arr['data']['settings']['e_invoice_type'], 'verifactu');
$this->assertEquals($arr['data']['settings']['e_invoice_type'], 'VERIFACTU');
$this->assertEquals($arr['data']['settings']['lock_invoices'], 'when_sent');
}
}