Tests for modified invoices

This commit is contained in:
David Bomba 2025-08-08 10:40:38 +10:00
parent aa918f7ec0
commit 5895c1b0ed
9 changed files with 1698 additions and 98 deletions

View File

@ -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

View File

@ -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
@ -219,10 +224,4 @@ class Verifactu extends AbstractService
return '01';
}
private function calculateInvoiceType(): string
{
//tipofactua
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* InvoiceModification - Complete Invoice Modification Container
*
* This class represents the complete modification structure required for Verifactu e-invoicing
* modification operations. It contains both the cancellation record and the modification record.
*/
class InvoiceModification extends BaseXmlModel
{
protected RegistroAnulacion $registroAnulacion;
protected RegistroModificacion $registroModificacion;
protected SistemaInformatico $sistemaInformatico;
// @todo - in the UI we'll need additional logic to support these codes
private array $motivo_anulacion_codes = [
'1' => "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;
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* RegistroAnulacion - Invoice Cancellation Record
*
* This class represents the cancellation record information required for Verifactu e-invoicing
* modification operations. It contains the details of the invoice to be cancelled.
*/
class RegistroAnulacion extends BaseXmlModel
{
protected string $idVersion;
protected string $idEmisorFactura;
protected string $numSerieFactura;
protected string $fechaExpedicionFactura;
protected string $motivoAnulacion;
public function __construct()
{
$this->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();
}
}

View File

@ -0,0 +1,675 @@
<?php
namespace App\Services\EDocument\Standards\Verifactu\Models;
/**
* RegistroModificacion - Invoice Modification Record
*
* This class represents the modification record information required for Verifactu e-invoicing
* modification operations. It contains the new/modified invoice data.
*/
class RegistroModificacion extends BaseXmlModel
{
protected string $idVersion;
protected string $idFactura;
protected ?string $refExterna = null;
protected string $nombreRazonEmisor;
protected ?string $subsanacion = null;
protected ?string $rechazoPrevio = null;
protected string $tipoFactura;
protected ?string $tipoRectificativa = null;
protected ?array $facturasRectificadas = null;
protected ?array $facturasSustituidas = null;
protected ?float $importeRectificacion = null;
protected ?string $fechaOperacion = null;
protected string $descripcionOperacion;
protected ?string $facturaSimplificadaArt7273 = null;
protected ?string $facturaSinIdentifDestinatarioArt61d = null;
protected ?string $macrodato = null;
protected ?string $emitidaPorTerceroODestinatario = null;
protected ?PersonaFisicaJuridica $tercero = null;
protected ?array $destinatarios = null;
protected ?string $cupon = null;
protected Desglose $desglose;
protected float $cuotaTotal;
protected float $importeTotal;
protected Encadenamiento $encadenamiento;
protected SistemaInformatico $sistemaInformatico;
protected string $fechaHoraHusoGenRegistro;
protected ?string $numRegistroAcuerdoFacturacion = null;
protected ?string $idAcuerdoSistemaInformatico = null;
protected string $tipoHuella;
protected string $huella;
protected ?string $signature = null;
protected ?FacturaRectificativa $facturaRectificativa = null;
protected ?string $privateKeyPath = null;
protected ?string $publicKeyPath = null;
protected ?string $certificatePath = null;
protected ?string $fechaExpedicionFactura = null;
public function __construct()
{
// Initialize required properties
$this->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;
}
}

View File

@ -0,0 +1,489 @@
<?php
namespace Tests\Feature\EInvoice\Verifactu\Models;
use Tests\TestCase;
use App\Services\EDocument\Standards\Verifactu\Models\Invoice;
use App\Services\EDocument\Standards\Verifactu\Models\InvoiceModification;
use App\Services\EDocument\Standards\Verifactu\Models\RegistroAnulacion;
use App\Services\EDocument\Standards\Verifactu\Models\RegistroModificacion;
use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica;
use App\Services\EDocument\Standards\Verifactu\Models\Desglose;
use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento;
use App\Services\EDocument\Standards\Verifactu\Models\SistemaInformatico;
class InvoiceModificationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
}
public function test_can_create_registro_anulacion()
{
$cancellation = new RegistroAnulacion();
$cancellation
->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('<soapenv:Envelope', $soapXml);
$this->assertStringContainsString('<soapenv:Header', $soapXml);
$this->assertStringContainsString('<soapenv:Body', $soapXml);
$this->assertStringContainsString('<sum:ModificacionFactura', $soapXml);
$this->assertStringContainsString('<sf:RegistroAnulacion', $soapXml);
$this->assertStringContainsString('<sf:RegistroModificacion', $soapXml);
// Verify cancellation structure
$this->assertStringContainsString('<sf:IDFactura', $soapXml);
$this->assertStringContainsString('<sf:IDEmisorFactura>99999910G</sf:IDEmisorFactura>', $soapXml);
$this->assertStringContainsString('<sf:NumSerieFactura>TEST0033343436</sf:NumSerieFactura>', $soapXml);
$this->assertStringContainsString('<sf:MotivoAnulacion>1</sf:MotivoAnulacion>', $soapXml);
// Verify modification structure
$this->assertStringContainsString('<sf:NombreRazonEmisor>Modified Company</sf:NombreRazonEmisor>', $soapXml);
$this->assertStringContainsString('<sf:CuotaTotal>42</sf:CuotaTotal>', $soapXml);
$this->assertStringContainsString('<sf:ImporteTotal>242</sf:ImporteTotal>', $soapXml);
}
}

View File

@ -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,94 +399,93 @@ $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 = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd">
<soapenv:Header>
<tik:ObligadoEmision xmlns:tik="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd">
<tik:NIF>A39200019</tik:NIF>
<tik:NombreRazon>Sistema de Facturación</tik:NombreRazon>
</tik:ObligadoEmision>
</soapenv:Header>
// 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');
<soapenv:Body>
<sum:ModificacionFactura>
// Add emitter to original invoice
$emisor = new PersonaFisicaJuridica();
$emisor
->setNif($nif)
->setRazonSocial('Original Company');
$originalInvoice->setTercero($emisor);
<sum1:RegistroAnulacion>
<sum1:IDFactura>
<sum1:IDEmisorFactura>99999910G</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>TEST0033343436</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>02-07-2025</sum1:FechaExpedicionFactura>
</sum1:IDFactura>
<sum1:MotivoAnulacion>1</sum1:MotivoAnulacion> <!-- 1 = Sustitución por otra factura -->
</sum1:RegistroAnulacion>
// 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);
<sum1:RegistroModificacion>
// 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');
<sum1:IDVersion>1.0</sum1:IDVersion>
<!-- IDFactura: The actual invoice issuer (using same test NIF) -->
<sum1:IDFactura>
<sum1:IDEmisorFactura>99999910G</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>{$invoice_number}</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>{$invoice_date}</sum1:FechaExpedicionFactura>
</sum1:IDFactura>
<!-- NombreRazonEmisor: The actual business that issued the invoice -->
<sum1:NombreRazonEmisor>CERTIFICADO FISICA PRUEBAS</sum1:NombreRazonEmisor>
<sum1:TipoFactura>F1</sum1:TipoFactura>
<sum1:DescripcionOperacion>Test invoice submitted by computer system on behalf of business</sum1:DescripcionOperacion>
<sum1:Destinatarios>
<sum1:IDDestinatario>
<sum1:NombreRazon>Test Recipient Company</sum1:NombreRazon>
<sum1:NIF>A39200019</sum1:NIF>
</sum1:IDDestinatario>
</sum1:Destinatarios>
<sum1:Desglose>
<sum1:DetalleDesglose>
<sum1:ClaveRegimen>01</sum1:ClaveRegimen>
<sum1:CalificacionOperacion>S1</sum1:CalificacionOperacion>
<sum1:TipoImpositivo>21</sum1:TipoImpositivo>
<sum1:BaseImponibleOimporteNoSujeto>100.00</sum1:BaseImponibleOimporteNoSujeto>
<sum1:CuotaRepercutida>21.00</sum1:CuotaRepercutida>
</sum1:DetalleDesglose>
</sum1:Desglose>
<sum1:CuotaTotal>21.00</sum1:CuotaTotal>
<sum1:ImporteTotal>121.00</sum1:ImporteTotal>
<!-- Encadenamiento: Required chaining information -->
<sum1:Encadenamiento>
<sum1:PrimerRegistro>N</sum1:PrimerRegistro>
</sum1:Encadenamiento>
<!-- SistemaInformatico: The computer system details (same as ObligadoEmision) -->
<sum1:SistemaInformatico>
<sum1:NombreRazon>Sistema de Facturación</sum1:NombreRazon>
<sum1:NIF>A39200019</sum1:NIF>
<sum1:NombreSistemaInformatico>InvoiceNinja</sum1:NombreSistemaInformatico>
<sum1:IdSistemaInformatico>77</sum1:IdSistemaInformatico>
<sum1:Version>1.0.03</sum1:Version>
<sum1:NumeroInstalacion>383</sum1:NumeroInstalacion>
<sum1:TipoUsoPosibleSoloVerifactu>N</sum1:TipoUsoPosibleSoloVerifactu>
<sum1:TipoUsoPosibleMultiOT>S</sum1:TipoUsoPosibleMultiOT>
<sum1:IndicadorMultiplesOT>S</sum1:IndicadorMultiplesOT>
</sum1:SistemaInformatico>
<sum1:FechaHoraHusoGenRegistro>{$currentTimestamp}</sum1:FechaHoraHusoGenRegistro>
<sum1:TipoHuella>01</sum1:TipoHuella>
<sum1:Huella>PLACEHOLDER_HUELLA</sum1:Huella>
// Add emitter to modified invoice
$emisorModificado = new PersonaFisicaJuridica();
$emisorModificado
->setNif($nif)
->setRazonSocial('CERTIFICADO FISICA PRUEBAS');
$modifiedInvoice->setTercero($emisorModificado);
// Add sistema informatico to modified invoice
$modifiedInvoice->setSistemaInformatico($sistema);
</sum1:RegistroModificacion>
// Add destinatarios to modified invoice
$destinatario = new PersonaFisicaJuridica();
$destinatario
->setNombreRazon('Test Recipient Company')
->setNif('A39200019');
$modifiedInvoice->setDestinatarios([$destinatario]);
</sum:ModificacionFactura>
</soapenv:Body>
</soapenv:Envelope>
XML;
// 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(
@ -499,11 +499,14 @@ $invoice->setDestinatarios($destinatarios);
$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']);
}