From 5895c1b0ed88baae3313ce1e8f192a36b54bb579 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 8 Aug 2025 10:40:38 +1000 Subject: [PATCH] Tests for modified invoices --- app/Models/VerifactuLog.php | 2 +- .../EDocument/Standards/Verifactu.php | 15 +- .../Verifactu/Models/Encadenamiento.php | 6 +- .../Standards/Verifactu/Models/Invoice.php | 64 ++ .../Verifactu/Models/InvoiceModification.php | 221 ++++++ .../Verifactu/Models/RegistroAnulacion.php | 149 ++++ .../Verifactu/Models/RegistroModificacion.php | 675 ++++++++++++++++++ .../Models/InvoiceModificationTest.php | 489 +++++++++++++ .../EInvoice/Verifactu/Models/WSTest.php | 175 ++--- 9 files changed, 1698 insertions(+), 98 deletions(-) create mode 100644 app/Services/EDocument/Standards/Verifactu/Models/InvoiceModification.php create mode 100644 app/Services/EDocument/Standards/Verifactu/Models/RegistroAnulacion.php create mode 100644 app/Services/EDocument/Standards/Verifactu/Models/RegistroModificacion.php create mode 100644 tests/Feature/EInvoice/Verifactu/Models/InvoiceModificationTest.php diff --git a/app/Models/VerifactuLog.php b/app/Models/VerifactuLog.php index ca701437ae..d52974fd7e 100644 --- a/app/Models/VerifactuLog.php +++ b/app/Models/VerifactuLog.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Model; * @property int $company_id * @property int $invoice_id * @property string $nif - * @property Carbon $date + * @property \Carbon\Carbon $date * @property string $invoice_number * @property string $hash * @property string $previous_hash diff --git a/app/Services/EDocument/Standards/Verifactu.php b/app/Services/EDocument/Standards/Verifactu.php index 06c31377e6..df6c6020ed 100644 --- a/app/Services/EDocument/Standards/Verifactu.php +++ b/app/Services/EDocument/Standards/Verifactu.php @@ -20,9 +20,13 @@ 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\Desglose; +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; class Verifactu extends AbstractService { @@ -101,7 +105,7 @@ class Verifactu extends AbstractService ->setIdVersion('1.0') ->setIdFactura($this->invoice->number) //invoice number ->setNombreRazonEmisor($this->company->present()->name()) //company name - ->setTipoFactura($this->calculateInvoiceType()) //invoice type + ->setTipoFactura('F1') //invoice type ->setDescripcionOperacion('')// Not manadatory - max chars 500 ->setCuotaTotal($this->invoice->total_taxes) //total taxes ->setImporteTotal($this->invoice->amount) //total invoice amount @@ -132,7 +136,7 @@ class Verifactu extends AbstractService $desglose = new Desglose(); //Combine the line taxes with invoice taxes here to get a total tax amount - $taxes = $calc->getTaxMap(); + $taxes = $this->calc->getTaxMap(); $desglose_iva = []; @@ -157,6 +161,7 @@ class Verifactu extends AbstractService $encadenamiento = new Encadenamiento(); // Get the previous invoice log + /** @var ?VerifactuLog $v_log */ $v_log = $this->company->verifactu_logs()->first(); // We chain the previous hash to the current invoice to ensure consistency @@ -218,11 +223,5 @@ class Verifactu extends AbstractService return '01'; } - - private function calculateInvoiceType(): string - { - //tipofactua - } - } \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Models/Encadenamiento.php b/app/Services/EDocument/Standards/Verifactu/Models/Encadenamiento.php index 8d5d02f155..4b87b0b436 100644 --- a/app/Services/EDocument/Standards/Verifactu/Models/Encadenamiento.php +++ b/app/Services/EDocument/Standards/Verifactu/Models/Encadenamiento.php @@ -2,6 +2,8 @@ namespace App\Services\EDocument\Standards\Verifactu\Models; +use App\Services\EDocument\Standards\Verifactu\Models\RegistroAnterior; + class Encadenamiento extends BaseXmlModel { protected ?string $primerRegistro = null; @@ -96,12 +98,12 @@ class Encadenamiento extends BaseXmlModel return $this; } - public function getRegistroAnterior(): ?EncadenamientoFacturaAnterior + public function getRegistroAnterior(): ?RegistroAnterior { return $this->registroAnterior; } - public function setRegistroAnterior(?EncadenamientoFacturaAnterior $registroAnterior): self + public function setRegistroAnterior(?RegistroAnterior $registroAnterior): self { $this->registroAnterior = $registroAnterior; return $this; diff --git a/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php b/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php index d4167cd123..760b5b7f7a 100644 --- a/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php +++ b/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php @@ -1138,4 +1138,68 @@ class Invoice extends BaseXmlModel $node = $element->getElementsByTagNameNS(self::XML_NAMESPACE, $tagName)->item(0); return $node ? $node->nodeValue : null; } + + /** + * Create a modification from this invoice + */ + public function createModification(Invoice $modifiedInvoice): InvoiceModification + { + return InvoiceModification::createFromInvoice($this, $modifiedInvoice); + } + + /** + * Create a cancellation record for this invoice + */ + public function createCancellation(): RegistroAnulacion + { + $cancellation = new RegistroAnulacion(); + $cancellation + ->setIdEmisorFactura($this->getTercero()?->getNif() ?? 'B12345678') + ->setNumSerieFactura($this->getIdFactura()) + ->setFechaExpedicionFactura($this->getFechaExpedicionFactura()) + ->setMotivoAnulacion('1'); // Sustitución por otra factura + + return $cancellation; + } + + /** + * Create a modification record from this invoice + */ + public function createModificationRecord(): RegistroModificacion + { + $modificationRecord = new RegistroModificacion(); + $modificationRecord + ->setIdVersion($this->getIdVersion()) + ->setIdFactura($this->getIdFactura()) + ->setRefExterna($this->getRefExterna()) + ->setNombreRazonEmisor($this->getNombreRazonEmisor()) + ->setSubsanacion($this->getSubsanacion()) + ->setRechazoPrevio($this->getRechazoPrevio()) + ->setTipoFactura($this->getTipoFactura()) + ->setTipoRectificativa($this->getTipoRectificativa()) + ->setFacturasRectificadas($this->getFacturasRectificadas()) + ->setFacturasSustituidas($this->getFacturasSustituidas()) + ->setImporteRectificacion($this->getImporteRectificacion()) + ->setFechaOperacion($this->getFechaOperacion()) + ->setDescripcionOperacion($this->getDescripcionOperacion()) + ->setFacturaSimplificadaArt7273($this->getFacturaSimplificadaArt7273()) + ->setFacturaSinIdentifDestinatarioArt61d($this->getFacturaSinIdentifDestinatarioArt61d()) + ->setMacrodato($this->getMacrodato()) + ->setEmitidaPorTerceroODestinatario($this->getEmitidaPorTerceroODestinatario()) + ->setTercero($this->getTercero()) + ->setDestinatarios($this->getDestinatarios()) + ->setCupon($this->getCupon()) + ->setDesglose($this->getDesglose()) + ->setCuotaTotal($this->getCuotaTotal()) + ->setImporteTotal($this->getImporteTotal()) + ->setEncadenamiento($this->getEncadenamiento()) + ->setSistemaInformatico($this->getSistemaInformatico()) + ->setFechaHoraHusoGenRegistro($this->getFechaHoraHusoGenRegistro()) + ->setNumRegistroAcuerdoFacturacion($this->getNumRegistroAcuerdoFacturacion()) + ->setIdAcuerdoSistemaInformatico($this->getIdAcuerdoSistemaInformatico()) + ->setTipoHuella($this->getTipoHuella()) + ->setHuella($this->getHuella()); + + return $modificationRecord; + } } \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Models/InvoiceModification.php b/app/Services/EDocument/Standards/Verifactu/Models/InvoiceModification.php new file mode 100644 index 0000000000..4e5cef3ce4 --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/Models/InvoiceModification.php @@ -0,0 +1,221 @@ + "Sustitución por otra factura", // Replacement by another invoice + '2' => "Error en facturación", // Billing error + '3' => "Anulación por devolución", // Cancellation due to return + '4' => "Anulación por insolvencia" // Cancellation due to insolvency + ]; + + public function __construct() + { + $this->registroAnulacion = new RegistroAnulacion(); + $this->registroModificacion = new RegistroModificacion(); + $this->sistemaInformatico = new SistemaInformatico(); + } + + public function getRegistroAnulacion(): RegistroAnulacion + { + return $this->registroAnulacion; + } + + public function setRegistroAnulacion(RegistroAnulacion $registroAnulacion): self + { + $this->registroAnulacion = $registroAnulacion; + return $this; + } + + public function getRegistroModificacion(): RegistroModificacion + { + return $this->registroModificacion; + } + + public function setRegistroModificacion(RegistroModificacion $registroModificacion): self + { + $this->registroModificacion = $registroModificacion; + return $this; + } + + public function getSistemaInformatico(): SistemaInformatico + { + return $this->sistemaInformatico; + } + + public function setSistemaInformatico(SistemaInformatico $sistemaInformatico): self + { + $this->sistemaInformatico = $sistemaInformatico; + return $this; + } + + /** + * Create a modification from an existing invoice + */ + public static function createFromInvoice(Invoice $originalInvoice, Invoice $modifiedInvoice): self + { + $modification = new self(); + + // Set up cancellation record + $cancellation = new RegistroAnulacion(); + $cancellation + ->setIdEmisorFactura($originalInvoice->getTercero()?->getNif() ?? 'B12345678') + ->setNumSerieFactura($originalInvoice->getIdFactura()) + ->setFechaExpedicionFactura($originalInvoice->getFechaExpedicionFactura()) + ->setMotivoAnulacion('1'); // Sustitución por otra factura + + $modification->setRegistroAnulacion($cancellation); + + // Set up modification record + $modificationRecord = new RegistroModificacion(); + $modificationRecord + ->setIdVersion($modifiedInvoice->getIdVersion()) + ->setIdFactura($modifiedInvoice->getIdFactura()) + ->setRefExterna($modifiedInvoice->getRefExterna()) + ->setNombreRazonEmisor($modifiedInvoice->getNombreRazonEmisor()) + ->setSubsanacion($modifiedInvoice->getSubsanacion()) + ->setRechazoPrevio($modifiedInvoice->getRechazoPrevio()) + ->setTipoFactura($modifiedInvoice->getTipoFactura()) + ->setTipoRectificativa($modifiedInvoice->getTipoRectificativa()) + ->setFacturasRectificadas($modifiedInvoice->getFacturasRectificadas()) + ->setFacturasSustituidas($modifiedInvoice->getFacturasSustituidas()) + ->setImporteRectificacion($modifiedInvoice->getImporteRectificacion()) + ->setFechaOperacion($modifiedInvoice->getFechaOperacion()) + ->setDescripcionOperacion($modifiedInvoice->getDescripcionOperacion()) + ->setFacturaSimplificadaArt7273($modifiedInvoice->getFacturaSimplificadaArt7273()) + ->setFacturaSinIdentifDestinatarioArt61d($modifiedInvoice->getFacturaSinIdentifDestinatarioArt61d()) + ->setMacrodato($modifiedInvoice->getMacrodato()) + ->setEmitidaPorTerceroODestinatario($modifiedInvoice->getEmitidaPorTerceroODestinatario()) + ->setTercero($modifiedInvoice->getTercero()) + ->setDestinatarios($modifiedInvoice->getDestinatarios()) + ->setCupon($modifiedInvoice->getCupon()) + ->setDesglose($modifiedInvoice->getDesglose()) + ->setCuotaTotal($modifiedInvoice->getCuotaTotal()) + ->setImporteTotal($modifiedInvoice->getImporteTotal()) + ->setEncadenamiento($modifiedInvoice->getEncadenamiento()) + ->setSistemaInformatico($modifiedInvoice->getSistemaInformatico()) + ->setFechaHoraHusoGenRegistro($modifiedInvoice->getFechaHoraHusoGenRegistro()) + ->setNumRegistroAcuerdoFacturacion($modifiedInvoice->getNumRegistroAcuerdoFacturacion()) + ->setIdAcuerdoSistemaInformatico($modifiedInvoice->getIdAcuerdoSistemaInformatico()) + ->setTipoHuella($modifiedInvoice->getTipoHuella()) + ->setHuella($modifiedInvoice->getHuella()); + + $modification->setRegistroModificacion($modificationRecord); + + // Set up sistema informatico for the modification + $modification->setSistemaInformatico($modifiedInvoice->getSistemaInformatico()); + + return $modification; + } + + public function toSoapEnvelope(): string + { + // Create the SOAP document + $soapDoc = new \DOMDocument('1.0', 'UTF-8'); + $soapDoc->preserveWhiteSpace = false; + $soapDoc->formatOutput = true; + + // Create SOAP envelope with namespaces + $envelope = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Envelope'); + $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:soapenv', 'http://schemas.xmlsoap.org/soap/envelope/'); + $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd'); + $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:sum1', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd'); + + $soapDoc->appendChild($envelope); + + // Create Header + $header = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Header'); + $envelope->appendChild($header); + + // Create Body + $body = $soapDoc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'soapenv:Body'); + $envelope->appendChild($body); + + // Create ModificacionFactura + $modificacionFactura = $soapDoc->createElementNS('https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd', 'sum:ModificacionFactura'); + $body->appendChild($modificacionFactura); + + // Add RegistroAnulacion + $registroAnulacionElement = $this->registroAnulacion->toXml($soapDoc); + $importedRegistroAnulacion = $soapDoc->importNode($registroAnulacionElement, true); + $modificacionFactura->appendChild($importedRegistroAnulacion); + + // Add RegistroModificacion + $registroModificacionElement = $this->registroModificacion->toXml($soapDoc); + $importedRegistroModificacion = $soapDoc->importNode($registroModificacionElement, true); + $modificacionFactura->appendChild($importedRegistroModificacion); + + return $soapDoc->saveXML(); + } + + public function toXmlString(): string + { + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + + // Create ModificacionFactura root + $root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':ModificacionFactura'); + $doc->appendChild($root); + + // Add RegistroAnulacion + $registroAnulacionElement = $this->registroAnulacion->toXml($doc); + $root->appendChild($registroAnulacionElement); + + // Add RegistroModificacion + $registroModificacionElement = $this->registroModificacion->toXml($doc); + $root->appendChild($registroModificacionElement); + + return $doc->saveXML(); + } + + public function toXml(\DOMDocument $doc): \DOMElement + { + // Create ModificacionFactura root + $root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':ModificacionFactura'); + + // Add RegistroAnulacion + $registroAnulacionElement = $this->registroAnulacion->toXml($doc); + $root->appendChild($registroAnulacionElement); + + // Add RegistroModificacion + $registroModificacionElement = $this->registroModificacion->toXml($doc); + $root->appendChild($registroModificacionElement); + + return $root; + } + + public static function fromDOMElement(\DOMElement $element): self + { + $modification = new self(); + + // Handle RegistroAnulacion + $registroAnulacionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RegistroAnulacion')->item(0); + if ($registroAnulacionElement) { + $registroAnulacion = RegistroAnulacion::fromDOMElement($registroAnulacionElement); + $modification->setRegistroAnulacion($registroAnulacion); + } + + // Handle RegistroModificacion + $registroModificacionElement = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'RegistroModificacion')->item(0); + if ($registroModificacionElement) { + $registroModificacion = RegistroModificacion::fromDOMElement($registroModificacionElement); + $modification->setRegistroModificacion($registroModificacion); + } + + return $modification; + } +} \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Models/RegistroAnulacion.php b/app/Services/EDocument/Standards/Verifactu/Models/RegistroAnulacion.php new file mode 100644 index 0000000000..d6c6d89715 --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/Models/RegistroAnulacion.php @@ -0,0 +1,149 @@ +idVersion = '1.0'; + $this->motivoAnulacion = '1'; // Default: Sustitución por otra factura + } + + public function getIdVersion(): string + { + return $this->idVersion; + } + + public function setIdVersion(string $idVersion): self + { + $this->idVersion = $idVersion; + return $this; + } + + public function getIdEmisorFactura(): string + { + return $this->idEmisorFactura; + } + + public function setIdEmisorFactura(string $idEmisorFactura): self + { + $this->idEmisorFactura = $idEmisorFactura; + return $this; + } + + public function getNumSerieFactura(): string + { + return $this->numSerieFactura; + } + + public function setNumSerieFactura(string $numSerieFactura): self + { + $this->numSerieFactura = $numSerieFactura; + return $this; + } + + public function getFechaExpedicionFactura(): string + { + return $this->fechaExpedicionFactura; + } + + public function setFechaExpedicionFactura(string $fechaExpedicionFactura): self + { + $this->fechaExpedicionFactura = $fechaExpedicionFactura; + return $this; + } + + public function getMotivoAnulacion(): string + { + return $this->motivoAnulacion; + } + + public function setMotivoAnulacion(string $motivoAnulacion): self + { + $this->motivoAnulacion = $motivoAnulacion; + return $this; + } + + public function toXml(\DOMDocument $doc): \DOMElement + { + $root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':RegistroAnulacion'); + + // Add IDVersion + $root->appendChild($this->createElement($doc, 'IDVersion', $this->idVersion)); + + // Create IDFactura structure + $idFactura = $this->createElement($doc, 'IDFactura'); + $idFactura->appendChild($this->createElement($doc, 'IDEmisorFactura', $this->idEmisorFactura)); + $idFactura->appendChild($this->createElement($doc, 'NumSerieFactura', $this->numSerieFactura)); + $idFactura->appendChild($this->createElement($doc, 'FechaExpedicionFactura', $this->fechaExpedicionFactura)); + $root->appendChild($idFactura); + + // Add MotivoAnulacion + $root->appendChild($this->createElement($doc, 'MotivoAnulacion', $this->motivoAnulacion)); + + return $root; + } + + public static function fromDOMElement(\DOMElement $element): self + { + $registroAnulacion = new self(); + + // Handle IDVersion + $idVersion = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDVersion')->item(0); + if ($idVersion) { + $registroAnulacion->setIdVersion($idVersion->nodeValue); + } + + // Handle IDFactura + $idFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDFactura')->item(0); + if ($idFactura) { + $idEmisorFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDEmisorFactura')->item(0); + if ($idEmisorFactura) { + $registroAnulacion->setIdEmisorFactura($idEmisorFactura->nodeValue); + } + + $numSerieFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumSerieFactura')->item(0); + if ($numSerieFactura) { + $registroAnulacion->setNumSerieFactura($numSerieFactura->nodeValue); + } + + $fechaExpedicionFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaExpedicionFactura')->item(0); + if ($fechaExpedicionFactura) { + $registroAnulacion->setFechaExpedicionFactura($fechaExpedicionFactura->nodeValue); + } + } + + // Handle MotivoAnulacion + $motivoAnulacion = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'MotivoAnulacion')->item(0); + if ($motivoAnulacion) { + $registroAnulacion->setMotivoAnulacion($motivoAnulacion->nodeValue); + } + + return $registroAnulacion; + } + + public function toXmlString(): string + { + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + + $root = $this->toXml($doc); + $doc->appendChild($root); + + return $doc->saveXML(); + } +} \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Models/RegistroModificacion.php b/app/Services/EDocument/Standards/Verifactu/Models/RegistroModificacion.php new file mode 100644 index 0000000000..0b894a4828 --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/Models/RegistroModificacion.php @@ -0,0 +1,675 @@ +desglose = new Desglose(); + $this->encadenamiento = new Encadenamiento(); + $this->sistemaInformatico = new SistemaInformatico(); + $this->tipoFactura = 'F1'; // Default to normal invoice + } + + // Getters and setters - same as Invoice model + public function getIdVersion(): string + { + return $this->idVersion; + } + + public function setIdVersion(string $idVersion): self + { + $this->idVersion = $idVersion; + return $this; + } + + public function getFechaExpedicionFactura(): string + { + return $this->fechaExpedicionFactura ?? now()->format('d-m-Y'); + } + + public function setFechaExpedicionFactura(string $fechaExpedicionFactura): self + { + $this->fechaExpedicionFactura = $fechaExpedicionFactura; + return $this; + } + + public function getIdFactura(): string + { + return $this->idFactura; + } + + public function setIdFactura(string $idFactura): self + { + $this->idFactura = $idFactura; + return $this; + } + + public function getRefExterna(): ?string + { + return $this->refExterna; + } + + public function setRefExterna(?string $refExterna): self + { + $this->refExterna = $refExterna; + return $this; + } + + public function getNombreRazonEmisor(): string + { + return $this->nombreRazonEmisor; + } + + public function setNombreRazonEmisor(string $nombreRazonEmisor): self + { + $this->nombreRazonEmisor = $nombreRazonEmisor; + return $this; + } + + public function getSubsanacion(): ?string + { + return $this->subsanacion; + } + + public function setSubsanacion(?string $subsanacion): self + { + $this->subsanacion = $subsanacion; + return $this; + } + + public function getRechazoPrevio(): ?string + { + return $this->rechazoPrevio; + } + + public function setRechazoPrevio(?string $rechazoPrevio): self + { + $this->rechazoPrevio = $rechazoPrevio; + return $this; + } + + public function getTipoFactura(): string + { + return $this->tipoFactura; + } + + public function setTipoFactura(string $tipoFactura): self + { + $this->tipoFactura = $tipoFactura; + return $this; + } + + public function getTipoRectificativa(): ?string + { + return $this->tipoRectificativa; + } + + public function setTipoRectificativa(?string $tipoRectificativa): self + { + $this->tipoRectificativa = $tipoRectificativa; + return $this; + } + + public function getFacturasRectificadas(): ?array + { + return $this->facturasRectificadas; + } + + public function setFacturasRectificadas(?array $facturasRectificadas): self + { + $this->facturasRectificadas = $facturasRectificadas; + return $this; + } + + public function getFacturasSustituidas(): ?array + { + return $this->facturasSustituidas; + } + + public function setFacturasSustituidas(?array $facturasSustituidas): self + { + $this->facturasSustituidas = $facturasSustituidas; + return $this; + } + + public function getImporteRectificacion(): ?float + { + return $this->importeRectificacion; + } + + public function setImporteRectificacion(?float $importeRectificacion): self + { + $this->importeRectificacion = $importeRectificacion; + return $this; + } + + public function getFechaOperacion(): ?string + { + return $this->fechaOperacion; + } + + public function setFechaOperacion(?string $fechaOperacion): self + { + $this->fechaOperacion = $fechaOperacion; + return $this; + } + + public function getDescripcionOperacion(): string + { + return $this->descripcionOperacion; + } + + public function setDescripcionOperacion(string $descripcionOperacion): self + { + $this->descripcionOperacion = $descripcionOperacion; + return $this; + } + + public function getFacturaSimplificadaArt7273(): ?string + { + return $this->facturaSimplificadaArt7273; + } + + public function setFacturaSimplificadaArt7273(?string $facturaSimplificadaArt7273): self + { + $this->facturaSimplificadaArt7273 = $facturaSimplificadaArt7273; + return $this; + } + + public function getFacturaSinIdentifDestinatarioArt61d(): ?string + { + return $this->facturaSinIdentifDestinatarioArt61d; + } + + public function setFacturaSinIdentifDestinatarioArt61d(?string $facturaSinIdentifDestinatarioArt61d): self + { + $this->facturaSinIdentifDestinatarioArt61d = $facturaSinIdentifDestinatarioArt61d; + return $this; + } + + public function getMacrodato(): ?string + { + return $this->macrodato; + } + + public function setMacrodato(?string $macrodato): self + { + $this->macrodato = $macrodato; + return $this; + } + + public function getEmitidaPorTerceroODestinatario(): ?string + { + return $this->emitidaPorTerceroODestinatario; + } + + public function setEmitidaPorTerceroODestinatario(?string $emitidaPorTerceroODestinatario): self + { + $this->emitidaPorTerceroODestinatario = $emitidaPorTerceroODestinatario; + return $this; + } + + public function getTercero(): ?PersonaFisicaJuridica + { + return $this->tercero; + } + + public function setTercero(?PersonaFisicaJuridica $tercero): self + { + $this->tercero = $tercero; + return $this; + } + + public function getDestinatarios(): ?array + { + return $this->destinatarios; + } + + public function setDestinatarios(?array $destinatarios): self + { + $this->destinatarios = $destinatarios; + return $this; + } + + public function getCupon(): ?string + { + return $this->cupon; + } + + public function setCupon(?string $cupon): self + { + $this->cupon = $cupon; + return $this; + } + + public function getDesglose(): Desglose + { + return $this->desglose; + } + + public function setDesglose(Desglose $desglose): self + { + $this->desglose = $desglose; + return $this; + } + + public function getCuotaTotal(): float + { + return $this->cuotaTotal; + } + + public function setCuotaTotal(float $cuotaTotal): self + { + $this->cuotaTotal = $cuotaTotal; + return $this; + } + + public function getImporteTotal(): float + { + return $this->importeTotal; + } + + public function setImporteTotal($importeTotal): self + { + if (!is_numeric($importeTotal)) { + throw new \InvalidArgumentException('ImporteTotal must be a numeric value'); + } + + $formatted = number_format((float)$importeTotal, 2, '.', ''); + if (!preg_match('/^(\+|-)?\d{1,12}(\.\d{0,2})?$/', $formatted)) { + throw new \InvalidArgumentException('ImporteTotal must be a number with up to 12 digits and 2 decimal places'); + } + + $this->importeTotal = (float)$importeTotal; + return $this; + } + + public function getEncadenamiento(): Encadenamiento + { + return $this->encadenamiento; + } + + public function setEncadenamiento(Encadenamiento $encadenamiento): self + { + $this->encadenamiento = $encadenamiento; + return $this; + } + + public function getSistemaInformatico(): SistemaInformatico + { + return $this->sistemaInformatico; + } + + public function setSistemaInformatico(SistemaInformatico $sistemaInformatico): self + { + $this->sistemaInformatico = $sistemaInformatico; + return $this; + } + + public function getFechaHoraHusoGenRegistro(): string + { + return $this->fechaHoraHusoGenRegistro; + } + + public function setFechaHoraHusoGenRegistro(string $fechaHoraHusoGenRegistro): self + { + if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $fechaHoraHusoGenRegistro)) { + throw new \InvalidArgumentException('Invalid date format for FechaHoraHusoGenRegistro. Expected format: YYYY-MM-DDThh:mm:ss'); + } + $this->fechaHoraHusoGenRegistro = $fechaHoraHusoGenRegistro; + return $this; + } + + public function getNumRegistroAcuerdoFacturacion(): ?string + { + return $this->numRegistroAcuerdoFacturacion; + } + + public function setNumRegistroAcuerdoFacturacion(?string $numRegistroAcuerdoFacturacion): self + { + $this->numRegistroAcuerdoFacturacion = $numRegistroAcuerdoFacturacion; + return $this; + } + + public function getIdAcuerdoSistemaInformatico(): ?string + { + return $this->idAcuerdoSistemaInformatico; + } + + public function setIdAcuerdoSistemaInformatico(?string $idAcuerdoSistemaInformatico): self + { + $this->idAcuerdoSistemaInformatico = $idAcuerdoSistemaInformatico; + return $this; + } + + public function getTipoHuella(): string + { + return $this->tipoHuella; + } + + public function setTipoHuella(string $tipoHuella): self + { + $this->tipoHuella = $tipoHuella; + return $this; + } + + public function getHuella(): string + { + return $this->huella; + } + + public function setHuella(string $huella): self + { + $this->huella = $huella; + return $this; + } + + public function getSignature(): ?string + { + return $this->signature; + } + + public function setSignature(?string $signature): self + { + $this->signature = $signature; + return $this; + } + + public function getFacturaRectificativa(): ?FacturaRectificativa + { + return $this->facturaRectificativa; + } + + public function setFacturaRectificativa(FacturaRectificativa $facturaRectificativa): void + { + $this->facturaRectificativa = $facturaRectificativa; + } + + public function setPrivateKeyPath(string $path): self + { + $this->privateKeyPath = $path; + return $this; + } + + public function setPublicKeyPath(string $path): self + { + $this->publicKeyPath = $path; + return $this; + } + + public function setCertificatePath(string $path): self + { + $this->certificatePath = $path; + return $this; + } + + public function toXml(\DOMDocument $doc): \DOMElement + { + // Create root element with proper namespaces + $root = $doc->createElementNS(self::XML_NAMESPACE, self::XML_NAMESPACE_PREFIX . ':RegistroModificacion'); + + // Add namespaces + $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:' . self::XML_NAMESPACE_PREFIX, self::XML_NAMESPACE); + $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:' . self::XML_DS_NAMESPACE_PREFIX, self::XML_DS_NAMESPACE); + + // Add required elements in exact order according to schema + $root->appendChild($this->createElement($doc, 'IDVersion', $this->idVersion)); + + // Create IDFactura structure + $idFactura = $this->createElement($doc, 'IDFactura'); + $idFactura->appendChild($this->createElement($doc, 'IDEmisorFactura', $this->tercero?->getNif() ?? 'B12345678')); + $idFactura->appendChild($this->createElement($doc, 'NumSerieFactura', $this->idFactura)); + $idFactura->appendChild($this->createElement($doc, 'FechaExpedicionFactura', $this->getFechaExpedicionFactura())); + $root->appendChild($idFactura); + + if ($this->refExterna !== null) { + $root->appendChild($this->createElement($doc, 'RefExterna', $this->refExterna)); + } + + $root->appendChild($this->createElement($doc, 'NombreRazonEmisor', $this->nombreRazonEmisor)); + + if ($this->subsanacion !== null) { + $root->appendChild($this->createElement($doc, 'Subsanacion', $this->subsanacion)); + } + + if ($this->rechazoPrevio !== null) { + $root->appendChild($this->createElement($doc, 'RechazoPrevio', $this->rechazoPrevio)); + } + + $root->appendChild($this->createElement($doc, 'TipoFactura', $this->tipoFactura)); + + if ($this->tipoFactura === 'R1' && $this->facturaRectificativa !== null) { + $root->appendChild($this->createElement($doc, 'TipoRectificativa', $this->facturaRectificativa->getTipoRectificativa())); + $facturasRectificadas = $this->createElement($doc, 'FacturasRectificadas'); + $facturasRectificadas->appendChild($this->facturaRectificativa->toXml($doc)); + $root->appendChild($facturasRectificadas); + if ($this->importeRectificacion !== null) { + $root->appendChild($this->createElement($doc, 'ImporteRectificacion', (string)$this->importeRectificacion)); + } + } + + if ($this->fechaOperacion) { + $root->appendChild($this->createElement($doc, 'FechaOperacion', date('d-m-Y', strtotime($this->fechaOperacion)))); + } + + $root->appendChild($this->createElement($doc, 'DescripcionOperacion', $this->descripcionOperacion)); + + if ($this->cupon !== null) { + $root->appendChild($this->createElement($doc, 'Cupon', $this->cupon)); + } + + if ($this->facturaSimplificadaArt7273 !== null) { + $root->appendChild($this->createElement($doc, 'FacturaSimplificadaArt7273', $this->facturaSimplificadaArt7273)); + } + + if ($this->facturaSinIdentifDestinatarioArt61d !== null) { + $root->appendChild($this->createElement($doc, 'FacturaSinIdentifDestinatarioArt61d', $this->facturaSinIdentifDestinatarioArt61d)); + } + + if ($this->macrodato !== null) { + $root->appendChild($this->createElement($doc, 'Macrodato', $this->macrodato)); + } + + if ($this->emitidaPorTerceroODestinatario !== null) { + $root->appendChild($this->createElement($doc, 'EmitidaPorTerceroODestinatario', $this->emitidaPorTerceroODestinatario)); + } + + if ($this->tercero !== null) { + $root->appendChild($this->tercero->toXml($doc)); + } + + if ($this->destinatarios !== null && count($this->destinatarios) > 0) { + $destinatariosElement = $this->createElement($doc, 'Destinatarios'); + foreach ($this->destinatarios as $destinatario) { + $idDestinatarioElement = $this->createElement($doc, 'IDDestinatario'); + + // Add NombreRazon + $idDestinatarioElement->appendChild($this->createElement($doc, 'NombreRazon', $destinatario->getNombreRazon())); + + // Add either NIF or IDOtro + if ($destinatario->getNif() !== null) { + $idDestinatarioElement->appendChild($this->createElement($doc, 'NIF', $destinatario->getNif())); + } else { + $idOtroElement = $this->createElement($doc, 'IDOtro'); + $idOtroElement->appendChild($this->createElement($doc, 'CodigoPais', $destinatario->getPais())); + $idOtroElement->appendChild($this->createElement($doc, 'IDType', $destinatario->getTipoIdentificacion())); + $idOtroElement->appendChild($this->createElement($doc, 'ID', $destinatario->getIdOtro())); + $idDestinatarioElement->appendChild($idOtroElement); + } + + $destinatariosElement->appendChild($idDestinatarioElement); + } + $root->appendChild($destinatariosElement); + } + + // Add Desglose + if ($this->desglose !== null) { + $root->appendChild($this->desglose->toXml($doc)); + } + + // Add CuotaTotal and ImporteTotal + $root->appendChild($this->createElement($doc, 'CuotaTotal', (string)$this->cuotaTotal)); + $root->appendChild($this->createElement($doc, 'ImporteTotal', (string)$this->importeTotal)); + + // Add Encadenamiento + if ($this->encadenamiento !== null) { + $root->appendChild($this->encadenamiento->toXml($doc)); + } + + // Add SistemaInformatico + if ($this->sistemaInformatico !== null) { + $root->appendChild($this->sistemaInformatico->toXml($doc)); + } + + // Add FechaHoraHusoGenRegistro + $root->appendChild($this->createElement($doc, 'FechaHoraHusoGenRegistro', $this->fechaHoraHusoGenRegistro)); + + // Add NumRegistroAcuerdoFacturacion + if ($this->numRegistroAcuerdoFacturacion !== null) { + $root->appendChild($this->createElement($doc, 'NumRegistroAcuerdoFacturacion', $this->numRegistroAcuerdoFacturacion)); + } + + // Add IdAcuerdoSistemaInformatico + if ($this->idAcuerdoSistemaInformatico !== null) { + $root->appendChild($this->createElement($doc, 'IdAcuerdoSistemaInformatico', $this->idAcuerdoSistemaInformatico)); + } + + // Add TipoHuella and Huella + $root->appendChild($this->createElement($doc, 'TipoHuella', $this->tipoHuella)); + $root->appendChild($this->createElement($doc, 'Huella', $this->huella)); + + return $root; + } + + public function toXmlString(): string + { + // Validate required fields first, outside of try-catch + $requiredFields = [ + 'idVersion' => 'IDVersion', + 'idFactura' => 'NumSerieFactura', + 'nombreRazonEmisor' => 'NombreRazonEmisor', + 'tipoFactura' => 'TipoFactura', + 'descripcionOperacion' => 'DescripcionOperacion', + 'cuotaTotal' => 'CuotaTotal', + 'importeTotal' => 'ImporteTotal', + 'fechaHoraHusoGenRegistro' => 'FechaHoraHusoGenRegistro', + 'tipoHuella' => 'TipoHuella', + 'huella' => 'Huella' + ]; + + foreach ($requiredFields as $property => $fieldName) { + if (!isset($this->$property)) { + throw new \InvalidArgumentException("Missing required field: $fieldName"); + } + } + + // Enable user error handling for XML operations + $previousErrorSetting = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + + // Create root element using toXml method + $root = $this->toXml($doc); + $doc->appendChild($root); + + $xml = $doc->saveXML(); + if ($xml === false) { + throw new \DOMException('Failed to generate XML'); + } + + return $xml; + } catch (\ErrorException $e) { + // Convert any libxml errors to DOMException + $errors = libxml_get_errors(); + libxml_clear_errors(); + if (!empty($errors)) { + throw new \DOMException($errors[0]->message); + } + throw new \DOMException($e->getMessage()); + } finally { + // Restore previous error handling setting + libxml_use_internal_errors($previousErrorSetting); + libxml_clear_errors(); + } + } + + public static function fromDOMElement(\DOMElement $element): self + { + $registroModificacion = new self(); + + // Handle IDVersion + $idVersion = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDVersion')->item(0); + if ($idVersion) { + $registroModificacion->setIdVersion($idVersion->nodeValue); + } + + // Handle IDFactura + $idFactura = $element->getElementsByTagNameNS(self::XML_NAMESPACE, 'IDFactura')->item(0); + if ($idFactura) { + $numSerieFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'NumSerieFactura')->item(0); + if ($numSerieFactura) { + $registroModificacion->setIdFactura($numSerieFactura->nodeValue); + } + + $fechaExpedicionFactura = $idFactura->getElementsByTagNameNS(self::XML_NAMESPACE, 'FechaExpedicionFactura')->item(0); + if ($fechaExpedicionFactura) { + $registroModificacion->setFechaExpedicionFactura($fechaExpedicionFactura->nodeValue); + } + } + + // Handle other fields similar to Invoice model + // ... (implement other field parsing as needed) + + return $registroModificacion; + } +} \ No newline at end of file diff --git a/tests/Feature/EInvoice/Verifactu/Models/InvoiceModificationTest.php b/tests/Feature/EInvoice/Verifactu/Models/InvoiceModificationTest.php new file mode 100644 index 0000000000..7ece9a0f3f --- /dev/null +++ b/tests/Feature/EInvoice/Verifactu/Models/InvoiceModificationTest.php @@ -0,0 +1,489 @@ +setIdEmisorFactura('99999910G') + ->setNumSerieFactura('TEST0033343436') + ->setFechaExpedicionFactura('02-07-2025') + ->setMotivoAnulacion('1'); + + $this->assertEquals('99999910G', $cancellation->getIdEmisorFactura()); + $this->assertEquals('TEST0033343436', $cancellation->getNumSerieFactura()); + $this->assertEquals('02-07-2025', $cancellation->getFechaExpedicionFactura()); + $this->assertEquals('1', $cancellation->getMotivoAnulacion()); + + $xml = $cancellation->toXmlString(); + $this->assertStringContainsString('RegistroAnulacion', $xml); + $this->assertStringContainsString('99999910G', $xml); + $this->assertStringContainsString('TEST0033343436', $xml); + $this->assertStringContainsString('02-07-2025', $xml); + $this->assertStringContainsString('1', $xml); + } + + public function test_can_create_registro_modificacion() + { + $modification = new RegistroModificacion(); + $modification + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('CERTIFICADO FISICA PRUEBAS') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Test invoice modification') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-02T12:00:00') + ->setTipoHuella('01') + ->setHuella('TEST_HASH'); + + // Add sistema informatico + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $modification->setSistemaInformatico($sistema); + + // Add desglose + $desglose = new Desglose(); + $desglose->setDesgloseFactura([ + 'Impuesto' => '01', + 'ClaveRegimen' => '01', + 'CalificacionOperacion' => 'S1', + 'TipoImpositivo' => '21', + 'BaseImponibleOimporteNoSujeto' => '100.00', + 'CuotaRepercutida' => '21.00' + ]); + $modification->setDesglose($desglose); + + // Add encadenamiento + $encadenamiento = new Encadenamiento(); + $encadenamiento->setPrimerRegistro('S'); + $modification->setEncadenamiento($encadenamiento); + + $this->assertEquals('1.0', $modification->getIdVersion()); + $this->assertEquals('TEST0033343436', $modification->getIdFactura()); + $this->assertEquals('CERTIFICADO FISICA PRUEBAS', $modification->getNombreRazonEmisor()); + $this->assertEquals('F1', $modification->getTipoFactura()); + $this->assertEquals(21.00, $modification->getCuotaTotal()); + $this->assertEquals(121.00, $modification->getImporteTotal()); + + $xml = $modification->toXmlString(); + + $this->assertStringContainsString('RegistroModificacion', $xml); + $this->assertStringContainsString('TEST0033343436', $xml); + $this->assertStringContainsString('CERTIFICADO FISICA PRUEBAS', $xml); + $this->assertStringContainsString('21', $xml); + $this->assertStringContainsString('121', $xml); + } + + public function test_can_create_invoice_modification_from_invoices() + { + // Create original invoice + $originalInvoice = new Invoice(); + $originalInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Original Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Original invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('ORIGINAL_HASH'); + + // Add emitter to original invoice + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Original Company'); + $originalInvoice->setTercero($emisor); + + // Add sistema informatico to original invoice + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $originalInvoice->setSistemaInformatico($sistema); + + // Create modified invoice + $modifiedInvoice = new Invoice(); + $modifiedInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Modified Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Modified invoice') + ->setCuotaTotal(42.00) + ->setImporteTotal(242.00) + ->setFechaHoraHusoGenRegistro('2025-01-02T12:00:00') + ->setTipoHuella('01') + ->setHuella('MODIFIED_HASH'); + + // Add emitter to modified invoice + $emisorModificado = new PersonaFisicaJuridica(); + $emisorModificado + ->setNif('99999910G') + ->setRazonSocial('Modified Company'); + $modifiedInvoice->setTercero($emisorModificado); + + // Add sistema informatico to modified invoice + $modifiedInvoice->setSistemaInformatico($sistema); + + // Create modification + $modification = InvoiceModification::createFromInvoice($originalInvoice, $modifiedInvoice); + + $this->assertInstanceOf(InvoiceModification::class, $modification); + $this->assertInstanceOf(RegistroAnulacion::class, $modification->getRegistroAnulacion()); + $this->assertInstanceOf(RegistroModificacion::class, $modification->getRegistroModificacion()); + + // Test cancellation record + $cancellation = $modification->getRegistroAnulacion(); + $this->assertEquals('99999910G', $cancellation->getIdEmisorFactura()); + $this->assertEquals('TEST0033343436', $cancellation->getNumSerieFactura()); + $this->assertEquals('1', $cancellation->getMotivoAnulacion()); + + // Test modification record + $modificationRecord = $modification->getRegistroModificacion(); + $this->assertEquals('Modified Company', $modificationRecord->getNombreRazonEmisor()); + $this->assertEquals(42.00, $modificationRecord->getCuotaTotal()); + $this->assertEquals(242.00, $modificationRecord->getImporteTotal()); + } + + public function test_can_generate_modification_soap_envelope() + { + // Create original invoice + $originalInvoice = new Invoice(); + $originalInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Original Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Original invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('ORIGINAL_HASH'); + + // Add emitter to original invoice + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Original Company'); + $originalInvoice->setTercero($emisor); + + // Add sistema informatico to original invoice + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $originalInvoice->setSistemaInformatico($sistema); + + // Create modified invoice + $modifiedInvoice = new Invoice(); + $modifiedInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Modified Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Modified invoice') + ->setCuotaTotal(42.00) + ->setImporteTotal(242.00) + ->setFechaHoraHusoGenRegistro('2025-01-02T12:00:00') + ->setTipoHuella('01') + ->setHuella('MODIFIED_HASH'); + + // Add emitter to modified invoice + $emisorModificado = new PersonaFisicaJuridica(); + $emisorModificado + ->setNif('99999910G') + ->setRazonSocial('Modified Company'); + $modifiedInvoice->setTercero($emisorModificado); + + // Add sistema informatico to modified invoice + $modifiedInvoice->setSistemaInformatico($sistema); + + // Create modification + $modification = InvoiceModification::createFromInvoice($originalInvoice, $modifiedInvoice); + + // Generate SOAP envelope + $soapXml = $modification->toSoapEnvelope(); + + $this->assertStringContainsString('soapenv:Envelope', $soapXml); + $this->assertStringContainsString('sum:ModificacionFactura', $soapXml); + $this->assertStringContainsString('sf:RegistroAnulacion', $soapXml); + $this->assertStringContainsString('sf:RegistroModificacion', $soapXml); + $this->assertStringContainsString('99999910G', $soapXml); + $this->assertStringContainsString('TEST0033343436', $soapXml); + $this->assertStringContainsString('Modified Company', $soapXml); + $this->assertStringContainsString('42', $soapXml); + $this->assertStringContainsString('242', $soapXml); + } + + public function test_invoice_can_create_modification() + { + // Create original invoice + $originalInvoice = new Invoice(); + $originalInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Original Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Original invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('ORIGINAL_HASH'); + + // Add emitter to original invoice + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Original Company'); + $originalInvoice->setTercero($emisor); + + // Add sistema informatico to original invoice + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $originalInvoice->setSistemaInformatico($sistema); + + // Create modified invoice + $modifiedInvoice = new Invoice(); + $modifiedInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Modified Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Modified invoice') + ->setCuotaTotal(42.00) + ->setImporteTotal(242.00) + ->setFechaHoraHusoGenRegistro('2025-01-02T12:00:00') + ->setTipoHuella('01') + ->setHuella('MODIFIED_HASH'); + + // Add emitter to modified invoice + $emisorModificado = new PersonaFisicaJuridica(); + $emisorModificado + ->setNif('99999910G') + ->setRazonSocial('Modified Company'); + $modifiedInvoice->setTercero($emisorModificado); + + // Add sistema informatico to modified invoice + $modifiedInvoice->setSistemaInformatico($sistema); + + // Create modification using the invoice method + $modification = $originalInvoice->createModification($modifiedInvoice); + + $this->assertInstanceOf(InvoiceModification::class, $modification); + + // Test cancellation record + $cancellation = $modification->getRegistroAnulacion(); + $this->assertEquals('99999910G', $cancellation->getIdEmisorFactura()); + $this->assertEquals('TEST0033343436', $cancellation->getNumSerieFactura()); + $this->assertEquals('1', $cancellation->getMotivoAnulacion()); + + // Test modification record + $modificationRecord = $modification->getRegistroModificacion(); + $this->assertEquals('Modified Company', $modificationRecord->getNombreRazonEmisor()); + $this->assertEquals(42.00, $modificationRecord->getCuotaTotal()); + $this->assertEquals(242.00, $modificationRecord->getImporteTotal()); + } + + public function test_invoice_can_create_cancellation() + { + $invoice = new Invoice(); + $invoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Test Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Test invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('TEST_HASH'); + + // Add emitter + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Test Company'); + $invoice->setTercero($emisor); + + $cancellation = $invoice->createCancellation(); + + $this->assertInstanceOf(RegistroAnulacion::class, $cancellation); + $this->assertEquals('99999910G', $cancellation->getIdEmisorFactura()); + $this->assertEquals('TEST0033343436', $cancellation->getNumSerieFactura()); + $this->assertEquals('1', $cancellation->getMotivoAnulacion()); + } + + public function test_invoice_can_create_modification_record() + { + $invoice = new Invoice(); + $invoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Test Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Test invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('TEST_HASH'); + + // Add emitter + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Test Company'); + $invoice->setTercero($emisor); + + // Add sistema informatico + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $invoice->setSistemaInformatico($sistema); + + $modificationRecord = $invoice->createModificationRecord(); + + $this->assertInstanceOf(RegistroModificacion::class, $modificationRecord); + $this->assertEquals('1.0', $modificationRecord->getIdVersion()); + $this->assertEquals('TEST0033343436', $modificationRecord->getIdFactura()); + $this->assertEquals('Test Company', $modificationRecord->getNombreRazonEmisor()); + $this->assertEquals('F1', $modificationRecord->getTipoFactura()); + $this->assertEquals(21.00, $modificationRecord->getCuotaTotal()); + $this->assertEquals(121.00, $modificationRecord->getImporteTotal()); + } + + public function test_modification_xml_structure_matches_aeat_requirements() + { + // Create original invoice + $originalInvoice = new Invoice(); + $originalInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Original Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Original invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro('2025-01-01T12:00:00') + ->setTipoHuella('01') + ->setHuella('ORIGINAL_HASH'); + + // Add emitter to original invoice + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif('99999910G') + ->setRazonSocial('Original Company'); + $originalInvoice->setTercero($emisor); + + // Add sistema informatico to original invoice + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $originalInvoice->setSistemaInformatico($sistema); + + // Create modified invoice + $modifiedInvoice = new Invoice(); + $modifiedInvoice + ->setIdVersion('1.0') + ->setIdFactura('TEST0033343436') + ->setNombreRazonEmisor('Modified Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Modified invoice') + ->setCuotaTotal(42.00) + ->setImporteTotal(242.00) + ->setFechaHoraHusoGenRegistro('2025-01-02T12:00:00') + ->setTipoHuella('01') + ->setHuella('MODIFIED_HASH'); + + // Add emitter to modified invoice + $emisorModificado = new PersonaFisicaJuridica(); + $emisorModificado + ->setNif('99999910G') + ->setRazonSocial('Modified Company'); + $modifiedInvoice->setTercero($emisorModificado); + + // Add sistema informatico to modified invoice + $modifiedInvoice->setSistemaInformatico($sistema); + + // Create modification + $modification = InvoiceModification::createFromInvoice($originalInvoice, $modifiedInvoice); + + // Generate SOAP envelope + $soapXml = $modification->toSoapEnvelope(); + + // Verify the XML structure matches AEAT requirements + $this->assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('99999910G', $soapXml); + $this->assertStringContainsString('TEST0033343436', $soapXml); + $this->assertStringContainsString('1', $soapXml); + + // Verify modification structure + $this->assertStringContainsString('Modified Company', $soapXml); + $this->assertStringContainsString('42', $soapXml); + $this->assertStringContainsString('242', $soapXml); + } +} \ No newline at end of file diff --git a/tests/Feature/EInvoice/Verifactu/Models/WSTest.php b/tests/Feature/EInvoice/Verifactu/Models/WSTest.php index 998d2d1307..a12a86f320 100644 --- a/tests/Feature/EInvoice/Verifactu/Models/WSTest.php +++ b/tests/Feature/EInvoice/Verifactu/Models/WSTest.php @@ -12,6 +12,7 @@ use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento; use App\Services\EDocument\Standards\Verifactu\Models\SistemaInformatico; use App\Services\EDocument\Standards\Verifactu\Response\ResponseProcessor; use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica; +use App\Services\EDocument\Standards\Verifactu\Models\InvoiceModification; class WSTest extends TestCase @@ -398,112 +399,114 @@ $invoice->setDestinatarios($destinatarios); //@todo - Need to test that modifying an invoice works. public function test_cancel_and_modify_existing_invoice() { - - $currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP'); $invoice_number = 'TEST0033343436'; $invoice_date = '02-07-2025'; - $calc_hash = 'A0B4D14E6F7769860C8A4EAFFA3EEBF52B7044685BD69D1DB5BBD68EA0E2BA21'; $nif = '99999910G'; - $soapXml = << - - - - A39200019 - Sistema de Facturación - - + // Create original invoice (the one to be cancelled) + $originalInvoice = new Invoice(); + $originalInvoice + ->setIdVersion('1.0') + ->setIdFactura($invoice_number) + ->setNombreRazonEmisor('Original Company') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Original invoice') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro($currentTimestamp) + ->setTipoHuella('01') + ->setHuella('ORIGINAL_HASH'); - - + // Add emitter to original invoice + $emisor = new PersonaFisicaJuridica(); + $emisor + ->setNif($nif) + ->setRazonSocial('Original Company'); + $originalInvoice->setTercero($emisor); - - - 99999910G - TEST0033343436 - 02-07-2025 - - 1 - + // Add sistema informatico to original invoice + $sistema = new SistemaInformatico(); + $sistema + ->setNombreRazon('Sistema de Facturación') + ->setNif('A39200019') + ->setNombreSistemaInformatico('InvoiceNinja') + ->setIdSistemaInformatico('77') + ->setVersion('1.0.03') + ->setNumeroInstalacion('383'); + $originalInvoice->setSistemaInformatico($sistema); - - - 1.0 - - - 99999910G - {$invoice_number} - {$invoice_date} - - - CERTIFICADO FISICA PRUEBAS - F1 - Test invoice submitted by computer system on behalf of business - - - Test Recipient Company - A39200019 - - - - - 01 - S1 - 21 - 100.00 - 21.00 - - - 21.00 - 121.00 - - - N - - - - Sistema de Facturación - A39200019 - InvoiceNinja - 77 - 1.0.03 - 383 - N - S - S - - {$currentTimestamp} - 01 - PLACEHOLDER_HUELLA - + // Create modified invoice (the replacement) + $modifiedInvoice = new Invoice(); + $modifiedInvoice + ->setIdVersion('1.0') + ->setIdFactura($invoice_number) + ->setNombreRazonEmisor('CERTIFICADO FISICA PRUEBAS') + ->setTipoFactura('F1') + ->setDescripcionOperacion('Test invoice submitted by computer system on behalf of business') + ->setCuotaTotal(21.00) + ->setImporteTotal(121.00) + ->setFechaHoraHusoGenRegistro($currentTimestamp) + ->setTipoHuella('01') + ->setHuella('PLACEHOLDER_HUELLA'); - + // Add emitter to modified invoice + $emisorModificado = new PersonaFisicaJuridica(); + $emisorModificado + ->setNif($nif) + ->setRazonSocial('CERTIFICADO FISICA PRUEBAS'); + $modifiedInvoice->setTercero($emisorModificado); - - - - XML; + // Add sistema informatico to modified invoice + $modifiedInvoice->setSistemaInformatico($sistema); + // Add destinatarios to modified invoice + $destinatario = new PersonaFisicaJuridica(); + $destinatario + ->setNombreRazon('Test Recipient Company') + ->setNif('A39200019'); + $modifiedInvoice->setDestinatarios([$destinatario]); + + // Add desglose to modified invoice + $desglose = new Desglose(); + $desglose->setDesgloseFactura([ + 'Impuesto' => '01', + 'ClaveRegimen' => '01', + 'CalificacionOperacion' => 'S1', + 'TipoImpositivo' => '21', + 'BaseImponibleOimporteNoSujeto' => '100.00', + 'CuotaRepercutida' => '21.00' + ]); + $modifiedInvoice->setDesglose($desglose); + + // Add encadenamiento to modified invoice + $encadenamiento = new Encadenamiento(); + $encadenamiento->setPrimerRegistro('S'); + $modifiedInvoice->setEncadenamiento($encadenamiento); + + // Create modification using the new models + $modification = InvoiceModification::createFromInvoice($originalInvoice, $modifiedInvoice); // Calculate the correct hash using AEAT's specified format $correctHash = $this->calculateVerifactuHash( $nif, // IDEmisorFactura $invoice_number, // NumSerieFactura - $invoice_date, // FechaExpedicionFactura - 'F1', // TipoFactura - '21.00', // CuotaTotal - '121.00', // ImporteTotal - '', // Huella (empty for first calculation) - $currentTimestamp // FechaHoraHusoGenRegistro (current time) + $invoice_date, // FechaExpedicionFactura + 'F1', // TipoFactura + '21.00', // CuotaTotal + '121.00', // ImporteTotal + '', // Huella (empty for first calculation) + $currentTimestamp // FechaHoraHusoGenRegistro (current time) ); - // Replace the placeholder with the correct hash - $soapXml = str_replace('PLACEHOLDER_HUELLA', $correctHash, $soapXml); + // Update the modification record with the correct hash + $modification->getRegistroModificacion()->setHuella($correctHash); nlog('Calculated hash for XML: ' . $correctHash); + // Generate SOAP envelope + $soapXml = $modification->toSoapEnvelope(); + // Sign the XML before sending $certPath = storage_path('aeat-cert5.pem'); $keyPath = storage_path('aeat-key5.pem'); @@ -538,14 +541,12 @@ $invoice->setDestinatarios($destinatarios); $this->assertTrue($response->successful()); - $responseProcessor = new ResponseProcessor(); $responseProcessor->processResponse($response->body()); nlog($responseProcessor->getSummary()); $this->assertTrue($responseProcessor->getSummary()['success']); - }