From f6a1553d8e25dbbad78a3078eecd0104366bc038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 5 Jun 2019 16:04:45 +0200 Subject: [PATCH 001/146] =?UTF-8?q?Arreglado=20namespace=20en=20documentac?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corregido typo en ejemplo de extensión de terceros --- doc/extensiones/extensiones.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/extensiones/extensiones.md b/doc/extensiones/extensiones.md index 267aae7..415cb1d 100644 --- a/doc/extensiones/extensiones.md +++ b/doc/extensiones/extensiones.md @@ -20,7 +20,7 @@ $awesome = $fac->getExtension(AwesomeExtension::class); Una extensión de Facturae-PHP tiene un aspecto similar a este: ```php -class AwesomeExtension extends josemmo\Facturae\Extensions\FacturaeExtension { +class AwesomeExtension extends \josemmo\Facturae\Extensions\FacturaeExtension { // NOTA: todos los métodos de este ejemplo son opcionales public function __getAdditionalData() { From 826cc94895f7554f98828a859b7d1cffc658c373 Mon Sep 17 00:00:00 2001 From: KaiserVito Date: Tue, 9 Jul 2019 15:03:32 +0200 Subject: [PATCH 002/146] Added new properties Added InvoiceAdditionalInformation and RelatedDocument --- src/FacturaeTraits/PropertiesTrait.php | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 212143e..e9f82ee 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -35,6 +35,11 @@ trait PropertiesTrait { protected $legalLiterals = array(); protected $discounts = array(); protected $charges = array(); + protected $additional = array( + "relatedInvoice" => null, + "relatedDocument" => array(), + "invoiceAdditionalInformation" => null + ); /** @@ -551,5 +556,52 @@ public function getTotals() { return $totals; } + + /** + * Set additional information + * @param string $information Invoice additional information + * @return Facturae Invoice instance + */ + + public function setAdditionalInformation($information) { + $this->additional['invoiceAdditionalInformation'] = $information; + return $this; + } + + /** + * Get additional information + * @return Facturae additional information + */ + + public function getAdditionalInformation() { + return $this->additional['invoiceAdditionalInformation']; + } + + /** + * Add related document + * @param string $file File path + * @param string $description Document Description + * @return Facturae Invoice instance + */ + + public function addRelatedDocument($file,$description) { + if (file_exists($file)) { + $myfile = fopen($file, "r"); + $fileData = fread($myfile,filesize($file)); + fclose($myfile); + $this->additional['relatedDocument'][] = array("attachmentCompressionAlgorithm" => "NONE", "attachmentFormat" => pathinfo($file, PATHINFO_EXTENSION), "attachmentEncoding" => "BASE64", "attachmentDescription" => $description, "attachmentData" => base64_encode($fileData)); + } + return $this; + } + + /** + * Clear related documents + * @return Facturae Invoice instance + */ + + public function clearRelatedDocument() { + $this->additional['relatedDocument'] = array(); + return $this; + } } From 493e458b3d244c8882e6321e194084ddd7b58993 Mon Sep 17 00:00:00 2001 From: KaiserVito Date: Tue, 9 Jul 2019 15:07:41 +0200 Subject: [PATCH 003/146] Added new properties Added InvoiceAdditionalInformation and RelatedDocument --- src/FacturaeTraits/ExportableTrait.php | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 0ec4e81..919f2b7 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -279,10 +279,28 @@ public function export($filePath=null) { $extXML = $ext->__getAdditionalData(); if (!empty($extXML)) $extensionsXML[] = $extXML; } - if (count($extensionsXML) > 0) { - $xml .= ''; - $xml .= implode("", $extensionsXML); - $xml .= ''; + if (count($extensionsXML) > 0 || count($this->additional['relatedDocument']) > 0 || $this->additional['invoiceAdditionalInformation'] !== NULL) { + $xml .= ''; + if (count($this->additional['relatedDocument']) > 0) { + $xml .= ''; + foreach ($this->additional['relatedDocument'] as $attachmnet) { + $xml .= ''; + $xml .= ''.$attachmnet['attachmentCompressionAlgorithm'].''; + $xml .= ''.$attachmnet['attachmentFormat'].''; + $xml .= ''.$attachmnet['attachmentEncoding'].''; + $xml .= ''.$attachmnet['attachmentDescription'].''; + $xml .= ''.$attachmnet['attachmentData'].''; + $xml .= ''; + } + $xml .= ''; + } + if ($this->additional['invoiceAdditionalInformation'] !== NULL) $xml .= '' . $this->additional['invoiceAdditionalInformation'] . ''; + if (count($extensionsXML) > 0) { + $xml .= ''; + $xml .= implode("", $extensionsXML); + $xml .= ''; + } + $xml .= ''; } // Close invoice and document From 509d38670f2e79e788707b6da31a30ba69485ff3 Mon Sep 17 00:00:00 2001 From: KaiserVito Date: Tue, 9 Jul 2019 15:11:38 +0200 Subject: [PATCH 004/146] Added new properties Added InvoiceAdditionalInformation and RelatedDocument --- src/FacturaeTraits/PropertiesTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index e9f82ee..9364d18 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -38,7 +38,7 @@ trait PropertiesTrait { protected $additional = array( "relatedInvoice" => null, "relatedDocument" => array(), - "invoiceAdditionalInformation" => null + "invoiceAdditionalInformation" => null ); From f8037c6433a023a5bb0c3c094c3a6c4463bacec3 Mon Sep 17 00:00:00 2001 From: KaiserVito Date: Tue, 9 Jul 2019 15:13:19 +0200 Subject: [PATCH 005/146] New properties Added InvoiceAdditionalInformation and RelatedDocument --- src/FacturaeTraits/PropertiesTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 9364d18..2b2ae4b 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -590,7 +590,7 @@ public function addRelatedDocument($file,$description) { $fileData = fread($myfile,filesize($file)); fclose($myfile); $this->additional['relatedDocument'][] = array("attachmentCompressionAlgorithm" => "NONE", "attachmentFormat" => pathinfo($file, PATHINFO_EXTENSION), "attachmentEncoding" => "BASE64", "attachmentDescription" => $description, "attachmentData" => base64_encode($fileData)); - } + } return $this; } From 2c98a62d650f911c145538c4f437e4e5831a0d86 Mon Sep 17 00:00:00 2001 From: KaiserVito Date: Tue, 9 Jul 2019 20:46:21 +0200 Subject: [PATCH 006/146] Added contact details Added contact details --- src/FacturaeParty.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index a50f019..bdc3c8d 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -151,6 +151,19 @@ public function getXML($schema) { '' . $tools->escape($this->province) . '' . '' . $this->countryCode . '' . ''; + + // Add contact details + if ($this->phone !== NULL || $this->fax !== NULL || $this->website !== NULL || $this->email !== NULL || $this->contactPeople !== NULL || $this->cnoCnae !== NULL || $this->ineTownCode !== NULL) { + $xml .= ''; + if ($this->phone !== NULL) $xml .= '' . $tools->escape($this->phone) . ''; + if ($this->fax !== NULL) $xml .= '' . $tools->escape($this->fax) . ''; + if ($this->website !== NULL) $xml .= '' . $tools->escape($this->website) . ''; + if ($this->email !== NULL) $xml .= '' . $tools->escape($this->email) . ''; + if ($this->contactPeople !== NULL) $xml .= '' . $tools->escape($this->contactPeople) . ''; + if ($this->cnoCnae !== NULL) $xml .= '' . $this->cnoCnae . ''; + if ($this->ineTownCode !== NULL) $xml .= '' . $$this->ineTownCode . ''; + $xml .= ''; + } // Close custom block $xml .= ($this->isLegalEntity) ? '' : ''; From 4d83429fae2776d7d415d7509312c3f545b28f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 14 Jul 2019 16:25:37 +0200 Subject: [PATCH 007/146] Refactorizado ContactDetails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactorizado generador de XML de ContactDetails en FacturaeParty - Modificado InvoiceTest para añadir datos de contacto - Actualizada documentación --- doc/entidades/compradores-y-vendedores.md | 4 +- src/FacturaeParty.php | 55 ++++++++++++++++++----- tests/InvoiceTest.php | 9 +++- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/doc/entidades/compradores-y-vendedores.md b/doc/entidades/compradores-y-vendedores.md index fff8e53..5beb798 100644 --- a/doc/entidades/compradores-y-vendedores.md +++ b/doc/entidades/compradores-y-vendedores.md @@ -28,7 +28,7 @@ $empresa = new FacturaeParty([ "fax" => "910555443" "website" => "http://www.perico.com/", "contactPeople" => "Perico", - "cnoCnae" => "4791", // Clasif. Nacional de Act. Económicas + "cnoCnae" => "04647", // Clasif. Nacional de Act. Económicas "ineTownCode" => "280796" // Cód. de municipio del INE ]); @@ -48,7 +48,7 @@ $personaFisica = new FacturaeParty([ "fax" => "910777888" "website" => "http://www.antoniogarcia.es/", "contactPeople" => "Antonio García", - "cnoCnae" => "4791", // Clasif. Nacional de Act. Económicas + "cnoCnae" => "04791", // Clasif. Nacional de Act. Económicas "ineTownCode" => "280796" // Cód. de municipio del INE ]); ``` diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index bdc3c8d..3479ce5 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -151,19 +151,9 @@ public function getXML($schema) { '' . $tools->escape($this->province) . '' . '' . $this->countryCode . '' . ''; - + // Add contact details - if ($this->phone !== NULL || $this->fax !== NULL || $this->website !== NULL || $this->email !== NULL || $this->contactPeople !== NULL || $this->cnoCnae !== NULL || $this->ineTownCode !== NULL) { - $xml .= ''; - if ($this->phone !== NULL) $xml .= '' . $tools->escape($this->phone) . ''; - if ($this->fax !== NULL) $xml .= '' . $tools->escape($this->fax) . ''; - if ($this->website !== NULL) $xml .= '' . $tools->escape($this->website) . ''; - if ($this->email !== NULL) $xml .= '' . $tools->escape($this->email) . ''; - if ($this->contactPeople !== NULL) $xml .= '' . $tools->escape($this->contactPeople) . ''; - if ($this->cnoCnae !== NULL) $xml .= '' . $this->cnoCnae . ''; - if ($this->ineTownCode !== NULL) $xml .= '' . $$this->ineTownCode . ''; - $xml .= ''; - } + $xml .= $this->getContactDetailsXML(); // Close custom block $xml .= ($this->isLegalEntity) ? '' : ''; @@ -172,4 +162,45 @@ public function getXML($schema) { return $xml; } + + /** + * Get contact details XML + * + * @return string Contact details XML + */ + private function getContactDetailsXML() { + $tools = new XmlTools(); + $contactFields = [ + "phone" => "Telephone", + "fax" => "TeleFax", + "website" => "WebAddress", + "email" => "ElectronicMail", + "contactPeople" => "ContactPersons", + "cnoCnae" => "CnoCnae", + "ineTownCode" => "INETownCode" + ]; + + // Validate attributes + $hasDetails = false; + foreach (array_keys($contactFields) as $field) { + if (!empty($this->$field)) { + $hasDetails = true; + break; + } + } + if (!$hasDetails) return ""; + + // Add fields + $xml = ''; + foreach ($contactFields as $field=>$xmlName) { + $value = $this->$field; + if (!empty($value)) { + $xml .= "<$xmlName>" . $tools->escape($value) . ""; + } + } + $xml .= ''; + + return $xml; + } + } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 9764911..3a3c3e1 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -44,7 +44,12 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "province" => "Madrid", "book" => "0", "sheet" => "1", - "merchantRegister" => "RG" + "merchantRegister" => "RG", + "phone" => "910112233", + "fax" => "910112234", + "email" => "noexiste@ejemplo.com", + "cnoCnae" => "04647", + "ineTownCode" => "0796" ])); // Incluimos los datos del comprador @@ -55,6 +60,8 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "postCode" => "28701", "town" => "San Sebastián de los Reyes", "province" => "Madrid", + "website" => "http://www.ssreyes.org/es/", + "contactPeople" => "Persona de contacto", "centres" => [ new FacturaeCentre([ "role" => FacturaeCentre::ROLE_GESTOR, From 7af6422a155a8d68cb708b4774718089242b9a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 14 Jul 2019 17:24:19 +0200 Subject: [PATCH 008/146] Refactorizado AdditionalData - Creados setter y getter para "relatedInvoice" - Renombrado "relatedDocuments" a "attachments" - Refactorizado ExportableTrait --- src/FacturaeTraits/ExportableTrait.php | 81 ++++++++++----- src/FacturaeTraits/PropertiesTrait.php | 136 +++++++++++++++---------- 2 files changed, 136 insertions(+), 81 deletions(-) diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 919f2b7..7664956 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -274,34 +274,7 @@ public function export($filePath=null) { } // Add additional data - $extensionsXML = array(); - foreach ($this->extensions as $ext) { - $extXML = $ext->__getAdditionalData(); - if (!empty($extXML)) $extensionsXML[] = $extXML; - } - if (count($extensionsXML) > 0 || count($this->additional['relatedDocument']) > 0 || $this->additional['invoiceAdditionalInformation'] !== NULL) { - $xml .= ''; - if (count($this->additional['relatedDocument']) > 0) { - $xml .= ''; - foreach ($this->additional['relatedDocument'] as $attachmnet) { - $xml .= ''; - $xml .= ''.$attachmnet['attachmentCompressionAlgorithm'].''; - $xml .= ''.$attachmnet['attachmentFormat'].''; - $xml .= ''.$attachmnet['attachmentEncoding'].''; - $xml .= ''.$attachmnet['attachmentDescription'].''; - $xml .= ''.$attachmnet['attachmentData'].''; - $xml .= ''; - } - $xml .= ''; - } - if ($this->additional['invoiceAdditionalInformation'] !== NULL) $xml .= '' . $this->additional['invoiceAdditionalInformation'] . ''; - if (count($extensionsXML) > 0) { - $xml .= ''; - $xml .= implode("", $extensionsXML); - $xml .= ''; - } - $xml .= ''; - } + $xml .= $this->getAdditionalDataXML(); // Close invoice and document $xml .= ''; @@ -319,4 +292,56 @@ public function export($filePath=null) { return $xml; } + + /** + * Get additional data XML + * @return string Additional data XML + */ + private function getAdditionalDataXML() { + $extensionsXML = array(); + foreach ($this->extensions as $ext) { + $extXML = $ext->__getAdditionalData(); + if (!empty($extXML)) $extensionsXML[] = $extXML; + } + $relInvoice =& $this->header['relatedInvoice']; + $additionalInfo =& $this->header['additionalInformation']; + + // Validate additional data fields + $hasData = !empty($extensionsXML) || !empty($this->attachments) || !empty($relInvoice) || !empty($additionalInfo); + if (!$hasData) return ""; + + // Generate initial XML block + $tools = new XmlTools(); + $xml = ''; + if (!empty($relInvoice)) $xml .= '' . $tools->escape($relInvoice) . ''; + + // Add attachments + if (!empty($this->attachments)) { + $xml .= ''; + foreach ($this->attachments as $att) { + $type = explode('/', $att['file']->getMimeType()); + $type = end($type); + $xml .= ''; + $xml .= 'NONE'; + $xml .= '' . $tools->escape($type) . ''; + $xml .= 'BASE64'; + $xml .= '' . $tools->escape($att['description']) . ''; + $xml .= '' . base64_encode($att['file']->getData()) . ''; + $xml .= ''; + } + $xml .= ''; + } + + // Add additional information + if (!empty($additionalInfo)) { + $xml .= '' . $tools->escape($additionalInfo) . ''; + } + + // Add extensions data + if (!empty($extensionsXML)) $xml .= '' . implode('', $extensionsXML) . ''; + + $xml .= ''; + return $xml; + } + } diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 2b2ae4b..eb460ed 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -1,6 +1,7 @@ null, "receiverTransactionReference" => null, "fileReference" => null, - "receiverContractReference" => null + "receiverContractReference" => null, + "relatedInvoice" => null, + "additionalInformation" => null ); protected $parties = array( "seller" => null, @@ -35,11 +38,7 @@ trait PropertiesTrait { protected $legalLiterals = array(); protected $discounts = array(); protected $charges = array(); - protected $additional = array( - "relatedInvoice" => null, - "relatedDocument" => array(), - "invoiceAdditionalInformation" => null - ); + protected $attachments = array(); /** @@ -427,6 +426,84 @@ public function clearCharges() { } + /** + * Set related invoice + * @param string $relatedInvoice Related invoice number + * @return Facturae Invoice instance + */ + public function setRelatedInvoice($relatedInvoice) { + $this->header['relatedInvoice'] = $relatedInvoice; + } + + + /** + * Get related invoice + * @return string|null Related invoice number + */ + public function getRelatedInvoice() { + return $this->header['relatedInvoice']; + } + + + /** + * Set additional information + * @param string $information Invoice additional information + * @return Facturae Invoice instance + */ + public function setAdditionalInformation($information) { + $this->header['additionalInformation'] = $information; + return $this; + } + + + /** + * Get additional information + * @return string|null Additional information + */ + public function getAdditionalInformation() { + return $this->header['additionalInformation']; + } + + + /** + * Add attachment + * @param string|FacturaeFile $file File path or instance + * @param string|null $description Document description + * @return Facturae Invoice instance + */ + public function addAttachment($file, $description=null) { + if (is_string($file)) { + $filePath = $file; + $file = new FacturaeFile(); + $file->loadFile($filePath); + } + + $this->attachments[] = array( + "file" => $file, + "description" => $description + ); + } + + + /** + * Get attachments + * @return array Attachments + */ + public function getAttachments() { + return $this->attachments; + } + + + /** + * Clear attachments + * @return Facturae Invoice instance + */ + public function clearAttachments() { + $this->attachments = array(); + return $this; + } + + /** * Add item * Adds an item row to invoice. The fist parameter ($desc), can be an string @@ -556,52 +633,5 @@ public function getTotals() { return $totals; } - - /** - * Set additional information - * @param string $information Invoice additional information - * @return Facturae Invoice instance - */ - - public function setAdditionalInformation($information) { - $this->additional['invoiceAdditionalInformation'] = $information; - return $this; - } - - /** - * Get additional information - * @return Facturae additional information - */ - - public function getAdditionalInformation() { - return $this->additional['invoiceAdditionalInformation']; - } - - /** - * Add related document - * @param string $file File path - * @param string $description Document Description - * @return Facturae Invoice instance - */ - - public function addRelatedDocument($file,$description) { - if (file_exists($file)) { - $myfile = fopen($file, "r"); - $fileData = fread($myfile,filesize($file)); - fclose($myfile); - $this->additional['relatedDocument'][] = array("attachmentCompressionAlgorithm" => "NONE", "attachmentFormat" => pathinfo($file, PATHINFO_EXTENSION), "attachmentEncoding" => "BASE64", "attachmentDescription" => $description, "attachmentData" => base64_encode($fileData)); - } - return $this; - } - - /** - * Clear related documents - * @return Facturae Invoice instance - */ - - public function clearRelatedDocument() { - $this->additional['relatedDocument'] = array(); - return $this; - } } From 65a64a0dad75a82fb9a26b01c755ee0154ce0d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 14 Jul 2019 17:39:55 +0200 Subject: [PATCH 009/146] =?UTF-8?q?Documentaci=C3=B3n=20y=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizados tests para validar datos adicionales de una factura - Añadida documentación de datos adicionales --- doc/propiedades/datos-adicionales.md | 38 ++++++++++++++++++++++++++ doc/propiedades/version-de-facturae.md | 2 +- tests/InvoiceTest.php | 10 +++++++ tests/MethodsTest.php | 16 +++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 doc/propiedades/datos-adicionales.md diff --git a/doc/propiedades/datos-adicionales.md b/doc/propiedades/datos-adicionales.md new file mode 100644 index 0000000..0c7871d --- /dev/null +++ b/doc/propiedades/datos-adicionales.md @@ -0,0 +1,38 @@ +--- +title: Datos adicionales +parent: Propiedades de una factura +nav_order: 5 +permalink: /propiedades/datos-adicionales.html +--- + +# Datos adicionales +Una factura puede contener una serie de datos adicionales, todos ellos opcionales, que se anexan al final de la misma. + +## Documentos adjuntos +Ficheros en formato `xml`, `doc`, `gif`, `rtf`, `pdf`, `xls`, `jpg`, `bmp`, `tiff` o `html` que se adjuntan al documento XML de la factura. +```php +$fac->addAttachment(__DIR__ . '/adjunto.pdf', 'Descripción del documento (opcional)'); +``` + +En vez de indicar la ruta del fichero adjunto, también se puede pasar una instancia de `FacturaeFile` a este método: +```php +$file = new FacturaeFile(); +$file->loadFile(__DIR__ . '/adjunto.pdf'); +$fac->addAttachment($file, 'Descripción del documento (opcional)'); +``` + +--- + +## Factura relacionada +Indica el número de una factura relacionada con la instancia actual. +```php +$fac->setRelatedInvoice('AAA-BB-27317'); +``` + +--- + +## Información adicional +Campo de texto libre de hasta 2500 caracteres que incluye la información que el emisor considere oportuno. +```php +$fac->setAdditionalInformation('En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor.'); +``` \ No newline at end of file diff --git a/doc/propiedades/version-de-facturae.md b/doc/propiedades/version-de-facturae.md index 49c805e..01ad7ca 100644 --- a/doc/propiedades/version-de-facturae.md +++ b/doc/propiedades/version-de-facturae.md @@ -1,7 +1,7 @@ --- title: Versión de FacturaE parent: Propiedades de una factura -nav_order: 5 +nav_order: 6 permalink: /propiedades/version-de-facturae.html --- diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 3a3c3e1..37e3495 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -2,6 +2,7 @@ namespace josemmo\Facturae\Tests; use josemmo\Facturae\Facturae; +use josemmo\Facturae\FacturaeFile; use josemmo\Facturae\FacturaeItem; use josemmo\Facturae\FacturaeParty; use josemmo\Facturae\FacturaeCentre; @@ -170,6 +171,15 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $fac->setDueDate("2017-12-31"); } + // Añadimos datos adicionales + $fac->setRelatedInvoice('AAA-01273S'); + $fac->setAdditionalInformation('Esta factura es una prueba generada por ' . Facturae::USER_AGENT); + + // Adjuntamos un documento + $attachment = new FacturaeFile(); + $attachment->loadData('mundo', 'adjunto.xml'); + $fac->addAttachment($attachment, 'Un documento XML muy pequeño'); + // Ya solo queda firmar la factura ... if ($isPfx) { $fac->sign(self::CERTS_DIR . "/facturae.pfx", null, self::FACTURAE_CERT_PASS); diff --git a/tests/MethodsTest.php b/tests/MethodsTest.php index ea31cd5..163f2cc 100644 --- a/tests/MethodsTest.php +++ b/tests/MethodsTest.php @@ -2,6 +2,7 @@ namespace josemmo\Facturae\Tests; use josemmo\Facturae\Facturae; +use josemmo\Facturae\FacturaeFile; use josemmo\Facturae\FacturaeItem; use josemmo\Facturae\FacturaeParty; @@ -92,6 +93,21 @@ public function testMethods() { $this->assertEquals($items, $fac->getItems()); $fac->clearItems(); $this->assertEquals([], $fac->getItems()); + + // Additional data + $relatedInvoice = "AAA-01726"; + $additionalInfo = "Lorem ipsum dolor sit amet consectetur adipiscing elit lectus imperdiet quam a nulla."; + $fac->setRelatedInvoice($relatedInvoice); + $this->assertEquals($relatedInvoice, $fac->getRelatedInvoice()); + $fac->setAdditionalInformation($additionalInfo); + $this->assertEquals($additionalInfo, $fac->getAdditionalInformation()); + + // Attachments + $file = new FacturaeFile(); + $fac->addAttachment($file, 'Test description'); + $this->assertEquals($file, $fac->getAttachments()[0]['file']); + $fac->clearAttachments(); + $this->assertEquals([], $fac->getAttachments()); } } From 0ac7505eeb14cbfb1c986baa0729ef156c696d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 15 Jul 2019 13:04:10 +0200 Subject: [PATCH 010/146] Fixed #53 - Calculados descuentos de FacturaeItem *antes* de aplicar impuestos - Aplicados impuestos de FacturaeItem sobre el "grossAmount" - Actualizado test de descuentos --- src/FacturaeItem.php | 42 ++++++++++++++++++++--------------------- tests/DiscountsTest.php | 8 ++++++++ 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 5205c5b..bb0b93d 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -105,27 +105,6 @@ public function getData($fac) { $totalAmountWithoutTax = $fac->pad($quantity * $unitPriceWithoutTax, 'Item/TotalAmountWithoutTax'); $grossAmount = $totalAmountWithoutTax; - // Get taxes - $totalTaxesOutputs = 0; - $totalTaxesWithheld = 0; - foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { - foreach ($this->{$taxesGroup} as $type=>$tax) { - $taxRate = $fac->pad($tax['rate'], 'Tax/Rate'); - $taxAmount = $totalAmountWithoutTax * ($taxRate / 100); - $taxAmount = $fac->pad($taxAmount, 'Tax/Amount'); - $addProps[$taxesGroup][$type] = array( - "base" => $fac->pad($totalAmountWithoutTax, 'Tax/Base'), - "rate" => $taxRate, - "amount" => $taxAmount - ); - if ($i == 1) { // In case of $taxesWithheld (2nd iteration) - $totalTaxesWithheld += $taxAmount; - } else { - $totalTaxesOutputs += $taxAmount; - } - } - } - // Process charges and discounts foreach (['discounts', 'charges'] as $i=>$groupTag) { $factor = ($i == 0) ? -1 : 1; @@ -147,6 +126,27 @@ public function getData($fac) { } } + // Get taxes + $totalTaxesOutputs = 0; + $totalTaxesWithheld = 0; + foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { + foreach ($this->{$taxesGroup} as $type=>$tax) { + $taxRate = $fac->pad($tax['rate'], 'Tax/Rate'); + $taxAmount = $grossAmount * ($taxRate / 100); + $taxAmount = $fac->pad($taxAmount, 'Tax/Amount'); + $addProps[$taxesGroup][$type] = array( + "base" => $fac->pad($grossAmount, 'Tax/Base'), + "rate" => $taxRate, + "amount" => $taxAmount + ); + if ($i == 1) { // In case of $taxesWithheld (2nd iteration) + $totalTaxesWithheld += $taxAmount; + } else { + $totalTaxesOutputs += $taxAmount; + } + } + } + // Add rest of properties $addProps['quantity'] = $quantity; $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax; diff --git a/tests/DiscountsTest.php b/tests/DiscountsTest.php index 238549c..4c790e6 100644 --- a/tests/DiscountsTest.php +++ b/tests/DiscountsTest.php @@ -103,9 +103,17 @@ public function testItemDiscounts() { $invoiceXml = $invoiceXml->Invoices->Invoice[0]; foreach ($invoiceXml->Items->InvoiceLine as $item) { $itemGross = floatval($item->GrossAmount); + $taxableBase = floatval($item->TaxesOutputs->Tax[0]->TaxableBase->TotalAmount); $expectedGross = array_shift($expectedGrossAmounts); $this->assertEquals($itemGross, $expectedGross, '', 0.00001); + $this->assertEquals($taxableBase, $expectedGross, '', 0.00001); } + + // Validate total amounts + $totalGrossAmount = floatval($invoiceXml->InvoiceTotals->TotalGrossAmount); + $totalTaxOutputs = floatval($invoiceXml->InvoiceTotals->TotalTaxOutputs); + $this->assertEquals(299, $totalGrossAmount, '', 0.00001); + $this->assertEquals(28, $totalTaxOutputs, '', 0.00001); } From 312f876bda9eeb5edca69a38ded1fc4dc3c727d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 18 Jul 2019 18:26:37 +0200 Subject: [PATCH 011/146] =?UTF-8?q?Actualizada=20versi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5.0 --> v1.5.1 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 0c15b42..152a93e 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.0"; + const VERSION = "1.5.1"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From d5462d443b0737dcdfb9a6452734bd9121aee0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:39:38 +0200 Subject: [PATCH 012/146] Refactorizado FacturaeItem - Reorganizada variable $grossAmount --- src/FacturaeItem.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index bb0b93d..843c5e3 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -103,9 +103,9 @@ public function getData($fac) { $quantity = $fac->pad($this->quantity, 'Item/Quantity'); $unitPriceWithoutTax = $fac->pad($this->unitPriceWithoutTax, 'Item/UnitPriceWithoutTax'); $totalAmountWithoutTax = $fac->pad($quantity * $unitPriceWithoutTax, 'Item/TotalAmountWithoutTax'); - $grossAmount = $totalAmountWithoutTax; // Process charges and discounts + $grossAmount = $totalAmountWithoutTax; foreach (['discounts', 'charges'] as $i=>$groupTag) { $factor = ($i == 0) ? -1 : 1; foreach ($this->{$groupTag} as $group) { @@ -125,6 +125,7 @@ public function getData($fac) { $grossAmount += $amount * $factor; } } + $grossAmount = $fac->pad($grossAmount, 'Item/GrossAmount'); // Get taxes $totalTaxesOutputs = 0; @@ -151,7 +152,7 @@ public function getData($fac) { $addProps['quantity'] = $quantity; $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax; $addProps['totalAmountWithoutTax'] = $totalAmountWithoutTax; - $addProps['grossAmount'] = $fac->pad($grossAmount, 'Item/GrossAmount'); + $addProps['grossAmount'] = $grossAmount; $addProps['totalTaxesOutputs'] = $totalTaxesOutputs; $addProps['totalTaxesWithheld'] = $totalTaxesWithheld; return array_merge(get_object_vars($this), $addProps); From 24ad38dae88d9160e99b9dc9450f6b4774820585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:42:45 +0200 Subject: [PATCH 013/146] Autosolucionador de decimales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadido autosolucionador de decimales a las líneas de producto > EXPLICACIÓN: cuando un importe de línea es indicado con impuestos incluidos ("unitPrice"), Facturae-PHP se asegurará de que al calcular la base imponible y los impuestos el resultado final sea el mismo que el indicado por el usuario. > Para ello ajustará 1 céntimo en los impuestos si es necesario, ya que el formato FacturaE lo permite. --- src/FacturaeItem.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 843c5e3..36c4307 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -148,6 +148,22 @@ public function getData($fac) { } } + // Fix decimals + if (!is_null($this->unitPrice)) { + $expectedTotal = $this->unitPrice * $this->quantity; + $generatedTotal = $totalAmountWithoutTax + $totalTaxesOutputs - $totalTaxesWithheld; + $diffAmount = $fac->pad($expectedTotal - $generatedTotal, 'Tax/Amount'); + if (abs($diffAmount) == 0.01) { + foreach (['taxesOutputs', 'taxesWithheld'] as $taxesGroup) { + foreach ($addProps[$taxesGroup] as &$taxItem) { + $taxItem['amount'] += $diffAmount; + ${'total' . ucfirst($taxesGroup)} += $diffAmount; + break 2; + } + } + } + } + // Add rest of properties $addProps['quantity'] = $quantity; $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax; From 78fae94846bf22784e897ffa27d6685cef30cc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:45:19 +0200 Subject: [PATCH 014/146] Refactorizados tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidos métodos "getBaseInvoice" y "validateInvoiceXML" a la clase AbstractTest - Eliminado código duplicado --- tests/AbstractTest.php | 92 +++++++++++++++++++++++++++++++++++++++ tests/DecimalsTest.php | 32 ++------------ tests/DiscountsTest.php | 36 +-------------- tests/ExtensionsTest.php | 24 +--------- tests/InvoiceTest.php | 57 +----------------------- tests/PerformanceTest.php | 24 +--------- 6 files changed, 100 insertions(+), 165 deletions(-) diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 41cd008..4033df6 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -2,6 +2,8 @@ namespace josemmo\Facturae\Tests; use PHPUnit\Framework\TestCase; +use josemmo\Facturae\Facturae; +use josemmo\Facturae\FacturaeParty; abstract class AbstractTest extends TestCase { @@ -10,5 +12,95 @@ abstract class AbstractTest extends TestCase { const FACTURAE_CERT_PASS = "12345"; const WEBSERVICES_CERT_PASS = "G5cp,fYC9gje"; const NOTIFICATIONS_EMAIL = "josemmo@pm.me"; + const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; + + /** + * Get base invoice + * @param string|null $schema FacturaE schema + * @return Facturae Invoice instance + */ + protected function getBaseInvoice($schema=null) { + $fac = is_null($schema) ? new Facturae() : new Facturae($schema); + $fac->setNumber('FAC' . date('Ym'), '0001'); + $fac->setIssueDate(date('Y-m-d')); + $fac->setSeller(new FacturaeParty([ + "taxNumber" => "A00000000", + "name" => "Perico de los Palotes S.A.", + "address" => "C/ Falsa, 123", + "postCode" => "12345", + "town" => "Madrid", + "province" => "Madrid" + ])); + $fac->setBuyer(new FacturaeParty([ + "isLegalEntity" => false, + "taxNumber" => "00000000A", + "name" => "Antonio", + "firstSurname" => "García", + "lastSurname" => "Pérez", + "address" => "Avda. Mayor, 7", + "postCode" => "54321", + "town" => "Madrid", + "province" => "Madrid" + ])); + return $fac; + } + + + /** + * Validate Invoice XML + * @param string $path Invoice path + * @param boolean $validateSignature Validate signature + */ + protected function validateInvoiceXML($path, $validateSignature=false) { + // Prepare file to upload + if (function_exists('curl_file_create')) { + $postFile = curl_file_create($path); + } else { + $postFile = "@" . realpath($path); + } + + // Send upload request + $ch = curl_init(); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_URL => "http://plataforma.firma-e.com/VisualizadorFacturae/index2.jsp", + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => array( + "referencia" => $postFile, + "valContable" => "on", + "valFirma" => $validateSignature ? "on" : "off", + "aceptarCondiciones" => "on", + "submit" => "Siguiente" + ), + CURLOPT_COOKIEJAR => self::COOKIES_PATH + )); + $res = curl_exec($ch); + curl_close($ch); + if (strpos($res, "window.open('facturae.jsp'") === false) { + $this->expectException(\UnexpectedValueException::class); + } + + // Fetch results + $ch = curl_init(); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_URL => "http://plataforma.firma-e.com/VisualizadorFacturae/facturae.jsp", + CURLOPT_COOKIEFILE => self::COOKIES_PATH + )); + $res = curl_exec($ch); + curl_close($ch); + + // Validate results + $this->assertNotEmpty($res); + $this->assertContains('euro_ok.png', $res, 'Invalid XML Format'); + if ($validateSignature) { + $this->assertContains('>Nivel de Firma Válido<', $res, 'Invalid Signature'); + } + if (strpos($res, '>Sellos de Tiempo<') !== false) { + $this->assertContains('>XAdES_T<', $res, 'Invalid Timestamp'); + } + } } diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php index b3669f0..663628c 100644 --- a/tests/DecimalsTest.php +++ b/tests/DecimalsTest.php @@ -3,7 +3,6 @@ use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaeItem; -use josemmo\Facturae\FacturaeParty; final class DecimalsTest extends AbstractTest { @@ -17,37 +16,13 @@ final class DecimalsTest extends AbstractTest { /** * Run test on a random invoice - * * @param string $schema FacturaE schema * @return boolean Success */ private function _runTest($schema) { - // Creamos una factura estándar - $fac = new Facturae($schema); - $fac->setNumber('EMP201712', '0003'); - $fac->setIssueDate('2017-12-01'); - $fac->setSeller(new FacturaeParty([ - "taxNumber" => "A00000000", - "name" => "Perico el de los Palotes S.A.", - "address" => "C/ Falsa, 123", - "postCode" => "12345", - "town" => "Madrid", - "province" => "Madrid" - ])); - $fac->setBuyer(new FacturaeParty([ - "isLegalEntity" => false, - "taxNumber" => "00000000A", - "name" => "Antonio", - "firstSurname" => "García", - "lastSurname" => "Pérez", - "address" => "Avda. Mayor, 7", - "postCode" => "54321", - "town" => "Madrid", - "province" => "Madrid" - ])); + $fac = $this->getBaseInvoice($schema); - // Añadimos elementos con importes aleatorios - $unitPriceTotal = 0; + // Add items with random values $pricePow = 10 ** self::PRICE_DECIMALS; $quantityPow = 10 ** self::QUANTITY_DECIMALS; $taxPow = 10 ** self::TAX_DECIMALS; @@ -55,7 +30,6 @@ private function _runTest($schema) { $unitPrice = mt_rand(1, $pricePow) / $pricePow; $quantity = mt_rand(1, $quantityPow*10) / $quantityPow; $specialTax = mt_rand(1, $taxPow*20) / $taxPow; - $unitPriceTotal += $unitPrice * $quantity; $fac->addItem(new FacturaeItem([ "name" => "Línea de producto #$i", "quantity" => $quantity, @@ -68,7 +42,7 @@ private function _runTest($schema) { ])); } - // Validamos los totales de la factura + // Validate invoice totals $invoiceXml = new \SimpleXMLElement($fac->export()); $invoiceXml = $invoiceXml->Invoices->Invoice[0]; diff --git a/tests/DiscountsTest.php b/tests/DiscountsTest.php index 4c790e6..568db67 100644 --- a/tests/DiscountsTest.php +++ b/tests/DiscountsTest.php @@ -3,46 +3,14 @@ use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaeItem; -use josemmo\Facturae\FacturaeParty; final class DiscountsTest extends AbstractTest { - /** - * Get base invoice - * @return Facturae Base invoice - */ - private function _getBaseInvoice() { - $fac = new Facturae(); - $fac->setNumber('EMP201712', '0003'); - $fac->setIssueDate('2017-12-01'); - $fac->setSeller(new FacturaeParty([ - "taxNumber" => "A00000000", - "name" => "Perico el de los Palotes S.A.", - "address" => "C/ Falsa, 123", - "postCode" => "12345", - "town" => "Madrid", - "province" => "Madrid" - ])); - $fac->setBuyer(new FacturaeParty([ - "isLegalEntity" => false, - "taxNumber" => "00000000A", - "name" => "Antonio", - "firstSurname" => "García", - "lastSurname" => "Pérez", - "address" => "Avda. Mayor, 7", - "postCode" => "54321", - "town" => "Madrid", - "province" => "Madrid" - ])); - return $fac; - } - - /** * Test invoice item discounts */ public function testItemDiscounts() { - $fac = $this->_getBaseInvoice(); + $fac = $this->getBaseInvoice(); $expectedGrossAmounts = []; // Add first item @@ -121,7 +89,7 @@ public function testItemDiscounts() { * Test general discounts */ public function testGeneralDiscounts() { - $fac = $this->_getBaseInvoice(); + $fac = $this->getBaseInvoice(); $fac->addItem('Test item', 100, 1, Facturae::TAX_IVA, 25); $fac->addDiscount('Half price', 50); $fac->addDiscount('5€ off', 5, false); diff --git a/tests/ExtensionsTest.php b/tests/ExtensionsTest.php index ef74597..fa8d136 100644 --- a/tests/ExtensionsTest.php +++ b/tests/ExtensionsTest.php @@ -3,7 +3,6 @@ use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaeCentre; -use josemmo\Facturae\FacturaeParty; use josemmo\Facturae\Tests\Extensions\DisclaimerExtension; final class ExtensionsTest extends AbstractTest { @@ -16,28 +15,7 @@ final class ExtensionsTest extends AbstractTest { */ public function testExtensions() { // Creamos una factura estándar - $fac = new Facturae(); - $fac->setNumber('EMP201712', '0003'); - $fac->setIssueDate('2017-12-01'); - $fac->setSeller(new FacturaeParty([ - "taxNumber" => "A00000000", - "name" => "Perico el de los Palotes S.A.", - "address" => "C/ Falsa, 123", - "postCode" => "12345", - "town" => "Madrid", - "province" => "Madrid" - ])); - $fac->setBuyer(new FacturaeParty([ - "isLegalEntity" => false, - "taxNumber" => "00000000A", - "name" => "Antonio", - "firstSurname" => "García", - "lastSurname" => "Pérez", - "address" => "Avda. Mayor, 7", - "postCode" => "54321", - "town" => "Madrid", - "province" => "Madrid" - ])); + $fac = $this->getBaseInvoice(); $fac->addItem("Línea de producto", 100, 1, Facturae::TAX_IVA, 10); // Obtener la extensión de FACeB2B y establecemos la entidad pública diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 37e3495..705a662 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -10,7 +10,6 @@ final class FacturaeTest extends AbstractTest { const FILE_PATH = self::OUTPUT_DIR . "/salida-*.xsig"; - const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; /** * Test Create Invoice @@ -195,7 +194,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $res = $fac->export($outputPath); // ... y validar la factura - $this->validateInvoiceXML($outputPath); + $this->validateInvoiceXML($outputPath, true); } @@ -211,58 +210,4 @@ public function invoicesProvider() { ]; } - - /** - * Validate Invoice XML - * - * @param string $path Invoice path - */ - private function validateInvoiceXML($path) { - // Prepare file to upload - if (function_exists('curl_file_create')) { - $postFile = curl_file_create($path); - } else { - $postFile = "@" . realpath($path); - } - - // Send upload request - $ch = curl_init(); - curl_setopt_array($ch, array( - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_URL => "http://plataforma.firma-e.com/VisualizadorFacturae/index2.jsp", - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => array( - "referencia" => $postFile, - "valContable" => "on", - "valFirma" => "on", - "aceptarCondiciones" => "on", - "submit" => "Siguiente" - ), - CURLOPT_COOKIEJAR => self::COOKIES_PATH - )); - $res = curl_exec($ch); - curl_close($ch); - if (strpos($res, "window.open('facturae.jsp'") === false) { - $this->expectException(UnexpectedValueException::class); - } - - // Fetch results - $ch = curl_init(); - curl_setopt_array($ch, array( - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_URL => "http://plataforma.firma-e.com/VisualizadorFacturae/facturae.jsp", - CURLOPT_COOKIEFILE => self::COOKIES_PATH - )); - $res = curl_exec($ch); - curl_close($ch); - - // Validate results - $this->assertNotEmpty($res); - $this->assertContains('euro_ok.png', $res, 'Invalid XML Format'); - $this->assertContains('>Nivel de Firma Válido<', $res, 'Invalid Signature'); - $this->assertContains('>XAdES_T<', $res, 'Invalid Timestamp'); - } - } diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php index cc15dbb..28395cc 100644 --- a/tests/PerformanceTest.php +++ b/tests/PerformanceTest.php @@ -2,7 +2,6 @@ namespace josemmo\Facturae\Tests; use josemmo\Facturae\Facturae; -use josemmo\Facturae\FacturaeParty; final class PerformanceTest extends AbstractTest { @@ -16,28 +15,7 @@ public function testPerformance() { $start = microtime(true); for ($i=0; $isetNumber('FAC201804', '123'); - $fac->setIssueDate('2018-04-01'); - $fac->setSeller(new FacturaeParty([ - "taxNumber" => "A00000000", - "name" => "Perico de los Palotes S.A.", - "address" => "C/ Falsa, 123", - "postCode" => "12345", - "town" => "Madrid", - "province" => "Madrid" - ])); - $fac->setBuyer(new FacturaeParty([ - "isLegalEntity" => false, - "taxNumber" => "00000000A", - "name" => "Antonio", - "firstSurname" => "García", - "lastSurname" => "Pérez", - "address" => "Avda. Mayor, 7", - "postCode" => "54321", - "town" => "Madrid", - "province" => "Madrid" - ])); + $fac = $this->getBaseInvoice(); $fac->addItem("Producto #$i", 20.14, 3, Facturae::TAX_IVA, 21); $fac->sign(self::CERTS_DIR . "/facturae.pfx", null, self::FACTURAE_CERT_PASS); $fac->export(); From 3cc836b0297a1e1c6bc92fb4816b86d34c3575c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:46:25 +0200 Subject: [PATCH 015/146] =?UTF-8?q?A=C3=B1adido=20test=20de=20autodecimale?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadido test para el autosolucionador de decimales --- tests/DecimalsTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php index 663628c..24526a6 100644 --- a/tests/DecimalsTest.php +++ b/tests/DecimalsTest.php @@ -71,4 +71,33 @@ public function testDecimals() { $this->assertEquals($totalCount, $successCount); } + + /** + * Test autofix decimals + */ + public function testAutofixDecimals() { + $fac = $this->getBaseInvoice(); + + // Add items + $amounts = [671, 69, 11.21]; + foreach ($amounts as $i=>$itemAmount) { + $fac->addItem(new FacturaeItem([ + "name" => "Tengo un importe peculiar #" . ($i+1), + "quantity" => $i+1, + "unitPrice" => $itemAmount / ($i+1), + "taxes" => [Facturae::TAX_IVA => 21] + ])); + } + + // Export invoice + $outputPath = self::OUTPUT_DIR . "/salida-autodecimales.xml"; + $fac->export($outputPath); + $invoiceXml = new \SimpleXMLElement(file_get_contents($outputPath)); + + // Validate invoice + $invoiceTotal = (float) $invoiceXml->Invoices->Invoice[0]->InvoiceTotals->InvoiceTotal; + $this->assertEquals($invoiceTotal, array_sum($amounts)); + $this->validateInvoiceXML($outputPath); + } + } From c3d8478c6c2b5468397451eb8038eb7f1f56521c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:47:13 +0200 Subject: [PATCH 016/146] =?UTF-8?q?Actualizada=20versi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5.1 --> v1.5.2 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 152a93e..b0649b4 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.1"; + const VERSION = "1.5.2"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 53626c8b8ab617f360bb6409c87c7cd313a21f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 6 Aug 2019 09:52:20 +0200 Subject: [PATCH 017/146] Renombrado getBaseInvoice en WS Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renombrado método "getBaseInvoice" a "getWsBaseInvoice" para evitar conflictos con la clase padre --- tests/WebservicesTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index 9188b52..efd261f 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -21,10 +21,10 @@ private function checkEnv() { /** - * Get base invoice + * Get Webservices base invoice * @return Facturae Invoice instance */ - private function getBaseInvoice() { + private function getWsBaseInvoice() { $fac = new Facturae(); $fac->setNumber('PRUEBA-' . date('ym'), date('Hms')); $fac->setIssueDate(date('Y-m-d')); @@ -61,7 +61,7 @@ public function testFace() { $this->assertFalse(empty($face->getNifs('E04921501')->nifs)); // Generate invoice - $fac = $this->getBaseInvoice(); + $fac = $this->getWsBaseInvoice(); $fac->setBuyer(new FacturaeParty([ "taxNumber" => "V28000024", "name" => "Banco de España", @@ -123,7 +123,7 @@ public function testFaceb2b() { $this->assertEquals(intval($faceb2b->getInvoiceCancellations()->resultStatus->code), 0); // Generate invoice - $fac = $this->getBaseInvoice(); + $fac = $this->getWsBaseInvoice(); $fac->setBuyer(new FacturaeParty([ "taxNumber" => "A78923125", "name" => "Teléfonica Móviles España, S.A.U.", From acd2da0cf083a34edf130599faa48374dde0aa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 10 Aug 2019 18:39:49 +0200 Subject: [PATCH 018/146] Actualizadas dependencias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadida la extensión Fileinfo a sugerencias --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 824bb53..ce9544b 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "suggest": { "ext-openssl": "For signing and timestamping both invoices and SOAP requests", "ext-curl": "For communicating with remote TSA Servers and SOAP Web Services", + "ext-fileinfo": "For getting MIME types when using FacturaeFile", "lib-libxml": "For parsing SOAP XML responses for FACe and FACeB2B" }, "require-dev": { From f5c9f843b4627e10a21d2f2dc3dcee4f5d780b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 22 Jan 2020 10:58:58 +0100 Subject: [PATCH 019/146] Actualizado ejemplo sin Composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidos "require_once" para las clases y traits omitidos --- doc/ejemplos/sin-composer.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/ejemplos/sin-composer.md b/doc/ejemplos/sin-composer.md index f49faa2..26fb0b3 100644 --- a/doc/ejemplos/sin-composer.md +++ b/doc/ejemplos/sin-composer.md @@ -9,6 +9,12 @@ permalink: /ejemplos/sin-composer.html Este ejemplo muestra cómo usar `Facturae-PHP` sin tener configurado un entorno de Composer, solo descargando el código fuente de la librería. ```php +require_once 'ruta/hacia/Facturae-PHP/src/Common/KeyPairReader.php'; +require_once 'ruta/hacia/Facturae-PHP/src/Common/XmlTools.php'; +require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/PropertiesTrait.php'; +require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/UtilsTrait.php'; +require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/SignableTrait.php'; +require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/ExportableTrait.php'; require_once 'ruta/hacia/Facturae-PHP/src/Facturae.php'; require_once 'ruta/hacia/Facturae-PHP/src/FacturaeCentre.php'; require_once 'ruta/hacia/Facturae-PHP/src/FacturaeItem.php'; From 16c1dc1917ad741c04348ad5730ae40d7eed8919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 22 Jan 2020 11:00:30 +0100 Subject: [PATCH 020/146] Actualizado Travis - Migrado proyecto de travis-ci.org a travis-ci.com - Cambiada matriz de versiones de PHP --- .travis.yml | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9eed56a..8f9c4f8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,9 +12,10 @@ matrix: - php: 5.6 - php: 7.0 - php: 7.1 - env: SEND_COVERAGE=1 TEST_WEBSERVICES=1 - php: 7.2 - php: 7.3 + env: SEND_COVERAGE=1 TEST_WEBSERVICES=1 + - php: 7.4 - php: nightly allow_failures: - php: nightly diff --git a/README.md b/README.md index 73203fd..2dfc884 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Facturae-PHP -[![Travis](https://travis-ci.org/josemmo/Facturae-PHP.svg?branch=master)](https://travis-ci.org/josemmo/Facturae-PHP) +[![Travis](https://travis-ci.com/josemmo/Facturae-PHP.svg?branch=master)](https://travis-ci.com/josemmo/Facturae-PHP) [![Codacy](https://api.codacy.com/project/badge/Grade/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) [![Coverage](https://api.codacy.com/project/badge/Coverage/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) [![Última versión estable](https://poser.pugx.org/josemmo/facturae-php/v/stable)](https://packagist.org/packages/josemmo/facturae-php) From 6e8ccfa67c09945a4b4a85da3e7758324f00170b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 22 Jan 2020 11:20:29 +0100 Subject: [PATCH 021/146] Solucionado implode en XmlTools implode(): Passing glue string after array is deprecated. Swap the parameters - Sintaxis incorrecta en `XmlTools::injectNamespaces()` que lanza warning en PHP 7.4 --- src/Common/XmlTools.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 3f2abdc..c0e7ea7 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -54,7 +54,7 @@ public function injectNamespaces($xml, $newNs) { $ns = array_merge($xmlns, $attributes); // Generate new XML element - $xml = $elementName . " " . implode($ns, " ") . ">" . $xml[1]; + $xml = $elementName . " " . implode(' ', $ns) . ">" . $xml[1]; return $xml; } From 73c16505176a3c696d66173f2147e366c714f6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 24 Jan 2020 11:01:29 +0100 Subject: [PATCH 022/146] =?UTF-8?q?Actualizada=20versi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5.2 --> v1.5.3 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index b0649b4..16a1456 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.2"; + const VERSION = "1.5.3"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 657cca79c80a07c5894ce73030b52f5587858ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 21 Nov 2020 10:38:21 +0100 Subject: [PATCH 023/146] Actualizado certificado webservices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sustituido certificado para webservices de FACe/FACeB2B al caducar el anterior > NOTA: el nuevo certificado pertenece al kit de pruebas de producción de IZENPE S.A., y es concretamente "representante_sw_act.p12" --- tests/AbstractTest.php | 2 +- tests/certs/webservices.p12 | Bin 3477 -> 5196 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 4033df6..1016cad 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -10,7 +10,7 @@ abstract class AbstractTest extends TestCase { const OUTPUT_DIR = __DIR__ . "/output"; const CERTS_DIR = __DIR__ . "/certs"; const FACTURAE_CERT_PASS = "12345"; - const WEBSERVICES_CERT_PASS = "G5cp,fYC9gje"; + const WEBSERVICES_CERT_PASS = "741258"; const NOTIFICATIONS_EMAIL = "josemmo@pm.me"; const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; diff --git a/tests/certs/webservices.p12 b/tests/certs/webservices.p12 index ee08ed7a42c56da8e9e911714b7ee7584df9b2fc..5b1d3e2482e0a0119170eb086305920fa7e06f98 100644 GIT binary patch literal 5196 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 literal 3477 zcmV;G4Qlc*f(?-Z0Ru3C4Oa#UDuzgg_YDCD0ic2nNCbinL@he(@qJvq0Bc+D>5KI8s-AaX$a0H+UG`9+)oo&uj_0G|zIV@!YUFid8KRS^QpgxzKOpbZ z{sWVuqC#N$P!EVfeLb|`{77ARYaXHgZ8!r~AI-zg_e?pn+ba^=CBwWrY`xAR900soWhYTLC?=50<46@5_Vw=zI?{`t}S8 z4;uhMYj|JdfZDv|s8fw`aP~HI1qmTbNXoCf7N`J%1<>^cR?o#G45%b>sI(QiA&m9F z-;Hv)UfQCgrJ(P?9oomkfo^(n5|>m1@>&fS^$ydevv|t>iL}`eoJDPGQ%s9VaSJ^( zKn@GC=~sLJj)2`R0f`T}+9A~Jd0~G~bYP4u3>eKHI1Rc_wqi}S=qokJ1fq(ZpZsSbFKSpXCQbbR(}lZf-O>}n9Wt#w4j}N z*zE^-@K*xh&p=+&GcupB2aDrrkO=E4QnZRxFwj_frpVPg;f<5|k@o*o+?y@6nOFgFYR& z1o7PS_oZId%o5Am25Hd6`Xxzp$`yz7Uc!%X`LrP{IFqzq1pk=x{>mIW@(t~ywGg*I z=j1X!2lf2YlidY<$QDB5l;AXpKo5&+s@`Mq`~GS*o=&lf#`u7LpW2VVy&r>%aJV?d z>Kul3Zo*gcrEZv~x@e-uibnwJ-LO>y?FeYk4_WLRH|87pT=wm0dl^tVZGV{A=ErI$ z&*9T;Sg<+_aYHPA*fQ%%A3_fubjTI|!)J+$#4!#xF-S5b*|SSw${`GfgELbfOo!AA~k%=4f__W6LYl5mEt5$l1DxD>F*y5Dm{^e!DBqb>t3??WCd*XF8&mn_ zPaOpln_^Qa;r!L~l37=(nw`tapMua5&u9@7LCn(txJADd%&-g#?Nhwk6)h2Js9+M~ zRMDQJ#*%J#&%T9SCZXb1T+Oop7xwgx?U8r{g*supeqARl)T~Mw*_B1VOf3>vr`A9y zb>;ySWw00v;zT>x%YoZ~LTaCxIfpgl-^_zoE|zJW!M!Si3Wz(4@Sj3C9os^MQ@iVe zTU|aCN&DB$x2l)S!mU9{MQ|0FKRs3#js4Xo@7`k*s>!iTh2~XUnCslo$ebQCeo*(U0rBD1&aMtcVh>*~Y9CiO zp2Zm0{5JvRJ;2W}dKvb3mAadbe@t+QlhE*q8tAHd2Z-?y3-7+Mn6FEz!K3KwGR+$b z1)M#r|DwL6%KstwsSDemXc;XFzy8IduN^6tdcq!n&~P`y=cpQgOMim)?6JQiGuRO8 zsCI?++;Yy{UR-{ohCl;vHrDP9Cle-b<@L08;YCeqT7&GkOcCSsOYn*GYA`;Ac0N!(~p zO9IN6$Y=L2osS4hWVPsOWNctb+@eU}!Uz9K2_{J9+g1Q8OrZEn;2E4}n> zk{l=Q*J`pzWSlJt=NMS?aXpZhm%|G{r3KdONFJ?ti&{AK5o$(AE9R-Cvu)+(CN+1T zS3h!Q&Hjq)5tJCv4ORjSK_2I(V&LNQUrcT>h;d)1o%bf6o>1xU;ld^?hfTnW z8J*w>%+@2#A+%>J4LpBkHA|>b(ndTta%u?7c=LXf008-CBI_t8vRWlRXIH2ry}=I+ zsIkqCn~J2I!q8zc|A1aKdh?BvvcBr*LeGfUJ#qH-n|$LsYN^DuLBaSnsArwy()v~{ zlFGAsLu=yfS{=Mm#+R#fnNtk-B!60|LK@%9rB_~{v_4UNhFEx6nbR-`Art@z zmtW5%SJk6PJ*}C~8_l4u>5U%Z;E3)L^Zx68U4Rm)0oThBvaM{hX^ZJcH2fLo_3zw; z+q7V|5l%>k;~RStjFx#?7?Y#Xfq4`@XgIU5+ z`+!|~WMK!?pqR3-ddSHXdfPG_EQ1^6KQ9(@N@OY|8h-;efQ&e8xFgGoF|Lfa#vXvl z^#K2P<92YAZp-C59`{3clk)5ow7k2KjA;-W_4Vjlra}M)UtAvzo_q=shI2?^3Oi3v zQwgY42ynZL6|ENu0ZIllm594?n_P3T!#xUTp}(UDsX9XQq z&492Imd0zd1pY4Sr|`-7f}U}t<>!8NfS$%ekBM046OdKc`o>iW4owP02`;Oi?fs{@ z+7;lM9Bg@dqUIO7M&4&k?O`}NS(?w+vYb09<{QtQ`~{;Qs$jA>*SP!4EHwjqj;x24 zLL&bEqff0QX6U3oCfb{9vtRJ?d&4<&H@M_q=A^(Md?(`X&tGt`gq6~~ea-o~Y@a0v z!BBkxXnHL!(lxDJKhF1MFOml+vz*06Q%xWFEoSm5I|H-LQpgOTfyg%x244`j_7KUa85GJ%RaHBKW4qiSZ1OuYllgm-^7^{vv3Iuq*j9zVTzc#c7cK)1wI(+j2QP%`)k zv*J{ZB>~3_Ig(vrdIdD0Rl+D!z9sO=EGAApPpn?9^@)atrf3Y>fZxM+F#u-N6RQ Date: Sun, 31 Jan 2021 10:42:03 +0100 Subject: [PATCH 024/146] Actualizado PHPUnit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado PHPUnit a última versión - Actualizado sistema de coverage - Actualizados tests a PHPUnit v9 --- .travis.yml | 6 +++--- composer.json | 3 +-- phpunit.xml | 22 +++++++++++----------- tests/AbstractTest.php | 6 +++--- tests/WebservicesTest.php | 3 +-- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8f9c4f8..8b35ec0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ matrix: - php: 7.1 - php: 7.2 - php: 7.3 - env: SEND_COVERAGE=1 TEST_WEBSERVICES=1 + env: SEND_COVERAGE=true TEST_WEBSERVICES=true - php: 7.4 - php: nightly allow_failures: @@ -30,8 +30,8 @@ install: # Run PHPUnit tests script: - - php vendor/bin/phpunit --coverage-clover build/coverage/xml + - XDEBUG_MODE=coverage vendor/bin/phpunit # Send coverage to Codacy (just once per build) after_success: - - if [ "$SEND_COVERAGE" == "1" ]; then php vendor/bin/codacycoverage clover build/coverage/xml; fi + - if [ "$SEND_COVERAGE" == "true" ]; then bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r build/clover.xml; fi diff --git a/composer.json b/composer.json index ce9544b..8e46f09 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,7 @@ "lib-libxml": "For parsing SOAP XML responses for FACe and FACeB2B" }, "require-dev": { - "codacy/coverage": "dev-master", - "phpunit/phpunit": "^5" + "phpunit/phpunit": "^9" }, "autoload-dev": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml index abd4c87..e7363dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,16 +1,16 @@ - + + + + + src + + + + + - + tests - - - - - - - src - - diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 1016cad..7c5fdf3 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -94,12 +94,12 @@ protected function validateInvoiceXML($path, $validateSignature=false) { // Validate results $this->assertNotEmpty($res); - $this->assertContains('euro_ok.png', $res, 'Invalid XML Format'); + $this->assertStringContainsString('euro_ok.png', $res, 'Invalid XML Format'); if ($validateSignature) { - $this->assertContains('>Nivel de Firma Válido<', $res, 'Invalid Signature'); + $this->assertStringContainsString('>Nivel de Firma Válido<', $res, 'Invalid Signature'); } if (strpos($res, '>Sellos de Tiempo<') !== false) { - $this->assertContains('>XAdES_T<', $res, 'Invalid Timestamp'); + $this->assertStringContainsString('>XAdES_T<', $res, 'Invalid Timestamp'); } } diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index efd261f..a4646b7 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -14,9 +14,8 @@ final class WebservicesTest extends AbstractTest { * Check environment */ private function checkEnv() { - $isCI = getenv('CI'); $testWS = getenv('TEST_WEBSERVICES'); - if ($isCI && !$testWS) $this->markTestSkipped('Environment conditions not met'); + if ($testWS !== "true") $this->markTestSkipped('TEST_WEBSERVICES is not true, skipping webservice tests'); } From e1f4d5709668e0ce67f455a586ee6e07c0fe78e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 31 Jan 2021 10:59:01 +0100 Subject: [PATCH 025/146] Soporte para PHP 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidos unset() después de curl_close() para forzar cerrar la conexión HTTP en PHP ^8.0 - Añadido badge a README.md - Actualizado .travis.yml --- .travis.yml | 13 +++++++------ README.md | 4 ++-- src/Face/SoapClient.php | 1 + src/FacturaeTraits/SignableTrait.php | 1 + tests/AbstractTest.php | 2 ++ 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8b35ec0..c312ffe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,13 +9,14 @@ notifications: matrix: fast_finish: true include: - - php: 5.6 - - php: 7.0 - - php: 7.1 - - php: 7.2 - - php: 7.3 + - php: '5.6' + - php: '7.0' + - php: '7.1' + - php: '7.2' + - php: '7.3' + - php: '7.4' env: SEND_COVERAGE=true TEST_WEBSERVICES=true - - php: 7.4 + - php: '8.0' - php: nightly allow_failures: - php: nightly diff --git a/README.md b/README.md index 2dfc884..cc2db32 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![Travis](https://travis-ci.com/josemmo/Facturae-PHP.svg?branch=master)](https://travis-ci.com/josemmo/Facturae-PHP) [![Codacy](https://api.codacy.com/project/badge/Grade/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) [![Coverage](https://api.codacy.com/project/badge/Coverage/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) -[![Última versión estable](https://poser.pugx.org/josemmo/facturae-php/v/stable)](https://packagist.org/packages/josemmo/facturae-php) -[![Licencia](https://poser.pugx.org/josemmo/facturae-php/license)](https://packagist.org/packages/josemmo/facturae-php) +[![Última versión estable](https://img.shields.io/packagist/v/josemmo/facturae-php)](https://packagist.org/packages/josemmo/facturae-php) +[![Versión de PHP](https://img.shields.io/badge/php-%3E%3D5.6%20%7C%7C%20%3E%3D7.0%20%7C%7C%20%3E%3D8.0-8892BF)](composer.json) [![Documentación](https://img.shields.io/badge/docs-online-blue.svg?longCache=true)](https://josemmo.github.io/Facturae-PHP/) Facturae-PHP es un paquete escrito puramente en PHP que permite generar facturas electrónicas siguiendo el formato estructurado [Facturae](http://www.facturae.gob.es/), **añadirlas firma electrónica** XAdES y sellado de tiempo, e incluso **enviarlas a FACe o FACeB2B** sin necesidad de ninguna librería o clase adicional. diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 301bb24..e6bc061 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -166,6 +166,7 @@ protected function request($body) { )); $res = curl_exec($ch); curl_close($ch); + unset($ch); // Parse response $xml = new \DOMDocument(); diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 5545637..e4bbbaa 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -261,6 +261,7 @@ private function injectTimestamp($signedXml) { $tsr = curl_exec($ch); if ($tsr === false) throw new \Exception('cURL error: ' . curl_error($ch)); curl_close($ch); + unset($ch); // Validate TimeStampRequest $responseCode = substr($tsr, 6, 3); diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 7c5fdf3..d0eb8f0 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -77,6 +77,7 @@ protected function validateInvoiceXML($path, $validateSignature=false) { )); $res = curl_exec($ch); curl_close($ch); + unset($ch); if (strpos($res, "window.open('facturae.jsp'") === false) { $this->expectException(\UnexpectedValueException::class); } @@ -91,6 +92,7 @@ protected function validateInvoiceXML($path, $validateSignature=false) { )); $res = curl_exec($ch); curl_close($ch); + unset($ch); // Validate results $this->assertNotEmpty($res); From 49e2a596a976db0072e7aa30d1b6810a6c43168b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 31 Jan 2021 11:31:20 +0100 Subject: [PATCH 026/146] =?UTF-8?q?A=C3=B1adido=20symfony/phpunit-bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplazado PHPUnit 9 con symfony/phpunit-bridge para poder hacer tests en PHP <7.3 --- .travis.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c312ffe..9122fc5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,7 +31,7 @@ install: # Run PHPUnit tests script: - - XDEBUG_MODE=coverage vendor/bin/phpunit + - XDEBUG_MODE=coverage vendor/bin/simple-phpunit # Send coverage to Codacy (just once per build) after_success: diff --git a/composer.json b/composer.json index 8e46f09..c6c04ea 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "lib-libxml": "For parsing SOAP XML responses for FACe and FACeB2B" }, "require-dev": { - "phpunit/phpunit": "^9" + "symfony/phpunit-bridge": "^5.2" }, "autoload-dev": { "psr-4": { From 1da6aed4bc2e06141e0f7285e58ec231a8ee42ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 31 Jan 2021 11:36:31 +0100 Subject: [PATCH 027/146] Arreglado DiscountsTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplazado método TestCase::assertEquals() por TestCase::assertEqualsWithDelta() --- tests/DiscountsTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/DiscountsTest.php b/tests/DiscountsTest.php index 568db67..de85b84 100644 --- a/tests/DiscountsTest.php +++ b/tests/DiscountsTest.php @@ -73,15 +73,15 @@ public function testItemDiscounts() { $itemGross = floatval($item->GrossAmount); $taxableBase = floatval($item->TaxesOutputs->Tax[0]->TaxableBase->TotalAmount); $expectedGross = array_shift($expectedGrossAmounts); - $this->assertEquals($itemGross, $expectedGross, '', 0.00001); - $this->assertEquals($taxableBase, $expectedGross, '', 0.00001); + $this->assertEqualsWithDelta($itemGross, $expectedGross, 0.00001); + $this->assertEqualsWithDelta($taxableBase, $expectedGross, 0.00001); } // Validate total amounts $totalGrossAmount = floatval($invoiceXml->InvoiceTotals->TotalGrossAmount); $totalTaxOutputs = floatval($invoiceXml->InvoiceTotals->TotalTaxOutputs); - $this->assertEquals(299, $totalGrossAmount, '', 0.00001); - $this->assertEquals(28, $totalTaxOutputs, '', 0.00001); + $this->assertEqualsWithDelta(299, $totalGrossAmount, 0.00001); + $this->assertEqualsWithDelta(28, $totalTaxOutputs, 0.00001); } @@ -104,9 +104,9 @@ public function testGeneralDiscounts() { $expectedDiscounts = (100 / 1.25) * 0.5 + 5; $expectedCharges = (100 / 1.25) * 0.5; $expectedTotal = 100 - $expectedDiscounts + $expectedCharges; - $this->assertEquals($totalDiscounts, $expectedDiscounts, '', 0.00001); - $this->assertEquals($totalCharges, $expectedCharges, '', 0.00001); - $this->assertEquals($invoiceTotal, $expectedTotal, '', 0.00001); + $this->assertEqualsWithDelta($totalDiscounts, $expectedDiscounts, 0.00001); + $this->assertEqualsWithDelta($totalCharges, $expectedCharges, 0.00001); + $this->assertEqualsWithDelta($invoiceTotal, $expectedTotal, 0.00001); } } From 1418d3d28ab5f912feae4f871fc26d9b2ab6ec50 Mon Sep 17 00:00:00 2001 From: peter279k Date: Fri, 5 Feb 2021 23:06:59 +0800 Subject: [PATCH 028/146] Test enhancement --- tests/InvoiceTest.php | 2 +- tests/MethodsTest.php | 4 ++-- tests/WebservicesTest.php | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 705a662..fa8e7f1 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -7,7 +7,7 @@ use josemmo\Facturae\FacturaeParty; use josemmo\Facturae\FacturaeCentre; -final class FacturaeTest extends AbstractTest { +final class InvoiceTest extends AbstractTest { const FILE_PATH = self::OUTPUT_DIR . "/salida-*.xsig"; diff --git a/tests/MethodsTest.php b/tests/MethodsTest.php index 163f2cc..9bf9a61 100644 --- a/tests/MethodsTest.php +++ b/tests/MethodsTest.php @@ -76,8 +76,8 @@ public function testMethods() { $fac->addCharge('First', 20); $fac->addCharge('Second', 25, false); $fac->addCharge('Third', 30); - $this->assertEquals(2, count($fac->getDiscounts())); - $this->assertEquals(3, count($fac->getCharges())); + $this->assertCount(2, $fac->getDiscounts()); + $this->assertCount(3, $fac->getCharges()); $fac->clearDiscounts(); $this->assertEquals([], $fac->getDiscounts()); $fac->clearCharges(); diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index a4646b7..ae3376b 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -54,10 +54,10 @@ public function testFace() { $face->setProduction(false); // Test misc. methods - $this->assertFalse(empty($face->getStatus()->estados)); - $this->assertFalse(empty($face->getAdministrations()->administraciones)); - $this->assertFalse(empty($face->getUnits('E04921501')->relaciones)); - $this->assertFalse(empty($face->getNifs('E04921501')->nifs)); + $this->assertNotEmpty($face->getStatus()->estados); + $this->assertNotEmpty($face->getAdministrations()->administraciones); + $this->assertNotEmpty($face->getUnits('E04921501')->relaciones); + $this->assertNotEmpty($face->getNifs('E04921501')->nifs); // Generate invoice $fac = $this->getWsBaseInvoice(); @@ -93,13 +93,13 @@ public function testFace() { $invoiceFile->loadData($fac->export(), "factura-de-prueba.xsig"); $res = $face->sendInvoice(self::NOTIFICATIONS_EMAIL, $invoiceFile); $this->assertEquals(intval($res->resultado->codigo), 0); - $this->assertFalse(empty($res->factura->numeroRegistro)); + $this->assertNotEmpty($res->factura->numeroRegistro); // Cancel invoice $res = $face->cancelInvoice($res->factura->numeroRegistro, "Factura de prueba autogenerada por " . Facturae::USER_AGENT); $this->assertEquals(intval($res->resultado->codigo), 0); - $this->assertFalse(empty($res->factura->mensaje)); + $this->assertNotEmpty($res->factura->mensaje); // Get invoice status $res = $face->getInvoices($res->factura->numeroRegistro); @@ -117,7 +117,7 @@ public function testFaceb2b() { $faceb2b->setProduction(false); // Test misc. methods - $this->assertFalse(empty($faceb2b->getCodes()->codes)); + $this->assertNotEmpty($faceb2b->getCodes()->codes); $this->assertEquals(intval($faceb2b->getRegisteredInvoices()->resultStatus->code), 0); $this->assertEquals(intval($faceb2b->getInvoiceCancellations()->resultStatus->code), 0); @@ -142,7 +142,7 @@ public function testFaceb2b() { $invoiceFile->loadData($fac->export(), "factura-de-prueba.xsig"); $res = $faceb2b->sendInvoice($invoiceFile); $this->assertEquals(intval($res->resultStatus->code), 0); - $this->assertFalse(empty($res->invoiceDetail->registryNumber)); + $this->assertNotEmpty($res->invoiceDetail->registryNumber); $registryNumber = $res->invoiceDetail->registryNumber; // Cancel invoice From d4e08577935aea1b5a3e2494bfc172044c503969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 7 Feb 2021 11:16:53 +0100 Subject: [PATCH 029/146] Sustituido Travis CI por GH Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creado workflow para tests - Eliminada configuración de Travis CI - Actualizada badge en README.md --- .github/workflows/tests.yml | 46 +++++++++++++++++++++++++++++++++++++ .travis.yml | 38 ------------------------------ README.md | 2 +- 3 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c6a7cd3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + pull_request: + schedule: + - cron: "0 12 * * 1" # Every Monday at 12:00 + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || false }} + strategy: + matrix: + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '8.0'] + include: + - php-version: '7.4' + test-ws: true + send-coverage: true + - php-version: '8.1' + experimental: true + steps: + # Download code from repository + - name: Checkout code + uses: actions/checkout@v2 + + # Setup PHP and Composer + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: xdebug + + # Run tests + - name: Run tests + env: + TEST_WEBSERVICES: ${{ !startsWith(github.ref, 'refs/pull/') && matrix.test-ws || false }} + run: vendor/bin/simple-phpunit + + # Send coverage + - name: Send coverage + if: ${{ !startsWith(github.ref, 'refs/pull/') && matrix.send-coverage }} + env: + CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} + run: bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r build/clover.xml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9122fc5..0000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Let's start with the basics: this is a PHP project -language: php - -# Disable email notifications for all builds -notifications: - email: false - -# Run on top of a few versions to check compatibility -matrix: - fast_finish: true - include: - - php: '5.6' - - php: '7.0' - - php: '7.1' - - php: '7.2' - - php: '7.3' - - php: '7.4' - env: SEND_COVERAGE=true TEST_WEBSERVICES=true - - php: '8.0' - - php: nightly - allow_failures: - - php: nightly - -# Prepare environment -before_install: - - if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then unset SEND_COVERAGE; unset TEST_WEBSERVICES; fi - -# Install Composer package before testing -install: - - composer install --no-interaction - -# Run PHPUnit tests -script: - - XDEBUG_MODE=coverage vendor/bin/simple-phpunit - -# Send coverage to Codacy (just once per build) -after_success: - - if [ "$SEND_COVERAGE" == "true" ]; then bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r build/clover.xml; fi diff --git a/README.md b/README.md index cc2db32..7a21bad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Facturae-PHP -[![Travis](https://travis-ci.com/josemmo/Facturae-PHP.svg?branch=master)](https://travis-ci.com/josemmo/Facturae-PHP) +[![CI](https://github.com/josemmo/Facturae-PHP/workflows/Tests/badge.svg)](https://github.com/josemmo/Facturae-PHP/actions) [![Codacy](https://api.codacy.com/project/badge/Grade/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) [![Coverage](https://api.codacy.com/project/badge/Coverage/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) [![Última versión estable](https://img.shields.io/packagist/v/josemmo/facturae-php)](https://packagist.org/packages/josemmo/facturae-php) From f2f02cbd8f3adefd79e562cd26a8b762f297fe2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 7 Feb 2021 11:19:00 +0100 Subject: [PATCH 030/146] Arreglado workflow tests - Instaladas dependencias de Composer antes de ejecutar tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c6a7cd3..a8f3364 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: - name: Run tests env: TEST_WEBSERVICES: ${{ !startsWith(github.ref, 'refs/pull/') && matrix.test-ws || false }} - run: vendor/bin/simple-phpunit + run: composer install && vendor/bin/simple-phpunit # Send coverage - name: Send coverage From 7e20cf9fffc9051f170a8ef24bf4a1f995699e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 7 Feb 2021 11:32:59 +0100 Subject: [PATCH 031/146] Arreglado coverage en CI - Movido reporting de coverage a PHP 8.0 --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a8f3364..ea2484a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,9 +13,9 @@ jobs: continue-on-error: ${{ matrix.experimental || false }} strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '8.0'] + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4'] include: - - php-version: '7.4' + - php-version: '8.0' test-ws: true send-coverage: true - php-version: '8.1' From 52c6d21ed597e53285202b4e71e298240f4f0c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 7 Feb 2021 11:36:32 +0100 Subject: [PATCH 032/146] v1.5.4 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 16a1456..780d351 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.3"; + const VERSION = "1.5.4"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 3bb8efd4524e4fd75b00709edbc705c7405ccdc7 Mon Sep 17 00:00:00 2001 From: Ricardo Augusto Date: Tue, 9 Mar 2021 21:57:51 +0000 Subject: [PATCH 033/146] Enable the possibility of Overseas adresses --- src/FacturaeParty.php | 49 ++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 3479ce5..1696b9f 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -96,13 +96,23 @@ public function getXML($schema) { break; } } - $xml .= '' . - '
' . $tools->escape($addressTarget->address) . '
' . - '' . $addressTarget->postCode . '' . - '' . $tools->escape($addressTarget->town) .'' . - '' . $tools->escape($addressTarget->province) . '' . - '' . $addressTarget->countryCode . '' . - '
'; + + if($addressTarget->countryCode=='ESP') { + $xml .= '' . + '
' . $tools->escape($addressTarget->address) . '
' . + '' . $addressTarget->postCode . '' . + '' . $tools->escape($addressTarget->town) . '' . + '' . $tools->escape($addressTarget->province) . '' . + '' . $addressTarget->countryCode . '' . + '
'; + } else { + $xml .= '' . + '
' . $tools->escape($addressTarget->address) . '
' . + '' . $addressTarget->postCode . ' ' . $tools->escape($addressTarget->town) . '' . + '' . $tools->escape($addressTarget->province) . '' . + '' . $addressTarget->countryCode . '' . + '
'; + } if (!is_null($centre->description)) { $xml .= '' . $tools->escape($centre->description) . ''; @@ -144,14 +154,22 @@ public function getXML($schema) { } // Add address - $xml .= '' . - '
' . $tools->escape($this->address) . '
' . - '' . $this->postCode . '' . - '' . $tools->escape($this->town) . '' . - '' . $tools->escape($this->province) . '' . - '' . $this->countryCode . '' . - '
'; - + if($this->countryCode=='ESP') { + $xml .= '' . + '
' . $tools->escape($this->address) . '
' . + '' . $this->postCode . '' . + '' . $tools->escape($this->town) . '' . + '' . $tools->escape($this->province) . '' . + '' . $this->countryCode . '' . + '
'; + } else { + $xml .= '' . + '
' . $tools->escape($this->address) . '
' . + '' . $this->postCode . ' ' . $tools->escape($this->town) . '' . + '' . $tools->escape($this->province) . '' . + '' . $this->countryCode . '' . + '
'; + } // Add contact details $xml .= $this->getContactDetailsXML(); @@ -204,3 +222,4 @@ private function getContactDetailsXML() { } } + From 2ffab2b4a9876b7514b6ebb811fc789d3fdc6a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 10 Mar 2021 11:44:33 +0100 Subject: [PATCH 034/146] =?UTF-8?q?A=C3=B1adido=20test=20para=20direccione?= =?UTF-8?q?s=20de=20fuera=20de=20Espa=C3=B1a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creado OverseasTest - Arreglado linting en FacturaeParty --- src/FacturaeParty.php | 2 +- tests/OverseasTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/OverseasTest.php diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 1696b9f..9173295 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -97,7 +97,7 @@ public function getXML($schema) { } } - if($addressTarget->countryCode=='ESP') { + if ($addressTarget->countryCode === "ESP") { $xml .= '' . '
' . $tools->escape($addressTarget->address) . '
' . '' . $addressTarget->postCode . '' . diff --git a/tests/OverseasTest.php b/tests/OverseasTest.php new file mode 100644 index 0000000..90a808d --- /dev/null +++ b/tests/OverseasTest.php @@ -0,0 +1,26 @@ +getBaseInvoice(); + $fac->getBuyer()->town = "Coimbra"; + $fac->getBuyer()->province = "Beira"; + $fac->getBuyer()->address = "Rua do Brasil 284"; + $fac->getBuyer()->postCode = "3030-775"; + $fac->getBuyer()->countryCode = "PRT"; + $fac->addItem("Línea de producto", 100, 1, Facturae::TAX_IVA, 21); + + $fac->export(self::FILE_PATH); + $this->validateInvoiceXML(self::FILE_PATH); + } + +} From 7e5c1df6fe365a80c2b734e833a55e97836fc5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 10 Mar 2021 11:46:56 +0100 Subject: [PATCH 035/146] Normalizado linting - Actualizado FacturaeParty --- src/FacturaeParty.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 9173295..93db675 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -154,7 +154,7 @@ public function getXML($schema) { } // Add address - if($this->countryCode=='ESP') { + if ($this->countryCode === "ESP") { $xml .= '' . '
' . $tools->escape($this->address) . '
' . '' . $this->postCode . '' . From 4eca4b7a486e7501d2ccc45be64f68dda142325b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 10 Mar 2021 11:55:14 +0100 Subject: [PATCH 036/146] Arreglado deprecated is_file(null) - Solucionado fallo en KeyPairReader::__construct() - Actualizado phpDoc de SignableTrait > is_file(): Passing null to parameter #1 ($filename) of type string is deprecated --- src/Common/KeyPairReader.php | 17 ++++++++++------- src/FacturaeTraits/SignableTrait.php | 10 +++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Common/KeyPairReader.php b/src/Common/KeyPairReader.php index 7606155..3629246 100644 --- a/src/Common/KeyPairReader.php +++ b/src/Common/KeyPairReader.php @@ -13,7 +13,7 @@ class KeyPairReader { /** * Get public key - * @return string Public Key + * @return string|null Public Key */ public function getPublicKey() { return $this->publicKey; @@ -22,7 +22,7 @@ public function getPublicKey() { /** * Get private key - * @return string Private Key + * @return string|null Private Key */ public function getPrivateKey() { return $this->privateKey; @@ -32,13 +32,16 @@ public function getPrivateKey() { /** * KeyPairReader constructor * - * @param string $publicPath Path to public key in PEM or PKCS#12 file - * @param string $privatePath Path to private key (null for PKCS#12) - * @param string $passphrase Private key passphrase + * @param string $publicPath Path to public key in PEM or PKCS#12 file + * @param string|null $privatePath Path to private key (null for PKCS#12) + * @param string $passphrase Private key passphrase */ public function __construct($publicPath, $privatePath=null, $passphrase="") { - if (is_null($privatePath)) $this->readPkcs12($publicPath, $passphrase); - $this->readX509($publicPath, $privatePath, $passphrase); + if (is_null($privatePath)) { + $this->readPkcs12($publicPath, $passphrase); + } else { + $this->readX509($publicPath, $privatePath, $passphrase); + } } diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index e4bbbaa..24009e6 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -41,11 +41,11 @@ public function setTimestampServer($server, $user=null, $pass=null) { /** * Sign - * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store - * @param string $privatePath Path to private key PEM file (should be null in case of PKCS#12) - * @param string $passphrase Private key passphrase - * @param array $policy Facturae sign policy - * @return boolean Success + * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store + * @param string|null $privatePath Path to private key PEM file (should be null in case of PKCS#12) + * @param string $passphrase Private key passphrase + * @param array $policy Facturae sign policy + * @return boolean Success */ public function sign($publicPath, $privatePath=null, $passphrase="", $policy=self::SIGN_POLICY_3_1) { // Generate random IDs From 8af3674fbcceaef3a2b2dd04e8524dd3e0f53358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 12 Mar 2021 12:15:07 +0100 Subject: [PATCH 037/146] =?UTF-8?q?Actualizada=20documentaci=C3=B3n=20cent?= =?UTF-8?q?ros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadido campo FacturaeCentre::countryCode anteriormente no documentado --- doc/entidades/centros-administrativos.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/entidades/centros-administrativos.md b/doc/entidades/centros-administrativos.md index a05d906..726abf5 100644 --- a/doc/entidades/centros-administrativos.md +++ b/doc/entidades/centros-administrativos.md @@ -19,13 +19,14 @@ $ayto = new FacturaeParty([ "province" => "Madrid", "centres" => [ new FacturaeCentre([ - "role" => FacturaeCentre::ROLE_GESTOR, - "code" => "L01281343", - "name" => "Intervención Municipal", - "address" => "Plaza de la Constitución, 1", - "postCode" => "28701", - "town" => "San Sebastián de los Reyes", - "province" => "Madrid" + "role" => FacturaeCentre::ROLE_GESTOR, + "code" => "L01281343", + "name" => "Intervención Municipal", + "address" => "Plaza de la Constitución, 1", + "postCode" => "28701", + "town" => "San Sebastián de los Reyes", + "province" => "Madrid", + "countryCode" => "ESP" // Se asume España si se omite ]), new FacturaeCentre([ "role" => FacturaeCentre::ROLE_TRAMITADOR, From 7d8a41a483ddd452f6bf5a38c859b7f2760d8807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 12 Mar 2021 12:17:26 +0100 Subject: [PATCH 038/146] v1.5.5 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 780d351..a6716fc 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.4"; + const VERSION = "1.5.5"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 0c821ed8229397ee9a82deb0a85ca67775b7bc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 30 Mar 2021 13:55:36 +0200 Subject: [PATCH 039/146] Implementado recargo de equivalencia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadida propiedad "surcharge" a FacturaeItem - Modificados PropertiesTrait y ExportableTrait --- src/FacturaeItem.php | 18 ++++++++++++------ src/FacturaeTraits/ExportableTrait.php | 24 ++++++++++++++++++------ src/FacturaeTraits/PropertiesTrait.php | 15 ++++++++++----- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 36c4307..05bc7a1 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -51,6 +51,9 @@ public function __construct($properties=array()) { if (!isset($tax['isWithheld'])) { // Get value by default $tax['isWithheld'] = Facturae::isWithheldTax($r); } + if (!isset($tax['surcharge'])) { + $tax['surcharge'] = 0; + } if ($tax['isWithheld']) { $this->taxesWithheld[$r] = $tax; } else { @@ -65,7 +68,7 @@ public function __construct($properties=array()) { $taxesPercent = 1; foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroupTag) { foreach ($this->{$taxesGroupTag} as $taxData) { - $rate = $taxData['rate'] / 100; + $rate = ($taxData['rate'] + $taxData['surcharge']) / 100; if ($i == 1) $rate *= -1; // In case of $taxesWithheld (2nd iteration) $taxesPercent += $rate; } @@ -133,17 +136,20 @@ public function getData($fac) { foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { foreach ($this->{$taxesGroup} as $type=>$tax) { $taxRate = $fac->pad($tax['rate'], 'Tax/Rate'); - $taxAmount = $grossAmount * ($taxRate / 100); - $taxAmount = $fac->pad($taxAmount, 'Tax/Amount'); + $surcharge = $fac->pad($tax['surcharge'], 'Tax/Surcharge'); + $taxAmount = $fac->pad($grossAmount * ($taxRate / 100), 'Tax/Amount'); + $surchargeAmount = $fac->pad($grossAmount * ($surcharge / 100), 'Tax/SurchargeAmount'); $addProps[$taxesGroup][$type] = array( "base" => $fac->pad($grossAmount, 'Tax/Base'), "rate" => $taxRate, - "amount" => $taxAmount + "surcharge" => $surcharge, + "amount" => $taxAmount, + "surchargeAmount" => $surchargeAmount ); if ($i == 1) { // In case of $taxesWithheld (2nd iteration) - $totalTaxesWithheld += $taxAmount; + $totalTaxesWithheld += $taxAmount + $surchargeAmount; } else { - $totalTaxesOutputs += $taxAmount; + $totalTaxesOutputs += $taxAmount + $surchargeAmount; } } } diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 7664956..b3a7d0c 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -108,17 +108,23 @@ public function export($filePath=null) { $xmlTag = ucfirst($taxesGroup); // Just capitalize variable name $xml .= "<$xmlTag>"; foreach ($totals[$taxesGroup] as $type=>$taxRows) { - foreach ($taxRows as $rate=>$tax) { + foreach ($taxRows as $tax) { $xml .= '' . '' . $type . '' . - '' . $this->pad($rate, 'Tax/Rate') . '' . + '' . $this->pad($tax['rate'], 'Tax/Rate') . '' . '' . '' . $this->pad($tax['base'], 'Tax/Base') . '' . '' . '' . '' . $this->pad($tax['amount'], 'Tax/Amount') . '' . - '' . - ''; + ''; + if ($tax['surcharge'] != 0) { + $xml .= '' . $this->pad($tax['surcharge'], 'Tax/Surcharge') . '' . + '' . + '' . $this->pad($tax['surchargeAmount'], 'Tax/SurchargeAmount') . '' . + ''; + } + $xml .= ''; } } $xml .= ""; @@ -222,8 +228,14 @@ public function export($filePath=null) { '' . '' . '' . $this->pad($tax['amount'], 'Tax/Amount') . '' . - '' . - ''; + ''; + if ($tax['surcharge'] != 0) { + $xml .= '' . $this->pad($tax['surcharge'], 'Tax/Surcharge') . '' . + '' . + '' . $this->pad($tax['surchargeAmount'], 'Tax/SurchargeAmount') . '' . + ''; + } + $xml .= ''; } $xml .= ""; } diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index eb460ed..a8c9525 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -584,14 +584,19 @@ public function getTotals() { if (!isset($totals[$taxGroup][$type])) { $totals[$taxGroup][$type] = array(); } - if (!isset($totals[$taxGroup][$type][$tax['rate']])) { - $totals[$taxGroup][$type][$tax['rate']] = array( + $taxKey = $tax['rate'] . ":" . $tax['surcharge']; + if (!isset($totals[$taxGroup][$type][$taxKey])) { + $totals[$taxGroup][$type][$taxKey] = array( "base" => 0, - "amount" => 0 + "rate" => $tax['rate'], + "surcharge" => $tax['surcharge'], + "amount" => 0, + "surchargeAmount" => 0 ); } - $totals[$taxGroup][$type][$tax['rate']]['base'] += $tax['base']; - $totals[$taxGroup][$type][$tax['rate']]['amount'] += $tax['amount']; + $totals[$taxGroup][$type][$taxKey]['base'] += $tax['base']; + $totals[$taxGroup][$type][$taxKey]['amount'] += $tax['amount']; + $totals[$taxGroup][$type][$taxKey]['surchargeAmount'] += $tax['surchargeAmount']; } } } From 1a080e2584166804bfd0e9fac7f56b55cd30e070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 30 Mar 2021 13:56:34 +0200 Subject: [PATCH 040/146] Test de recargo de equivalencia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadida línea de producto con rec. equiv. a InvoiceTest --- tests/InvoiceTest.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index fa8e7f1..26f28c8 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -145,6 +145,17 @@ public function testCreateInvoice($schemaVersion, $isPfx) { ) ])); + // Un producto con IVA con recargo de equivalencia e IRPF + $fac->addItem(new FacturaeItem([ + "name" => "Llevo IVA con recargo de equivalencia", + "quantity" => 1, + "unitPrice" => 10, + "taxes" => [ + Facturae::TAX_IVA => ["rate"=>21, "surcharge"=>5.2], + Facturae::TAX_IRPF => 19 + ] + ])); + // Para terminar, añadimos 3 bombillas LED con un coste de 6,50 € ... // ... pero con los impuestos NO INCLUÍDOS en el precio unitario $fac->addItem(new FacturaeItem([ @@ -191,7 +202,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // ... exportarlo a un archivo ... $isPfxStr = $isPfx ? "PKCS12" : "X509"; $outputPath = str_replace("*", "$schemaVersion-$isPfxStr", self::FILE_PATH); - $res = $fac->export($outputPath); + $fac->export($outputPath); // ... y validar la factura $this->validateInvoiceXML($outputPath, true); From 4b1b2951a61066035128f7c682ef99e3429f9435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 30 Mar 2021 13:57:29 +0200 Subject: [PATCH 041/146] =?UTF-8?q?Documentaci=C3=B3n=20recargo=20de=20equ?= =?UTF-8?q?ivalencia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renombrado apartado de "Múltiples impuestos" a "Impuestos - Añadida sección sobre recargo de equivalencia --- .../{multiples-impuestos.md => impuestos.md} | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) rename doc/productos/{multiples-impuestos.md => impuestos.md} (77%) diff --git a/doc/productos/multiples-impuestos.md b/doc/productos/impuestos.md similarity index 77% rename from doc/productos/multiples-impuestos.md rename to doc/productos/impuestos.md index 8b846c4..43773b0 100644 --- a/doc/productos/multiples-impuestos.md +++ b/doc/productos/impuestos.md @@ -1,11 +1,13 @@ --- -title: Múltiples impuestos +title: Impuestos parent: Líneas de producto nav_order: 1 -permalink: /productos/multiples-impuestos.html +permalink: /productos/impuestos.html --- -# Múltiples impuestos +# Impuestos + +## Múltiples impuestos Supongamos que se quieren añadir varios impuestos a una misma línea de producto. En este caso se deberá hacer uso de la API avanzada de productos de Facturae-PHP a través de la clase `FacturaeItem`: ```php // Vamos a añadir un producto utilizando la API avanzada @@ -44,9 +46,23 @@ $fac->addItem(new FacturaeItem([ "name" => "Llevo impuestos retenidos", "quantity" => 1, "unitPrice" => 10, - "taxes" => array( + "taxes" => [ Facturae::TAX_IVA => 21, Facturae::TAX_IE => ["rate"=>4, "isWithheld"=>true] - ) + ] +])); +``` + +## IVA con recargo de equivalencia +Para añadir un recargo de equivalencia al IVA ("equivalence surcharge" en inglés) se debe especificar el porcentaje de recargo dentro de la propiedad `surcharge`: +```php +$fac->addItem(new FacturaeItem([ + "name" => "Llevo IVA con recargo de equivalencia", + "quantity" => 1, + "unitPrice" => 10, + "taxes" => [ + Facturae::TAX_IVA => ["rate"=>21, "surcharge"=>5.2], + Facturae::TAX_IRPF => 19 + ] ])); ``` From c7e95cd73622aabc96de0320be869f336e997e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 1 Apr 2021 13:26:14 +0200 Subject: [PATCH 042/146] =?UTF-8?q?Arreglada=20firma=20electr=C3=B3nica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cambiado algoritmo de digest a SHA-512 - Añadido ObjectIdentifier para identificar el documento como factura electrónica - Invertido orden de propiedades en "X509IssuerName" > **Mensaje de error arreglado** > El certificado firmante no está recogido en los apartados a) ó c) del artículo 18 del Reglamento que está recogido en R.D. 1496/2003 del 28 de Noviembre --- src/Common/XmlTools.php | 8 ++++---- src/FacturaeTraits/SignableTrait.php | 20 ++++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index c0e7ea7..314f136 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -82,13 +82,13 @@ private function prettify($input) { /** - * Get digest + * Get digest in SHA-512 * @param string $input Input string * @param boolean $pretty Pretty Base64 response * @return string Digest */ public function getDigest($input, $pretty=false) { - return $this->toBase64(sha1($input, true), $pretty); + return $this->toBase64(hash("sha512", $input, true), $pretty); } @@ -109,13 +109,13 @@ public function getCert($publicKey, $pretty=true) { /** - * Get certificate digest + * Get certificate digest in SHA-512 * @param string $publicKey Public Key * @param boolean $pretty Pretty Base64 response * @return string Base64 Digest */ public function getCertDigest($publicKey, $pretty=false) { - $digest = openssl_x509_fingerprint($publicKey, "sha1", true); + $digest = openssl_x509_fingerprint($publicKey, "sha512", true); return $this->toBase64($digest, $pretty); } diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 24009e6..968bd6c 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -87,11 +87,11 @@ protected function injectSignature($xml) { // Prepare signed properties $signTime = is_null($this->signTime) ? time() : $this->signTime; $certData = openssl_x509_parse($this->publicKey); - $certIssuer = array(); + $certIssuer = []; foreach ($certData['issuer'] as $item=>$value) { - $certIssuer[] = $item . '=' . $value; + $certIssuer[] = "$item=$value"; } - $certIssuer = implode(',', $certIssuer); + $certIssuer = implode(',', array_reverse($certIssuer)); // Generate signed properties $prop = '' . + '' . '' . $tools->getCertDigest($this->publicKey) . '' . '' . '' . @@ -131,6 +131,9 @@ protected function injectSignature($xml) { '' . '' . 'Factura electrónica' . + '' . + 'urn:oid:1.2.840.10003.5.109.10' . + '' . 'text/xml' . '' . '' . @@ -171,21 +174,22 @@ protected function injectSignature($xml) { 'Type="http://uri.etsi.org/01903#SignedProperties" ' . 'URI="#Signature' . $this->signatureID . '-SignedProperties' . $this->signatureSignedPropertiesID . '">' . "\n" . - '' . + '' . '' . "\n" . '' . $propDigest . '' . "\n" . '' . "\n" . '' . "\n" . - '' . + '' . '' . "\n" . '' . $kInfoDigest . '' . "\n" . '' . "\n" . - '' . "\n" . + '' . "\n" . '' . "\n" . '' . '' . "\n" . '' . "\n" . - '' . + '' . '' . "\n" . '' . $documentDigest . '' . "\n" . '' . "\n" . From b868bb74bd0a67ff2eebb986e2093142325805bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 1 Apr 2021 15:51:05 +0200 Subject: [PATCH 043/146] =?UTF-8?q?A=C3=B1adida=20cadena=20de=20certificad?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modificado KeyPairReader para almacenar cadenas de certificados al leer bancos de PKCS#12 - Modificado XmlTools::getCert() - Modificado SignableTrait > El validador del Gobierno de España no tiene actualizados los certificados raíz de confianza de la FNMT, entre otros organismos. > En consecuencia, la validación de facturas firmadas con certificados expedidos recientemente falla. > La solución pasa por anexar en la factura la cadena de certificados completa. --- src/Common/KeyPairReader.php | 41 ++++++++++++++++++---------- src/Common/XmlTools.php | 19 ++++++------- src/FacturaeTraits/SignableTrait.php | 24 ++++++++-------- 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/Common/KeyPairReader.php b/src/Common/KeyPairReader.php index 3629246..1c14bf2 100644 --- a/src/Common/KeyPairReader.php +++ b/src/Common/KeyPairReader.php @@ -6,23 +6,31 @@ * file (X.509 certificate). */ class KeyPairReader { + private $publicChain = []; + private $privateKey = null; - private $publicKey; - private $privateKey; + + /** + * Get public certificates chain from child to top CA + * @return string[] Array of PEM strings + */ + public function getPublicChain() { + return $this->publicChain; + } /** * Get public key - * @return string|null Public Key + * @return string|null Certificate for the Public Key in PEM format */ public function getPublicKey() { - return $this->publicKey; + return empty($this->publicChain) ? null : $this->publicChain[0]; } /** * Get private key - * @return string|null Private Key + * @return \OpenSSLAsymmetricKey|resource|null Decrypted Private Key */ public function getPrivateKey() { return $this->privateKey; @@ -54,11 +62,15 @@ public function __construct($publicPath, $privatePath=null, $passphrase="") { */ private function readX509($publicPath, $privatePath, $passphrase) { if (!is_file($publicPath) || !is_file($privatePath)) return; - $this->publicKey = openssl_x509_read(file_get_contents($publicPath)); - $this->privateKey = openssl_pkey_get_private( - file_get_contents($privatePath), - $passphrase - ); + + // Validate and normalize public key + $publicKey = openssl_x509_read(file_get_contents($publicPath)); + if (empty($publicKey)) return; + openssl_x509_export($publicKey, $publicKeyPem); + $this->publicChain = array($publicKeyPem); + + // Decrypt private key + $this->privateKey = openssl_pkey_get_private(file_get_contents($privatePath), $passphrase); } @@ -69,10 +81,11 @@ private function readX509($publicPath, $privatePath, $passphrase) { * @param string $passphrase Password for unlocking the PKCS#12 file */ private function readPkcs12($certPath, $passphrase) { - if (!is_file($certPath)) return false; - if (openssl_pkcs12_read(file_get_contents($certPath), $certs, $passphrase)) { - $this->publicKey = openssl_x509_read($certs['cert']); - $this->privateKey = openssl_pkey_get_private($certs['pkey']); + if (!is_file($certPath)) return; + if (openssl_pkcs12_read(file_get_contents($certPath), $store, $passphrase)) { + $this->publicChain = array_merge(array($store['cert']), $store['extracerts']); + $this->privateKey = openssl_pkey_get_private($store['pkey']); + unset($store); } } diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 314f136..aef0730 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -94,17 +94,16 @@ public function getDigest($input, $pretty=false) { /** * Get certificate - * @param string $publicKey Public Key - * @param boolean $pretty Pretty Base64 response - * @return string Base64 Certificate + * @param string $pem Certificate for the public key in PEM format + * @param boolean $pretty Pretty Base64 response + * @return string Base64 Certificate */ - public function getCert($publicKey, $pretty=true) { - openssl_x509_export($publicKey, $publicPEM); - $publicPEM = str_replace("-----BEGIN CERTIFICATE-----", "", $publicPEM); - $publicPEM = str_replace("-----END CERTIFICATE-----", "", $publicPEM); - $publicPEM = str_replace("\n", "", str_replace("\r", "", $publicPEM)); - if ($pretty) $publicPEM = $this->prettify($publicPEM); - return $publicPEM; + public function getCert($pem, $pretty=true) { + $pem = str_replace("-----BEGIN CERTIFICATE-----", "", $pem); + $pem = str_replace("-----END CERTIFICATE-----", "", $pem); + $pem = str_replace("\n", "", str_replace("\r", "", $pem)); + if ($pretty) $pem = $this->prettify($pem); + return $pem; } diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 968bd6c..e9134b0 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -14,7 +14,7 @@ trait SignableTrait { protected $timestampServer = null; private $timestampUser = null; private $timestampPass = null; - private $publicKey = null; + private $publicChain = []; private $privateKey = null; /** @@ -61,13 +61,13 @@ public function sign($publicPath, $privatePath=null, $passphrase="", $policy=sel // Load public and private keys $reader = new KeyPairReader($publicPath, $privatePath, $passphrase); - $this->publicKey = $reader->getPublicKey(); + $this->publicChain = $reader->getPublicChain(); $this->privateKey = $reader->getPrivateKey(); $this->signPolicy = $policy; unset($reader); // Return success - return (!empty($this->publicKey) && !empty($this->privateKey)); + return (!empty($this->publicChain) && !empty($this->privateKey)); } @@ -78,7 +78,7 @@ public function sign($publicPath, $privatePath=null, $passphrase="", $policy=sel */ protected function injectSignature($xml) { // Make sure we have all we need to sign the document - if (empty($this->publicKey) || empty($this->privateKey)) return $xml; + if (empty($this->publicChain) || empty($this->privateKey)) return $xml; $tools = new XmlTools(); // Normalize document @@ -86,7 +86,7 @@ protected function injectSignature($xml) { // Prepare signed properties $signTime = is_null($this->signTime) ? time() : $this->signTime; - $certData = openssl_x509_parse($this->publicKey); + $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; foreach ($certData['issuer'] as $item=>$value) { $certIssuer[] = "$item=$value"; @@ -102,7 +102,7 @@ protected function injectSignature($xml) { '' . '' . '' . - '' . $tools->getCertDigest($this->publicKey) . '' . + '' . $tools->getCertDigest($this->publicChain[0]) . '' . '' . '' . '' . $certIssuer . '' . @@ -146,11 +146,13 @@ protected function injectSignature($xml) { $exponent = base64_encode($privateData['rsa']['e']); // Generate KeyInfo - $kInfo = '' . "\n" . - '' . "\n" . - '' . "\n" . $tools->getCert($this->publicKey) . '' . "\n" . - '' . "\n" . - '' . "\n" . + $kInfo = '' . "\n" . + '' . "\n"; + foreach ($this->publicChain as $pemCertificate) { + $kInfo .= '' . "\n" . $tools->getCert($pemCertificate) . '' . "\n"; + } + $kInfo .= '' . "\n" . + '' . "\n" . '' . "\n" . '' . "\n" . $modulus . '' . "\n" . '' . $exponent . '' . "\n" . From 93fb720a51adc250337ea2d28b2067ad8dd83220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 1 Apr 2021 15:53:27 +0200 Subject: [PATCH 044/146] Actualizado certificado para pruebas de firma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplazados certificado "facturae.pfx" y sus respectivos ficheros PEM - Actualizadas rutas y contraseñas en tests > El nuevo certificado a usar es "EIDAS_CERTIFICADO_PRUEBAS___99999999R", obtenido desde https://ws024.juntadeandalucia.es/ae/adminelec/areatecnica/afirma --- tests/AbstractTest.php | 2 +- tests/ExtensionsTest.php | 2 +- tests/InvoiceTest.php | 2 +- tests/PerformanceTest.php | 2 +- tests/certs/facturae-private.pem | 58 +++++++++++++-------------- tests/certs/facturae-public.pem | 66 ++++++++++++++++++++----------- tests/certs/facturae.p12 | Bin 0 -> 7084 bytes tests/certs/facturae.pfx | Bin 2677 -> 0 bytes 8 files changed, 75 insertions(+), 57 deletions(-) create mode 100644 tests/certs/facturae.p12 delete mode 100644 tests/certs/facturae.pfx diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index d0eb8f0..6201c00 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -9,7 +9,7 @@ abstract class AbstractTest extends TestCase { const OUTPUT_DIR = __DIR__ . "/output"; const CERTS_DIR = __DIR__ . "/certs"; - const FACTURAE_CERT_PASS = "12345"; + const FACTURAE_CERT_PASS = "1234"; const WEBSERVICES_CERT_PASS = "741258"; const NOTIFICATIONS_EMAIL = "josemmo@pm.me"; const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; diff --git a/tests/ExtensionsTest.php b/tests/ExtensionsTest.php index fa8d136..009942c 100644 --- a/tests/ExtensionsTest.php +++ b/tests/ExtensionsTest.php @@ -60,7 +60,7 @@ public function testExtensions() { $disclaimer->enable(); // Exportamos la factura - $fac->sign(self::CERTS_DIR . "/facturae.pfx", null, self::FACTURAE_CERT_PASS); + $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); $success = ($fac->export(self::FILE_PATH) !== false); $this->assertTrue($success); diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 26f28c8..eb62e65 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -192,7 +192,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // Ya solo queda firmar la factura ... if ($isPfx) { - $fac->sign(self::CERTS_DIR . "/facturae.pfx", null, self::FACTURAE_CERT_PASS); + $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); } else { $fac->sign(self::CERTS_DIR . "/facturae-public.pem", self::CERTS_DIR . "/facturae-private.pem", self::FACTURAE_CERT_PASS); diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php index 28395cc..65bdd79 100644 --- a/tests/PerformanceTest.php +++ b/tests/PerformanceTest.php @@ -17,7 +17,7 @@ public function testPerformance() { for ($i=0; $igetBaseInvoice(); $fac->addItem("Producto #$i", 20.14, 3, Facturae::TAX_IVA, 21); - $fac->sign(self::CERTS_DIR . "/facturae.pfx", null, self::FACTURAE_CERT_PASS); + $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); $fac->export(); } diff --git a/tests/certs/facturae-private.pem b/tests/certs/facturae-private.pem index a307303..2b03bdb 100644 --- a/tests/certs/facturae-private.pem +++ b/tests/certs/facturae-private.pem @@ -1,30 +1,30 @@ -----BEGIN ENCRYPTED PRIVATE KEY----- -MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQISLmiSlmfKI4CAggA -MBQGCCqGSIb3DQMHBAi9Dmna0ehssQSCBMhfw21A964jWlt4o2ZjCNS2OuIBDJkX -BwXD0XcYptiDF0W8L2YA0C+C4x6Ud76QOSQ9CPUvDmsyBq2IVz0IM2hP2Txx+YY9 -2Kq4BQOf34RWtW0ruLsQ9mmOPL5+CJjBVhOxQmFGzrRH6fAIYe7pOCpZ7vrri3di -oLmzuqkr43zvRYQ2oR2/WgWiZ1aVkPQl+3K88vd9uRd7fGiK8tGZgdRbTCCLPVfj -I9cF6fDt5hA9YnMAY02YAn2eR6JY9cvXqi9tL5102fH8kMAlSnww9f/qel1PszLk -D2dyBBF887T/YKryPQzOMXp4lp6ax1kJ+FEm9Nn32dv8kVyqClkXui12h5xSkkvI -vSfTxm5Oc0Z9ux3aAyW/TkaBr8aAjZITt27rBk9Qhe0kOfIZcLHMxesqx5/LokI7 -48Ya551g48DQPcy7ykCs7vUY7kVToYw1aCxNmq5LxsSnZfQK3ZcXzGQzEqoWorZL -IW5j+ShjW9C/JhWUJq5ks2Gq4xQO1I5diC/3bmaTrHPp5jglxZq5dD3yofzTd1fO -L31TqTyOaW4U4w9YrvAvx8kEbausGVB+cnD+XvCU0y5YonvX8rcM21MRbjQcvtDN -OgHp9upUfVsTRc/vyi6W8QC+0YlKo9lYTswbMIwA+hFIP8uFn4uZ4CAWOFm5+kFi -R7bsnbBi1L6BzeNNKoZxkM1D50xbKCQ2QxN8AJ/mQWlbuV4OZU8n14EkXWmWMJuC -ojv8Cj3yH7LMVhtINgz2s5mnkJxnLcyJx3qxP5cSB1rZRVebNXzO2bM9APQ8MejP -M7darKqlvaS3gvDWYI2fOWZUXIDqHSKV6akjpYGvw3YXeKd+rATbfE0+0+xavmva -8L6lYyu5VApxfkGi2i1UL/m0N5n0u9NbiykZNI5vCRQlhaifIw6SH6IQUoMvwkx0 -kmZsB2WglXyTtB6u+n+XR5cuqm2ZE3pU2fL7Ybos8424/kyIR8lFHKrL5HwDHqbO -wSxXtqPiR7foB0B7uzTQJpjRC+02YBtMplO3GTlfqbA2+RjTNBgUo7sNUvXdHAOC -O/Y0XkXYt0o460RfWfyFijCqCUxR+g6OfJvwU3wvFmpWTQGbUff5s2oJ5I5c4Ihq -MLbtuANJZ+N5UtsGk2LuaURk/LpMDN5mrB4EPJ+yVRSkcQreJeSVlyRhgfNdv1Qc -jSMlUW5I7VMzFCvOvlySBIz54XLrP38tyNIJjrI6vlnNRcsCrqUxz0XLDRlIbz3+ -TMGTqa535Yqzkvx/Hjc8P2wp1rAbzoHrOYTH7kMOLUEWhSfrald5f0c0WUHE+45n -cUoiNDayJFB1wuQ2zYlKxBdBNrSzLgEUCxnON4fDbcbWewixjvuwUoYzOpeeICbc -cHRq2uX9Ay6bJduXB+yIIGMv54Ow8F/cFhrb1CE+6lE24AuRdSonH5J59Txs2eoB -M8rQbMooAYz0a3C5uIrzTiVKBgLzP8G4yJraPinbXmnepiBhLp9h/JNmugAo8pur -1RVr8jOvQ9svi3DhPHFXSTmEil+rp3DXuxEL35Ejs9MEikIhCkpxeVtK7RDhUXWb -2A5g1oQlbcrIPRc1nvdeVNYQcYnKQWfLZz+pmmjzB+SvD+XZen6dAYy3EdkqqMMC -/vo= ------END ENCRYPTED PRIVATE KEY----- +MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIG4Kky+Gm/VICAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECLtOZxo5MtoqBIIEyGblv9lcZ4gh +9UMmH4DvngSuZQ8V4hboG3Eqd0YVpLs8r+/CXXh11J7f6EJKwaGKIxb9s7YI6t2u +KdtNhfv8KzBpl9yQx2LPtuyb4QUcrPuOMQ6I6AneKLyvDXWFUCRw3XM/1oqlzxjP +9G4RxEsxiLJuPaTPxhSAbi7hdm7RREXmL/4+gs1fxjk98bvDAmZDpjnxgEiXeZE1 +qOlvWm6MHL/Qo3kpbk6DKk3uqpcX12T6/LwDX35zktzu6BzrukvS5tvmZH0gFJYv ++YXllJPE5OAaxLBGPVhVOZGMs5Fe9adcr0dcTzJGc5hC25pnHIl39/aiiLQ0sKUQ +MkV/rV8ChoRiA++rlC6Pcm0ucco1pYnejCPaTPfvEL2GPWp5O4CU+UQL+X+hdmd2 +vAprvikEIxT3XjrKe+iQTRdZkwIcRjpFo6qvxN2lHhIQjAJE4U3gfnHE1tCAvrA/ +fP/ThSRGI268zw0MFx2hGELwUSbt5WXV6ajXg+tWP3FUgSy/amcxeezUUqiq4KMe +OPzKFSvekkfEQjVi5R/HxeLkCbJrhDXmxhtrLKRE47kdfNB8MSaufuhJJrstvCJ+ +7UWRWl6tvWpVB0oJPD8NXq+eV8Hv9onsuie/Vg2c13Sj2Gu21QHkIOAElvktFTi0 +kV/gj2DCPWZEi71fejpHKdQzBykbMaibzNcDqiVb0tElEo0QjZhjxc69tYgSSAY2 +zLMHybeA9ea/8S4MTC2A655X/5cgwQWRcAz8Iks4bBQLknjoIC9va8/K6JfScfxi +BTH6qYwP2YbkcgmP9s9QeqZqEdV+YKfuNOWHafRlvDBAagH4pmdN1OSl12ivkiF/ +cjt1KYTl3QO+3ABrHVNK59Np6O6KlMGgBbkqs6rmSZQ9QfracSIqgJF91iACBiZJ +uCU8q6HJJRj6If+XjGd+8VTQ+lORLjhgNXxb9KXpPNVayNUrwNKWHshgXvepXFEJ +fegQwzugFEyf0cRXWWc6zp/BOUdHk+g11QqwXXuRd1C7jLJd9N3DSfCRcBNDF1M0 +Mzk+1WuPewBoH18LNty6O/jxoxe7zpLT87A8FMeRMi+x8jBVN1sUxSPSPp2wyAuF +iOgEPjlsSHi3/Hxd8li5a7J/I9my8Y85Qyq13O4OiVohlXZA3UKaRBJS+ETs3BYY +j1aqS207Hzu7kCLlSFjW7s8QXpjUuJDkSIKLiKgn2Akge6Ay8FRFENfnd28OwYDP +QRAGXFjkqfPyTiL/CRO1fnC2tCN6HbddX02UDekg7RQCCkGHv14dz8TphovvgICf +MU+5zULnHSc7YNBsvKo8laFEZ7HZfdw8caxCxcdKptbLIPDAQIpVTwiRxOXL/dbh +/jB7nA5+aC1b0EYFd6XWoyxw1fglMrypKqOt6qSvpfl4nqGbrx102izjQCnUxNPd +yMSSrnhemoGMyYoC5DdlSyiTFFMpKkqb3rVNT+eyMtCPQDVIni7g9RKWlXyPe9Gi +vj+cvdzwnFa+LWAKJDDcCdsF9hN7I/xrHdYMzzMsIR1W0leQKmWvGbjS1LMDch/h +R2VJniGFkbYFkqUw3FfoTgSadvYQD30sG+R2rnBMuADpfLwmUpA90bLSYDdEI4kL +CQ7aDxgZUOrFC+FxxMcuKw== +-----END ENCRYPTED PRIVATE KEY----- \ No newline at end of file diff --git a/tests/certs/facturae-public.pem b/tests/certs/facturae-public.pem index a50f469..22dd397 100644 --- a/tests/certs/facturae-public.pem +++ b/tests/certs/facturae-public.pem @@ -1,25 +1,43 @@ -----BEGIN CERTIFICATE----- -MIIEMTCCAxmgAwIBAgIJAKeVepKRtaTpMA0GCSqGSIb3DQEBCwUAMIGuMQswCQYD -VQQGEwJFUzEQMA4GA1UECAwHU2Vnb3ZpYTEQMA4GA1UEBwwHU2Vnb3ZpYTEbMBkG -A1UECgwSRmFsc2UgY29tcGFueSBTLkwuMSAwHgYDVQQLDBdGYWxzZSBPcmdhbml6 -YXRpb24gVW5pdDEaMBgGA1UEAwwRRmFsc2UgQ29tbW9uIE5hbWUxIDAeBgkqhkiG -9w0BCQEWEWZhbHNlQGV4YW1wbGUuY29tMB4XDTE3MDkwNzE1MjU0MFoXDTE4MDkw -NzE1MjU0MFowga4xCzAJBgNVBAYTAkVTMRAwDgYDVQQIDAdTZWdvdmlhMRAwDgYD -VQQHDAdTZWdvdmlhMRswGQYDVQQKDBJGYWxzZSBjb21wYW55IFMuTC4xIDAeBgNV -BAsMF0ZhbHNlIE9yZ2FuaXphdGlvbiBVbml0MRowGAYDVQQDDBFGYWxzZSBDb21t -b24gTmFtZTEgMB4GCSqGSIb3DQEJARYRZmFsc2VAZXhhbXBsZS5jb20wggEiMA0G -CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDei4mMER6QAB0wenpbQmSnkpCbHyFE -8X3h/0XIMsBAkAdMfzI+LEoRJ07d3IJRHsCmR0D5+TFsXJERLATKu6MO8dtGSr6m -vnGr8jI+iZrc/E7pcTjXsYuLTp6wNNthWoDjShFDbL4LBXnsGwl2kNEFh3JE3ER+ -xqczUnw+MK2uSwfaSFu2shafkH2UAHXEnutm+cjFLW76qKlzdl9dIRublgz1EFMP -nmp3IKbiSzAV8LN6SBhD2l/MnY0BKm5dE0pi60h9bvOWTfnupPNTl96wueWTMgvM -1RGmIe0f6CsrDLfVJ0ytbf2/p2beat+9dbmfUocKYD1W50rsUcB6hIk5AgMBAAGj -UDBOMB0GA1UdDgQWBBTnigCZhH8V3DVgaGBKzqMzkXVZajAfBgNVHSMEGDAWgBTn -igCZhH8V3DVgaGBKzqMzkXVZajAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA -A4IBAQAnDSdnKoj8DHLsQPD5Oypl8AdopB9VZrFKIxiK7ZK5mcfDJMQ9vf/Kb+xU -E6GxJo68ynaOQ37tYdDkMRVdnms5oy0k+wMTAMUc0FynORP5sTdHwmrO69/HOw/C -PjuM3+iEIPCxjP5UwRa4XnjzwNtePDv7R3JnihkLw+eSm0+nm+K0VKZ4hxvJvIgC -YX5E+/aezHUcMvXgV0+hPR3oYQFY20Yde4XGP54L95QCHR9jbX5KCHWLs9hizcH7 -M1lpEioEj3X9R0STz5Viw5wHc0N7BAC1iZd1G3T+aK/WejZvO1/lxzghEku4b/SK -RTQne68i20CYdE8lABGspUlFUKp5 ------END CERTIFICATE----- +MIIHhjCCBm6gAwIBAgIQSOSlyjvRFUlfo/hUFNAvqDANBgkqhkiG9w0BAQsFADBL +MQswCQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVz +MRkwFwYDVQQDDBBBQyBGTk1UIFVzdWFyaW9zMB4XDTIwMTEwNTEzMDQyMFoXDTI0 +MTEwNTEzMDQyMFowgYUxCzAJBgNVBAYTAkVTMRgwFgYDVQQFEw9JRENFUy05OTk5 +OTk5OVIxEDAOBgNVBCoMB1BSVUVCQVMxGjAYBgNVBAQMEUVJREFTIENFUlRJRklD +QURPMS4wLAYDVQQDDCVFSURBUyBDRVJUSUZJQ0FETyBQUlVFQkFTIC0gOTk5OTk5 +OTlSMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAujAnB2L5X2Bm42S5 +f/axKFu1QsAcZGJAeYELZZJ04jriBu3E8V3Rus3tUxfQ+ylqBm0bNWgHfP+gekos +HaYoJNQmAVBuwpd183uHksTRUtbeOAFS2xd7v29stM7ARkec+WVV+SK8G6HECIB0 +VIAMoB2tVs0y6XRVRcjE4I7kH1h3ZbMIzvW43B4hxruYtXcvozGwvZpxQKVrjEY8 +IXH5+aXHM8WLCba4I06FyhvI+2/9WUPN2YvDoml7lQM4edgepTEZifq2ZPHGpCC5 +NhSXj2ab5FtnGTMgUaWH6tCljT0kOdfJBOHnIWOw4dBdgkik2CuxwGyMrq/P5VqQ +IC2hXQIDAQABo4IEKTCCBCUwgZIGA1UdEQSBijCBh4Edc29wb3J0ZV90ZWNuaWNv +X2NlcmVzQGZubXQuZXOkZjBkMRgwFgYJKwYBBAGsZgEEDAk5OTk5OTk5OVIxGjAY +BgkrBgEEAaxmAQMMC0NFUlRJRklDQURPMRQwEgYJKwYBBAGsZgECDAVFSURBUzEW +MBQGCSsGAQQBrGYBAQwHUFJVRUJBUzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE +AwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDBAYIKwYBBQUHAwIwHQYDVR0OBBYEFE5a +HiQQRwVYJzmmkfG/i5MxmMNdMB8GA1UdIwQYMBaAFLHUT8QjefpEBQnG6znP6DWw +uCBkMIGCBggrBgEFBQcBAQR2MHQwPQYIKwYBBQUHMAGGMWh0dHA6Ly9vY3NwdXN1 +LmNlcnQuZm5tdC5lcy9vY3NwdXN1L09jc3BSZXNwb25kZXIwMwYIKwYBBQUHMAKG +J2h0dHA6Ly93d3cuY2VydC5mbm10LmVzL2NlcnRzL0FDVVNVLmNydDCCARUGA1Ud +IASCAQwwggEIMIH6BgorBgEEAaxmAwoBMIHrMCkGCCsGAQUFBwIBFh1odHRwOi8v +d3d3LmNlcnQuZm5tdC5lcy9kcGNzLzCBvQYIKwYBBQUHAgIwgbAMga1DZXJ0aWZp +Y2FkbyBjdWFsaWZpY2FkbyBkZSBmaXJtYSBlbGVjdHLDs25pY2EuIFN1amV0byBh +IGxhcyBjb25kaWNpb25lcyBkZSB1c28gZXhwdWVzdGFzIGVuIGxhIERQQyBkZSBs +YSBGTk1ULVJDTSBjb24gTklGOiBRMjgyNjAwNC1KIChDL0pvcmdlIEp1YW4gMTA2 +LTI4MDA5LU1hZHJpZC1Fc3Bhw7FhKTAJBgcEAIvsQAEAMIG6BggrBgEFBQcBAwSB +rTCBqjAIBgYEAI5GAQEwCwYGBACORgEDAgEPMBMGBgQAjkYBBjAJBgcEAI5GAQYB +MHwGBgQAjkYBBTByMDcWMWh0dHBzOi8vd3d3LmNlcnQuZm5tdC5lcy9wZHMvUERT +QUNVc3Vhcmlvc19lcy5wZGYTAmVzMDcWMWh0dHBzOi8vd3d3LmNlcnQuZm5tdC5l +cy9wZHMvUERTQUNVc3Vhcmlvc19lbi5wZGYTAmVuMIG1BgNVHR8Ega0wgaowgaeg +gaSggaGGgZ5sZGFwOi8vbGRhcHVzdS5jZXJ0LmZubXQuZXMvY249Q1JMMzc0OCxj +bj1BQyUyMEZOTVQlMjBVc3VhcmlvcyxvdT1DRVJFUyxvPUZOTVQtUkNNLGM9RVM/ +Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDtiaW5hcnk/YmFzZT9vYmplY3RjbGFz +cz1jUkxEaXN0cmlidXRpb25Qb2ludDANBgkqhkiG9w0BAQsFAAOCAQEAH4t5/v/S +Lsm/dXRDw4QblCmTX+5pgXJ+4G1Lb3KTSPtDJ0UbQiAMUx+iqDDOoMHU5H7po/HZ +LJXgNwvKLoiLbl5/q6Mqasif87fa6awNkuz/Y6dvXw0UOJh+Ud/Wrk0EyaP9ZtrL +VsraUOobNyS6g+lOrCxRrNxGRK2yAeotO6LEo1y3b7CB+Amd2jDq8lY3AtCYlrhu +CaTf0AD9IBYYmigHzFD/VH5a8uG95l6J85FQG7tMsG6UQHFM2EmNhpbrYH+ihetz +3UhzcC5Fd/P1X7pGBymQgbCyBjCRf/HEVzyoHL72uMp2I4JXX4v8HABZT8xtlDY4 +LE0am9keJhaNcg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/certs/facturae.p12 b/tests/certs/facturae.p12 new file mode 100644 index 0000000000000000000000000000000000000000..b60d4f08c8ddf207d7ed2455872c465c408282c3 GIT binary patch literal 7084 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 BVOIbE literal 0 HcmV?d00001 diff --git a/tests/certs/facturae.pfx b/tests/certs/facturae.pfx deleted file mode 100644 index 97e39d345409197128a2edc636249430c3172ea4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2677 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 From 7ad26f1d1d3568ae7c426647b7167d5dbe418952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 1 Apr 2021 16:19:02 +0200 Subject: [PATCH 045/146] Arreglado lectura de PKCS - Modificado KeyPairReader para leer "extracerts" solo si existen --- src/Common/KeyPairReader.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Common/KeyPairReader.php b/src/Common/KeyPairReader.php index 1c14bf2..c6c551c 100644 --- a/src/Common/KeyPairReader.php +++ b/src/Common/KeyPairReader.php @@ -83,7 +83,10 @@ private function readX509($publicPath, $privatePath, $passphrase) { private function readPkcs12($certPath, $passphrase) { if (!is_file($certPath)) return; if (openssl_pkcs12_read(file_get_contents($certPath), $store, $passphrase)) { - $this->publicChain = array_merge(array($store['cert']), $store['extracerts']); + $this->publicChain = array($store['cert']); + if (!empty($store['extracerts'])) { + $this->publicChain = array_merge($this->publicChain, $store['extracerts']); + } $this->privateKey = openssl_pkey_get_private($store['pkey']); unset($store); } From 0535344ecabbe387de08057085b4f8e4fa889bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 1 Apr 2021 17:19:15 +0200 Subject: [PATCH 046/146] Arreglada firma en webservices - Cambiado algoritmo de digest a SHA-512 en SoapClient --- src/Common/XmlTools.php | 2 +- src/Face/SoapClient.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index aef0730..a531194 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -120,7 +120,7 @@ public function getCertDigest($publicKey, $pretty=false) { /** - * Get signature + * Get signature in SHA-1 * @param string $payload Data to sign * @param string $privateKey Private Key * @param boolean $pretty Pretty Base64 response diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index e6bc061..12918f7 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -117,11 +117,11 @@ protected function request($body) { '' . '' . '' . - '' . + '' . '' . $timestampDigest . '' . '' . '' . - '' . + '' . '' . $bodyDigest . '' . '' . ''; From 6db763c3f0734bb8a5d6b759a84bb878dc11cb7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 10 Apr 2021 11:04:20 +0200 Subject: [PATCH 047/146] Actualizado CONTRIBUTING.md - Reemplazado Travis CI --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80a279f..2bf9d08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ ## Notas importantes antes de contribuir - - Todos los *pull requests* a la rama `develop`, nunca a `master` - - Las contribuciones que hagan fallar a Travis serán rechazadas +- Todos los *pull requests* a la rama `develop`, nunca a `master` +- Las contribuciones que hagan fallar los tests unitarios serán rechazadas From e5d22b71680d1caf9b9b354234283af653c0e4fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 10 Apr 2021 11:05:14 +0200 Subject: [PATCH 048/146] =?UTF-8?q?Documentaci=C3=B3n=20firma=20con=20PKCS?= =?UTF-8?q?#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadida nota sobre cadena de certificados en documentación de firma electrónica --- doc/firma-electronica/firma-electronica.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md index a4f9438..59c1e0c 100644 --- a/doc/firma-electronica/firma-electronica.md +++ b/doc/firma-electronica/firma-electronica.md @@ -31,6 +31,12 @@ Desde la versión 1.0.5 de Facturae-PHP ya es posible cargar un banco de certifi $fac->sign("certificado.pfx", null, "passphrase"); ``` +> #### NOTA +> Al utilizar un banco PKCS#12, Facturae-PHP incluirá la cadena completa de certificados en la factura al firmarla. +> +> Aunque en la mayoría de los casos esto no supone ninguna diferencia con respecto a firmar desde ficheros PEM, el validador del Gobierno de España **presenta problemas para verificar firmas de certificados expedidos recientemente por la FNMT**. +> Dicho problema se soluciona cuando se incluyen los certificados raíz e intermedios de la Entidad de Certificación, por lo que es recomendable usar este método de firma con Facturae-PHP. + --- ## Fecha de la firma From e31581772720545b544f32d75706bcea4c4a2119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 10 Apr 2021 11:30:37 +0200 Subject: [PATCH 049/146] =?UTF-8?q?A=C3=B1adida=20cesi=C3=B3n=20de=20cr?= =?UTF-8?q?=C3=A9dito?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidos setters y getters para "assignee" y "assignmentClauses" - Actualizado ExportableTrait - Añadida documentación - Actualizado InvoiceTest > Relacionado con #71 --- doc/entidades/cesionarios.md | 31 +++++++++++ src/FacturaeTraits/ExportableTrait.php | 77 +++++++++++++++++--------- src/FacturaeTraits/PropertiesTrait.php | 42 ++++++++++++++ tests/InvoiceTest.php | 19 +++++-- 4 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 doc/entidades/cesionarios.md diff --git a/doc/entidades/cesionarios.md b/doc/entidades/cesionarios.md new file mode 100644 index 0000000..3f2919c --- /dev/null +++ b/doc/entidades/cesionarios.md @@ -0,0 +1,31 @@ +--- +title: Cesionarios +parent: Entidades +nav_order: 3 +permalink: /entidades/cesionarios.html +--- + +# Cesionarios +Un caso de uso contemplado en el formato FacturaE y admitido por FACe para tratar con entidades de la Administración Pública es la cesión de crédito, en las que se cede el crédito de una factura a una entidad tercera (el cesionario). + +De acuerdo al [BOE-A-2019-13633](https://www.boe.es/eli/es/res/2019/09/17/(1)/con) se deberán incluir adicionalmente los datos del cesionario (representado dentro de Facturae-PHP como una instancia de `FacturaeParty`) y la cláusula de cesión: +```php +$fac->setAssignee(new FacturaeParty([ + "taxNumber" => "B00000000", + "name" => "Cesionario S.L.", + "address" => "C/ Falsa, 123", + "postCode" => "02001", + "town" => "Albacete", + "province" => "Albacete", + "phone" => "967000000", + "fax" => "967000001", + "email" => "cesionario@ejemplo.com" +])); +$fac->setAssignmentClauses('Cláusula de cesión'); +``` + +Además, para cumplir con la especificación, es necesario establecer los datos relativos al pago tal y como se explica en [este apartado](../propiedades/datos-del-pago.md): +```php +$fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766", "CAHMESMM"); +$fac->setDueDate("2017-12-31"); +``` diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index b3a7d0c..a282b8e 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -45,10 +45,10 @@ public function export($filePath=null) { $xml = ''; $totals = $this->getTotals(); + $paymentDetailsXML = $this->getPaymentDetailsXML($totals); // Add header - $batchIdentifier = $this->parties['seller']->taxNumber . - $this->header['number'] . $this->header['serie']; + $batchIdentifier = $this->parties['seller']->taxNumber . $this->header['number'] . $this->header['serie']; $xml .= '' . '' . $this->version .'' . 'I' . @@ -66,8 +66,23 @@ public function export($filePath=null) { '' . $totals['invoiceAmount'] . '' . '' . '' . $this->currency . '' . - '' . - ''; + ''; + + // Add factoring assignment data + if (!is_null($this->parties['assignee'])) { + $xml .= ''; + $xml .= '' . $this->parties['assignee']->getXML($this->version) . ''; + $xml .= $paymentDetailsXML; + if (!is_null($this->header['assignmentClauses'])) { + $xml .= '' . + $tools->escape($this->header['assignmentClauses']) . + ''; + } + $xml .= ''; + } + + // Close header + $xml .= ''; // Add parties $xml .= '' . @@ -252,29 +267,7 @@ public function export($filePath=null) { $xml .= ''; // Add payment details - if (!is_null($this->header['paymentMethod'])) { - $dueDate = is_null($this->header['dueDate']) ? - $this->header['issueDate'] : - $this->header['dueDate']; - $xml .= ''; - $xml .= ''; - $xml .= '' . date('Y-m-d', $dueDate) . ''; - $xml .= '' . $totals['invoiceAmount'] . ''; - $xml .= '' . $this->header['paymentMethod'] . ''; - if (!is_null($this->header['paymentIBAN'])) { - $accountType = ($this->header['paymentMethod'] == self::PAYMENT_DEBIT) ? - "AccountToBeDebited" : - "AccountToBeCredited"; - $xml .= "<$accountType>"; - $xml .= '' . $this->header['paymentIBAN'] . ''; - if (!is_null($this->header['paymentBIC'])) { - $xml .= '' . $this->header['paymentBIC'] . ''; - } - $xml .= ""; - } - $xml .= ''; - $xml .= ''; - } + $xml .= $paymentDetailsXML; // Add legal literals if (count($this->legalLiterals) > 0) { @@ -305,6 +298,36 @@ public function export($filePath=null) { } + /** + * Get payment details XML + * @param array $totals Invoice totals + * @return string Payment details XML, empty string if not available + */ + private function getPaymentDetailsXML($totals) { + if (is_null($this->header['paymentMethod'])) return ""; + + $dueDate = is_null($this->header['dueDate']) ? $this->header['issueDate'] : $this->header['dueDate']; + $xml = ''; + $xml .= ''; + $xml .= '' . date('Y-m-d', $dueDate) . ''; + $xml .= '' . $totals['invoiceAmount'] . ''; + $xml .= '' . $this->header['paymentMethod'] . ''; + if (!is_null($this->header['paymentIBAN'])) { + $accountType = ($this->header['paymentMethod'] == self::PAYMENT_DEBIT) ? "AccountToBeDebited" : "AccountToBeCredited"; + $xml .= "<$accountType>"; + $xml .= '' . $this->header['paymentIBAN'] . ''; + if (!is_null($this->header['paymentBIC'])) { + $xml .= '' . $this->header['paymentBIC'] . ''; + } + $xml .= ""; + } + $xml .= ''; + $xml .= ''; + + return $xml; + } + + /** * Get additional data XML * @return string Additional data XML diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index a8c9525..2b367c6 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -20,6 +20,7 @@ trait PropertiesTrait { "dueDate" => null, "startDate" => null, "endDate" => null, + "assignmentClauses" => null, "paymentMethod" => null, "paymentIBAN" => null, "paymentBIC" => null, @@ -31,6 +32,7 @@ trait PropertiesTrait { "additionalInformation" => null ); protected $parties = array( + "assignee" => null, "seller" => null, "buyer" => null ); @@ -70,6 +72,46 @@ public function getSchemaVersion() { } + /** + * Set assignee + * @param FacturaeParty $assignee Assignee information + * @return Facturae Invoice instance + */ + public function setAssignee($assignee) { + $this->parties['assignee'] = $assignee; + return $this; + } + + + /** + * Get assignee + * @return FacturaeParty|null Assignee information + */ + public function getAssignee() { + return $this->parties['assignee']; + } + + + /** + * Set assignment clauses + * @param string $clauses Assignment clauses + * @return Facturae Invoice instance + */ + public function setAssignmentClauses($clauses) { + $this->header['assignmentClauses'] = $clauses; + return $this; + } + + + /** + * Get assignment clauses + * @return string|null Assignment clauses + */ + public function getAssignmentClauses() { + return $this->header['assignmentClauses']; + } + + /** * Set seller * @param FacturaeParty $seller Seller information diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index eb62e65..c46f84d 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -174,10 +174,21 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $fac->addDiscount('A mitad de precio', 50); $fac->addCharge('Recargo del 50%', 50); - // Establecemos un método de pago (por coverage, solo en algunos casos) - if (!$isPfx) { - $fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, - "ES7620770024003102575766", "CAHMESMM"); + // Establecemos un método de pago con cesión de crédito (solo en algunos casos) + if ($isPfx) { + $fac->setAssignee(new FacturaeParty([ + "taxNumber" => "B00000000", + "name" => "Cesionario S.L.", + "address" => "C/ Falsa, 321", + "postCode" => "02001", + "town" => "Albacete", + "province" => "Albacete", + "phone" => "967000000", + "fax" => "967000001", + "email" => "cesionario@ejemplo.com" + ])); + $fac->setAssignmentClauses('Cláusula de cesión'); + $fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766", "CAHMESMM"); $fac->setDueDate("2017-12-31"); } From 37caccee1b3a45449a08a779d3f790d07c4d9587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 12 Apr 2021 20:08:52 +0200 Subject: [PATCH 050/146] v1.6.0 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index a6716fc..31bc614 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.5.5"; + const VERSION = "1.6.0"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 9183244ca8d1d04977f93030ecc6632ee4f09365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 2 Aug 2021 19:49:18 +0200 Subject: [PATCH 051/146] =?UTF-8?q?A=C3=B1adido=20cliente=20personalizado?= =?UTF-8?q?=20de=20FACe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creados StegeableTrait, FaceTrait y Faceb2bTrait - Actualizados clientes de FACe y FACeB2B - Añadido CustomFaceClient - Actualizada documentación --- doc/envio-y-recepcion/face.md | 11 ++ src/Face/CustomFaceClient.php | 32 +++++ src/Face/FaceClient.php | 133 +---------------- src/Face/Faceb2bClient.php | 223 +---------------------------- src/Face/SoapClient.php | 19 --- src/Face/Traits/FaceTrait.php | 123 ++++++++++++++++ src/Face/Traits/Faceb2bTrait.php | 213 +++++++++++++++++++++++++++ src/Face/Traits/StageableTrait.php | 23 +++ 8 files changed, 416 insertions(+), 361 deletions(-) create mode 100644 src/Face/CustomFaceClient.php create mode 100644 src/Face/Traits/FaceTrait.php create mode 100644 src/Face/Traits/Faceb2bTrait.php create mode 100644 src/Face/Traits/StageableTrait.php diff --git a/doc/envio-y-recepcion/face.md b/doc/envio-y-recepcion/face.md index ea64f7c..47c0027 100644 --- a/doc/envio-y-recepcion/face.md +++ b/doc/envio-y-recepcion/face.md @@ -38,6 +38,17 @@ Todas las llamadas a FACe desde `FaceClient` devuelven un objeto `SimpleXMLEleme --- +## Cliente personalizado +Algunas administraciones públicas optan por desplegar su propio FACe en vez de utilizar el punto central de recepción de facturas a nivel nacional. Para estos casos, Facturae-PHP dispone de la clase `CustomFaceClient` que permite especificar la URL del servicio con el que nos queremos comunicar. + +Un ejemplo de este caso de uso es el [PGEFe de la Diputación Foral de Gipuzkoa](https://egoitza.gipuzkoa.eus/WAS/HACI/HFAPortalProveedorWEB/estatico/doc/ServiciosSistemasAutomatizadosProveedores.pdf), que dispone de su web service donde implementa el WSDL de FACe: +```php +$endpointUrl = "https://w390w.gipuzkoa.net/WAS/HACI/HFAServiciosProveedoresWEB/services/FacturaSSPPWebServiceProxyPort"; +$face = new CustomFaceClient($endpointUrl, "certificado.pfx", null, "passphrase"); +``` + +--- + ## Listado de métodos A continuación se incluyen los métodos de `FaceClient` para llamar al servicio web FACe junto a una vista previa en JSON de la respuesta que devuelven. diff --git a/src/Face/CustomFaceClient.php b/src/Face/CustomFaceClient.php new file mode 100644 index 0000000..310e2af --- /dev/null +++ b/src/Face/CustomFaceClient.php @@ -0,0 +1,32 @@ +endpointUrl = $endpointUrl; + } + + + /** + * Get endpoint URL + * @return string Endpoint URL + */ + protected function getEndpointUrl() { + return $this->endpointUrl; + } +} diff --git a/src/Face/FaceClient.php b/src/Face/FaceClient.php index 021b241..cd8d3a7 100644 --- a/src/Face/FaceClient.php +++ b/src/Face/FaceClient.php @@ -1,139 +1,20 @@ production ? self::$PROD_URL : self::$STAGING_URL; - } - - - /** - * Get web namespace - * @return string Web namespace - */ - protected function getWebNamespace() { - return self::$WEB_NS; - } - - - /** - * Get invoice status codes - * @return SimpleXMLElement Response - */ - public function getStatus() { - return $this->request(''); - } - - - /** - * Get administrations - * @param boolean $onlyTopLevel Get only top level administrations - * @return SimpleXMLElement Response - */ - public function getAdministrations($onlyTopLevel=true) { - $tag = "consultarAdministraciones"; - if (!$onlyTopLevel) $tag .= "Repositorio"; - return $this->request(""); - } - - - /** - * Get units - * @param string|null $code Administration code - * @return SimpleXMLElement Response - */ - public function getUnits($code=null) { - if (is_null($code)) return $this->request(''); - return $this->request('' . - '' . $code . '' . - ''); + return $this->isProduction() ? + "https://webservice.face.gob.es/facturasspp2" : + "https://se-face-webservice.redsara.es/facturasspp2"; } - - - /** - * Get NIFs - * @param string|null $code Administration code - * @return SimpleXMLElement Response - */ - public function getNifs($code=null) { - if (is_null($code)) return $this->request(''); - return $this->request('' . - '' . $code . '' . - ''); - } - - - /** - * Get invoice - * @param string|string[] $regId Invoice register ID(s) - * @return SimpleXMLElement Response - */ - public function getInvoices($regId) { - if (is_string($regId)) { - return $this->request('' . - '' . $regId . '' . - ''); - } - $req = ''; - foreach ($regId as $id) $req .= '' . $id . ''; - $req .= ''; - return $this->request($req); - } - - - /** - * Send invoice - * @param string $email Email address - * @param FacturaeFile $invoice Invoice - * @param FacturaeFile[] $attachments Attachments - * @return SimpleXMLElement Response - */ - public function sendInvoice($email, $invoice, $attachments=array()) { - $tools = new XmlTools(); - $req = ''; - $req .= '' . $email . ''; - $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . - '' . $invoice->getFilename() . '' . - 'application/xml' . // Mandatory MIME type - ''; - $req .= ''; - foreach ($attachments as $file) { - $req .= '' . - '' . $tools->toBase64($file->getData()) . '' . - '' . $file->getFilename() . '' . - '' . $file->getMimeType() . '' . - ''; - } - $req .= ''; - $req .= ''; - return $this->request($req); - } - - - /** - * Cancel invoice - * @param string $regId Invoice register ID - * @param string $reason Cancelation reason - * @return SimpleXMLElement Response - */ - public function cancelInvoice($regId, $reason) { - return $this->request('' . - '' . $regId . '' . - '' . $reason . '' . - ''); - } - } diff --git a/src/Face/Faceb2bClient.php b/src/Face/Faceb2bClient.php index d702c80..c5e1405 100644 --- a/src/Face/Faceb2bClient.php +++ b/src/Face/Faceb2bClient.php @@ -1,229 +1,20 @@ production ? self::$PROD_URL : self::$STAGING_URL; - } - - - /** - * Get web namespace - * @return string Web namespace - */ - protected function getWebNamespace() { - return self::$WEB_NS; - } - - - /** - * Send invoice - * @param FacturaeFile $invoice Invoice - * @param FacturaeFile $attachment Attachment - * @return SimpleXMLElement Response - */ - public function sendInvoice($invoice, $attachment=null) { - $tools = new XmlTools(); - $req = ''; - - $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . - '' . $invoice->getFilename() . '' . - 'text/xml' . // Mandatory MIME type - ''; - - if (!is_null($attachment)) { - $req .= '' . - '' . $tools->toBase64($attachment->getData()) . '' . - '' . $attachment->getFilename() . '' . - '' . $attachment->getMimeType() . '' . - ''; - } - - $req .= ''; - return $this->request($req); - } - - - /** - * Get invoice details - * @param string $regId Registry number - * @return SimpleXMLElement Response - */ - public function getInvoiceDetails($regId) { - return $this->request('' . - '' . $regId . '' . - ''); - } - - - /** - * Request invoice cancellation - * @param string $regId Registry number - * @param string $reason Reason code - * @param string $comment Additional comments - * @return SimpleXMLElement Response - */ - public function requestInvoiceCancellation($regId, $reason, $comment=null) { - $req = ''; - $req .= '' . $regId . ''; - $req .= '' . $reason . ''; - if (empty($comment)) $req .= '' . $comment . ''; - $req .= ''; - return $this->request($req); - } - - - /** - * Get registered invoices - * @param string $receivingUnit Receiving unit code - * @return SimpleXMLElement Response - */ - public function getRegisteredInvoices($receivingUnit=null) { - $req = ''; - if (is_null($receivingUnit)) { - $req .= '' . $receivingUnit . ''; - } - $req .= ''; - return $this->request($req); - } - - - /** - * Get invoice cancellations - * @return SimpleXMLElement Response - */ - public function getInvoiceCancellations() { - return $this->request('' . - ''); - } - - - /** - * Download invoice - * @param string $regId Registry number - * @param boolean $validate Validate invoice - * @return SimpleXMLElement Response - */ - public function downloadInvoice($regId, $validate=true) { - $req = ''; - $req .= '' . $regId . ''; - if ($validate) { - $req .= 'validate'; - } - $req .= ''; - return $this->request($req); - } - - - /** - * Confirm invoice download - * @param string $regId Registry number - * @return SimpleXMLElement Response - */ - public function confirmInvoiceDownload($regId) { - return $this->request('' . - '' . $regId . '' . - ''); - } - - - /** - * Reject invoice - * @param string $regId Registry number - * @param string $reason Reason code - * @param string $comment Additional comments - * @return SimpleXMLElement Response - */ - public function rejectInvoice($regId, $reason, $comment=null) { - $req = ''; - $req .= '' . $regId . ''; - $req .= '' . $reason . ''; - if (empty($comment)) $req .= '' . $comment . ''; - $req .= ''; - return $this->request($req); - } - - - /** - * Mark invoice as paid - * @param string $regId Registry number - * @return SimpleXMLElement Response - */ - public function markInvoiceAsPaid($regId) { - return $this->request('>' . - '' . $regId . '' . - '>'); - } - - - /** - * Accept invoice cancellation - * @param string $regId Registry number - * @return SimpleXMLElement Response - */ - public function acceptInvoiceCancellation($regId) { - return $this->request('' . - '' . $regId . '' . - ''); - } - - - /** - * Reject invoice cancellation - * @param string $regId Registry number - * @param string $comment Commment - * @return SimpleXMLElement Response - */ - public function rejectInvoiceCancellation($regId, $comment) { - return $this->request('' . - '' . $regId . '' . - '' . $comment . '' . - ''); + return $this->isProduction() ? + "https://ws.faceb2b.gob.es/sv1/invoice" : + "https://se-ws-faceb2b.redsara.es/sv1/invoice"; } - - - /** - * Validate invoice signature - * @param string $regId Registry number - * @param FacturaeFile $invoice Invoice - * @return SimpleXMLElement Response - */ - public function validateInvoiceSignature($regId, $invoice) { - $tools = new XmlTools(); - $req = ''; - $req .= '' . $regId . ''; - $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . - '' . $invoice->getFilename() . '' . - '' . $invoice->getMimeType() . '' . - ''; - $req .= ''; - return $this->request($req); - } - - - /** - * Get codes - * @param string $type Code type - * @return SimpleXMLElement Response - */ - public function getCodes($type="") { - return $this->request('' . - '' . $type . '' . - ''); - } - } diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 12918f7..22616a4 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -11,7 +11,6 @@ abstract class SoapClient { private $publicKey; private $privateKey; - protected $production = true; /** @@ -43,24 +42,6 @@ protected abstract function getEndpointUrl(); protected abstract function getWebNamespace(); - /** - * Set production environment - * @param boolean $production Is production - */ - public function setProduction($production) { - $this->production = $production; - } - - - /** - * Is production - * @return boolean Is production - */ - public function isProduction() { - return $this->production; - } - - /** * Send SOAP request * @param string $body Request body diff --git a/src/Face/Traits/FaceTrait.php b/src/Face/Traits/FaceTrait.php new file mode 100644 index 0000000..0abb719 --- /dev/null +++ b/src/Face/Traits/FaceTrait.php @@ -0,0 +1,123 @@ +request(''); + } + + + /** + * Get administrations + * @param boolean $onlyTopLevel Get only top level administrations + * @return SimpleXMLElement Response + */ + public function getAdministrations($onlyTopLevel=true) { + $tag = "consultarAdministraciones"; + if (!$onlyTopLevel) $tag .= "Repositorio"; + return $this->request(""); + } + + + /** + * Get units + * @param string|null $code Administration code + * @return SimpleXMLElement Response + */ + public function getUnits($code=null) { + if (is_null($code)) return $this->request(''); + return $this->request('' . + '' . $code . '' . + ''); + } + + + /** + * Get NIFs + * @param string|null $code Administration code + * @return SimpleXMLElement Response + */ + public function getNifs($code=null) { + if (is_null($code)) return $this->request(''); + return $this->request('' . + '' . $code . '' . + ''); + } + + + /** + * Get invoice + * @param string|string[] $regId Invoice register ID(s) + * @return SimpleXMLElement Response + */ + public function getInvoices($regId) { + if (is_string($regId)) { + return $this->request('' . + '' . $regId . '' . + ''); + } + $req = ''; + foreach ($regId as $id) $req .= '' . $id . ''; + $req .= ''; + return $this->request($req); + } + + + /** + * Send invoice + * @param string $email Email address + * @param FacturaeFile $invoice Invoice + * @param FacturaeFile[] $attachments Attachments + * @return SimpleXMLElement Response + */ + public function sendInvoice($email, $invoice, $attachments=array()) { + $tools = new XmlTools(); + $req = ''; + $req .= '' . $email . ''; + $req .= '' . + '' . $tools->toBase64($invoice->getData()) . '' . + '' . $invoice->getFilename() . '' . + 'application/xml' . // Mandatory MIME type + ''; + $req .= ''; + foreach ($attachments as $file) { + $req .= '' . + '' . $tools->toBase64($file->getData()) . '' . + '' . $file->getFilename() . '' . + '' . $file->getMimeType() . '' . + ''; + } + $req .= ''; + $req .= ''; + return $this->request($req); + } + + + /** + * Cancel invoice + * @param string $regId Invoice register ID + * @param string $reason Cancelation reason + * @return SimpleXMLElement Response + */ + public function cancelInvoice($regId, $reason) { + return $this->request('' . + '' . $regId . '' . + '' . $reason . '' . + ''); + } +} diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php new file mode 100644 index 0000000..874e61f --- /dev/null +++ b/src/Face/Traits/Faceb2bTrait.php @@ -0,0 +1,213 @@ +'; + + $req .= '' . + '' . $tools->toBase64($invoice->getData()) . '' . + '' . $invoice->getFilename() . '' . + 'text/xml' . // Mandatory MIME type + ''; + + if (!is_null($attachment)) { + $req .= '' . + '' . $tools->toBase64($attachment->getData()) . '' . + '' . $attachment->getFilename() . '' . + '' . $attachment->getMimeType() . '' . + ''; + } + + $req .= ''; + return $this->request($req); + } + + + /** + * Get invoice details + * @param string $regId Registry number + * @return SimpleXMLElement Response + */ + public function getInvoiceDetails($regId) { + return $this->request('' . + '' . $regId . '' . + ''); + } + + + /** + * Request invoice cancellation + * @param string $regId Registry number + * @param string $reason Reason code + * @param string $comment Additional comments + * @return SimpleXMLElement Response + */ + public function requestInvoiceCancellation($regId, $reason, $comment=null) { + $req = ''; + $req .= '' . $regId . ''; + $req .= '' . $reason . ''; + if (empty($comment)) $req .= '' . $comment . ''; + $req .= ''; + return $this->request($req); + } + + + /** + * Get registered invoices + * @param string $receivingUnit Receiving unit code + * @return SimpleXMLElement Response + */ + public function getRegisteredInvoices($receivingUnit=null) { + $req = ''; + if (is_null($receivingUnit)) { + $req .= '' . $receivingUnit . ''; + } + $req .= ''; + return $this->request($req); + } + + + /** + * Get invoice cancellations + * @return SimpleXMLElement Response + */ + public function getInvoiceCancellations() { + return $this->request('' . + ''); + } + + + /** + * Download invoice + * @param string $regId Registry number + * @param boolean $validate Validate invoice + * @return SimpleXMLElement Response + */ + public function downloadInvoice($regId, $validate=true) { + $req = ''; + $req .= '' . $regId . ''; + if ($validate) { + $req .= 'validate'; + } + $req .= ''; + return $this->request($req); + } + + + /** + * Confirm invoice download + * @param string $regId Registry number + * @return SimpleXMLElement Response + */ + public function confirmInvoiceDownload($regId) { + return $this->request('' . + '' . $regId . '' . + ''); + } + + + /** + * Reject invoice + * @param string $regId Registry number + * @param string $reason Reason code + * @param string $comment Additional comments + * @return SimpleXMLElement Response + */ + public function rejectInvoice($regId, $reason, $comment=null) { + $req = ''; + $req .= '' . $regId . ''; + $req .= '' . $reason . ''; + if (empty($comment)) $req .= '' . $comment . ''; + $req .= ''; + return $this->request($req); + } + + + /** + * Mark invoice as paid + * @param string $regId Registry number + * @return SimpleXMLElement Response + */ + public function markInvoiceAsPaid($regId) { + return $this->request('>' . + '' . $regId . '' . + '>'); + } + + + /** + * Accept invoice cancellation + * @param string $regId Registry number + * @return SimpleXMLElement Response + */ + public function acceptInvoiceCancellation($regId) { + return $this->request('' . + '' . $regId . '' . + ''); + } + + + /** + * Reject invoice cancellation + * @param string $regId Registry number + * @param string $comment Commment + * @return SimpleXMLElement Response + */ + public function rejectInvoiceCancellation($regId, $comment) { + return $this->request('' . + '' . $regId . '' . + '' . $comment . '' . + ''); + } + + + /** + * Validate invoice signature + * @param string $regId Registry number + * @param FacturaeFile $invoice Invoice + * @return SimpleXMLElement Response + */ + public function validateInvoiceSignature($regId, $invoice) { + $tools = new XmlTools(); + $req = ''; + $req .= '' . $regId . ''; + $req .= '' . + '' . $tools->toBase64($invoice->getData()) . '' . + '' . $invoice->getFilename() . '' . + '' . $invoice->getMimeType() . '' . + ''; + $req .= ''; + return $this->request($req); + } + + + /** + * Get codes + * @param string $type Code type + * @return SimpleXMLElement Response + */ + public function getCodes($type="") { + return $this->request('' . + '' . $type . '' . + ''); + } +} diff --git a/src/Face/Traits/StageableTrait.php b/src/Face/Traits/StageableTrait.php new file mode 100644 index 0000000..0fa0b31 --- /dev/null +++ b/src/Face/Traits/StageableTrait.php @@ -0,0 +1,23 @@ +production = $production; + } + + + /** + * Is production + * @return boolean Is production + */ + public function isProduction() { + return $this->production; + } +} From 983562aed117d9881c59ef13215b392c18ce0e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 2 Aug 2021 19:50:45 +0200 Subject: [PATCH 052/146] Corregido typo en tests - Actualizado WebservicesTest --- tests/WebservicesTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index ae3376b..8dd1396 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -125,7 +125,7 @@ public function testFaceb2b() { $fac = $this->getWsBaseInvoice(); $fac->setBuyer(new FacturaeParty([ "taxNumber" => "A78923125", - "name" => "Teléfonica Móviles España, S.A.U.", + "name" => "Telefónica Móviles España, S.A.U.", "address" => "Calle Gran Vía, 28", "postCode" => "28013", "town" => "Madrid", From 40acfc47f886c660ad81b5393326ba98a196fa2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 4 Aug 2021 20:52:35 +0200 Subject: [PATCH 053/146] Actualizadas badges de Codacy - Modificado README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a21bad..7427afd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Facturae-PHP [![CI](https://github.com/josemmo/Facturae-PHP/workflows/Tests/badge.svg)](https://github.com/josemmo/Facturae-PHP/actions) -[![Codacy](https://api.codacy.com/project/badge/Grade/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) -[![Coverage](https://api.codacy.com/project/badge/Coverage/ff163f2711a44e31b19a8181c15726f5)](https://www.codacy.com/app/josemmo/Facturae-PHP) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/7c45d5ebdf9a4e88b0fe6b0e12d720b1)](https://www.codacy.com/gh/josemmo/Facturae-PHP) +[![Coverage](https://app.codacy.com/project/badge/Coverage/7c45d5ebdf9a4e88b0fe6b0e12d720b1)](https://www.codacy.com/gh/josemmo/Facturae-PHP) [![Última versión estable](https://img.shields.io/packagist/v/josemmo/facturae-php)](https://packagist.org/packages/josemmo/facturae-php) [![Versión de PHP](https://img.shields.io/badge/php-%3E%3D5.6%20%7C%7C%20%3E%3D7.0%20%7C%7C%20%3E%3D8.0-8892BF)](composer.json) [![Documentación](https://img.shields.io/badge/docs-online-blue.svg?longCache=true)](https://josemmo.github.io/Facturae-PHP/) From dd886de286931d25319d9b922d62f72a68d84a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 1 Sep 2021 20:51:15 +0200 Subject: [PATCH 054/146] Arreglados comentarios en FaceB2bClient - Actualizados campos opcionales en phpDoc - Arreglados campos de comentarios - Corregidos typos --- src/Face/Traits/Faceb2bTrait.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php index 874e61f..c5937d6 100644 --- a/src/Face/Traits/Faceb2bTrait.php +++ b/src/Face/Traits/Faceb2bTrait.php @@ -15,8 +15,8 @@ protected function getWebNamespace() { /** * Send invoice - * @param FacturaeFile $invoice Invoice - * @param FacturaeFile $attachment Attachment + * @param FacturaeFile $invoice Invoice + * @param FacturaeFile|null $attachment Optional attachment * @return SimpleXMLElement Response */ public function sendInvoice($invoice, $attachment=null) { @@ -58,14 +58,16 @@ public function getInvoiceDetails($regId) { * Request invoice cancellation * @param string $regId Registry number * @param string $reason Reason code - * @param string $comment Additional comments + * @param string|null $comment Optional comments * @return SimpleXMLElement Response */ public function requestInvoiceCancellation($regId, $reason, $comment=null) { $req = ''; $req .= '' . $regId . ''; $req .= '' . $reason . ''; - if (empty($comment)) $req .= '' . $comment . ''; + if (!is_null($comment)) { + $req .= '' . $comment . ''; + } $req .= ''; return $this->request($req); } @@ -73,7 +75,7 @@ public function requestInvoiceCancellation($regId, $reason, $comment=null) { /** * Get registered invoices - * @param string $receivingUnit Receiving unit code + * @param string|null $receivingUnit Receiving unit code * @return SimpleXMLElement Response */ public function getRegisteredInvoices($receivingUnit=null) { @@ -129,14 +131,16 @@ public function confirmInvoiceDownload($regId) { * Reject invoice * @param string $regId Registry number * @param string $reason Reason code - * @param string $comment Additional comments + * @param string|null $comment Optional comments * @return SimpleXMLElement Response */ public function rejectInvoice($regId, $reason, $comment=null) { $req = ''; $req .= '' . $regId . ''; $req .= '' . $reason . ''; - if (empty($comment)) $req .= '' . $comment . ''; + if (!is_null($comment)) { + $req .= '' . $comment . ''; + } $req .= ''; return $this->request($req); } @@ -148,9 +152,9 @@ public function rejectInvoice($regId, $reason, $comment=null) { * @return SimpleXMLElement Response */ public function markInvoiceAsPaid($regId) { - return $this->request('>' . + return $this->request('' . '' . $regId . '' . - '>'); + ''); } @@ -169,7 +173,7 @@ public function acceptInvoiceCancellation($regId) { /** * Reject invoice cancellation * @param string $regId Registry number - * @param string $comment Commment + * @param string $comment Comment * @return SimpleXMLElement Response */ public function rejectInvoiceCancellation($regId, $comment) { From b2ea195166fe4e72f19c9fc55084bcffb5ed224d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 1 Sep 2021 20:53:33 +0200 Subject: [PATCH 055/146] Corregido "No SOAPAction header" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadida cabecera "SOAPAction" en SoapClient --- src/Face/SoapClient.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 22616a4..074ebae 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -133,6 +133,10 @@ protected function request($body) { $req = $tools->injectNamespaces($req, $ns); $req = '' . "\n" . $req; + // Extract SOAP action from "" + $soapAction = substr($body, 5, strpos($body, '>')-5); + $soapAction = $this->getWebNamespace() . "#$soapAction"; + // Send request $ch = curl_init(); curl_setopt_array($ch, array( @@ -142,7 +146,10 @@ protected function request($body) { CURLOPT_TIMEOUT => 30, CURLOPT_POST => 1, CURLOPT_POSTFIELDS => $req, - CURLOPT_HTTPHEADER => array("Content-Type: text/xml"), + CURLOPT_HTTPHEADER => array( + "Content-Type: text/xml", + "SOAPAction: $soapAction" + ), CURLOPT_USERAGENT => Facturae::USER_AGENT )); $res = curl_exec($ch); From f221dd9ada424fcd9a24e00328fe39515b2bb29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 1 Sep 2021 20:54:33 +0200 Subject: [PATCH 056/146] Actualizado workflow de tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadido argumento "--testdox" a PHPUnit --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea2484a..9aca49b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: - name: Run tests env: TEST_WEBSERVICES: ${{ !startsWith(github.ref, 'refs/pull/') && matrix.test-ws || false }} - run: composer install && vendor/bin/simple-phpunit + run: composer install && vendor/bin/simple-phpunit --testdox # Send coverage - name: Send coverage From 31ab293eb7af9eb18c173abcace1942be8c1a9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 6 Sep 2021 19:27:06 +0200 Subject: [PATCH 057/146] v1.6.1 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 31bc614..eb1c5fe 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.6.0"; + const VERSION = "1.6.1"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 81d25744ffd1339ddfd89b1cad900b934e7a11f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 25 Sep 2021 16:09:28 +0200 Subject: [PATCH 058/146] Modificado redondeo de decimales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aplicado redondeo de decimales solo antes de generar el documento XML de la factura - Actualizado Facturae::$DECIMALS - Modificado método UtilsTrait::pad() - Actualizado test de decimales --- src/Facturae.php | 43 ++++++++++------- src/FacturaeItem.php | 56 +++++++++++----------- src/FacturaeTraits/ExportableTrait.php | 64 +++++++++++++------------- src/FacturaeTraits/PropertiesTrait.php | 20 ++------ src/FacturaeTraits/UtilsTrait.php | 17 ++++--- tests/DecimalsTest.php | 18 ++++++-- 6 files changed, 112 insertions(+), 106 deletions(-) diff --git a/src/Facturae.php b/src/Facturae.php index eb1c5fe..c9eddeb 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -115,24 +115,35 @@ class Facturae { self::SCHEMA_3_2_2 => "http://www.facturae.gob.es/formato/Versiones/Facturaev3_2_2.xml" ); protected static $DECIMALS = array( - null => [ - null => ["min"=>2, "max"=>2], - "Item/Quantity" => ["min"=>2, "max"=>8], - "Item/UnitPriceWithoutTax" => ["min"=>2, "max"=>8], - "Item/GrossAmount" => ["min"=>2, "max"=>8], - "Tax/Rate" => ["min"=>2, "max"=>8], - "Discount/Rate" => ["min"=>2, "max"=>8], - "Discount/Amount" => ["min"=>2, "max"=>2] + '' => [ + '' => ['min'=>2, 'max'=>2], + // 'InvoiceTotal' => ['min'=>2, 'max'=>8], + // 'TotalGrossAmount' => ['min'=>2, 'max'=>8], + // 'TotalGrossAmountBeforeTaxes' => ['min'=>2, 'max'=>8], + // 'TotalGeneralDiscounts' => ['min'=>2, 'max'=>8], + // 'TotalGeneralSurcharges' => ['min'=>2, 'max'=>8], + // 'TotalTaxOutputs' => ['min'=>2, 'max'=>8], + // 'TotalTaxesWithheld' => ['min'=>2, 'max'=>8], + 'Tax/TaxRate' => ['min'=>2, 'max'=>8], + // 'Tax/TaxableBase' => ['min'=>2, 'max'=>8], + // 'Tax/TaxAmount' => ['min'=>2, 'max'=>8], + 'Tax/EquivalenceSurchargeAmount' => ['min'=>2, 'max'=>8], + 'DiscountCharge/Rate' => ['min'=>2, 'max'=>8], + // 'DiscountCharge/Amount' => ['min'=>2, 'max'=>8], + 'Item/Quantity' => ['min'=>0, 'max'=>8], + 'Item/UnitPriceWithoutTax' => ['min'=>2, 'max'=>8], + // 'Item/TotalCost' => ['min'=>2, 'max'=>8], + // 'Item/GrossAmount' => ['min'=>2, 'max'=>8], ], self::SCHEMA_3_2 => [ - null => ["min"=>2, "max"=>2], - "Item/Quantity" => ["min"=>2, "max"=>6], - "Item/TotalAmountWithoutTax" => ["min"=>6, "max"=>6], - "Item/UnitPriceWithoutTax" => ["min"=>6, "max"=>6], - "Item/GrossAmount" => ["min"=>6, "max"=>6], - "Discount/Rate" => ["min"=>4, "max"=>4], - "Discount/Amount" => ["min"=>6, "max"=>6] - ] + '' => ['min'=>2, 'max'=>2], + 'DiscountCharge/Rate' => ['min'=>4, 'max'=>4], + 'DiscountCharge/Amount' => ['min'=>6, 'max'=>6], + 'Item/Quantity' => ['min'=>0, 'max'=>8], + 'Item/UnitPriceWithoutTax' => ['min'=>6, 'max'=>6], + 'Item/TotalCost' => ['min'=>6, 'max'=>6], + 'Item/GrossAmount' => ['min'=>6, 'max'=>6], + ], ); use PropertiesTrait; diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 05bc7a1..1003f4c 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -75,7 +75,7 @@ public function __construct($properties=array()) { } // Adjust discounts and charges according to taxes - foreach (['discounts', 'charges'] as $i=>$groupTag) { + foreach (['discounts', 'charges'] as $groupTag) { foreach ($this->{$groupTag} as &$group) { if (isset($group['rate'])) continue; $hasTaxes = isset($group['hasTaxes']) ? $group['hasTaxes'] : true; @@ -92,10 +92,9 @@ public function __construct($properties=array()) { /** * Get data for this item fixing decimals to match invoice settings * - * @param Facturae $fac Invoice instance - * @return array Item data + * @return array Item data */ - public function getData($fac) { + public function getData() { $addProps = [ 'taxesOutputs' => [], 'taxesWithheld' => [], @@ -103,9 +102,9 @@ public function getData($fac) { 'charges' => [] ]; - $quantity = $fac->pad($this->quantity, 'Item/Quantity'); - $unitPriceWithoutTax = $fac->pad($this->unitPriceWithoutTax, 'Item/UnitPriceWithoutTax'); - $totalAmountWithoutTax = $fac->pad($quantity * $unitPriceWithoutTax, 'Item/TotalAmountWithoutTax'); + $quantity = $this->quantity; + $unitPriceWithoutTax = $this->unitPriceWithoutTax; + $totalAmountWithoutTax = $quantity * $unitPriceWithoutTax; // Process charges and discounts $grossAmount = $totalAmountWithoutTax; @@ -113,13 +112,12 @@ public function getData($fac) { $factor = ($i == 0) ? -1 : 1; foreach ($this->{$groupTag} as $group) { if (isset($group['rate'])) { - $rate = $fac->pad($group['rate'], 'Discount/Rate'); + $rate = $group['rate']; $amount = $totalAmountWithoutTax * ($rate / 100); } else { $rate = null; $amount = $group['amount']; } - $amount = $fac->pad($amount, 'Discount/Amount'); $addProps[$groupTag][] = array( "reason" => $group['reason'], "rate" => $rate, @@ -128,19 +126,18 @@ public function getData($fac) { $grossAmount += $amount * $factor; } } - $grossAmount = $fac->pad($grossAmount, 'Item/GrossAmount'); // Get taxes $totalTaxesOutputs = 0; $totalTaxesWithheld = 0; foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { foreach ($this->{$taxesGroup} as $type=>$tax) { - $taxRate = $fac->pad($tax['rate'], 'Tax/Rate'); - $surcharge = $fac->pad($tax['surcharge'], 'Tax/Surcharge'); - $taxAmount = $fac->pad($grossAmount * ($taxRate / 100), 'Tax/Amount'); - $surchargeAmount = $fac->pad($grossAmount * ($surcharge / 100), 'Tax/SurchargeAmount'); + $taxRate = $tax['rate']; + $surcharge = $tax['surcharge']; + $taxAmount = $grossAmount * ($taxRate / 100); + $surchargeAmount = $grossAmount * ($surcharge / 100); $addProps[$taxesGroup][$type] = array( - "base" => $fac->pad($grossAmount, 'Tax/Base'), + "base" => $grossAmount, "rate" => $taxRate, "surcharge" => $surcharge, "amount" => $taxAmount, @@ -155,20 +152,21 @@ public function getData($fac) { } // Fix decimals - if (!is_null($this->unitPrice)) { - $expectedTotal = $this->unitPrice * $this->quantity; - $generatedTotal = $totalAmountWithoutTax + $totalTaxesOutputs - $totalTaxesWithheld; - $diffAmount = $fac->pad($expectedTotal - $generatedTotal, 'Tax/Amount'); - if (abs($diffAmount) == 0.01) { - foreach (['taxesOutputs', 'taxesWithheld'] as $taxesGroup) { - foreach ($addProps[$taxesGroup] as &$taxItem) { - $taxItem['amount'] += $diffAmount; - ${'total' . ucfirst($taxesGroup)} += $diffAmount; - break 2; - } - } - } - } + // TODO: remove, is this no longer necessary? + // if (!is_null($this->unitPrice)) { + // $expectedTotal = $this->unitPrice * $this->quantity; + // $generatedTotal = $totalAmountWithoutTax + $totalTaxesOutputs - $totalTaxesWithheld; + // $diffAmount = $expectedTotal - $generatedTotal; + // if (abs($diffAmount) == 0.01) { + // foreach (['taxesOutputs', 'taxesWithheld'] as $taxesGroup) { + // foreach ($addProps[$taxesGroup] as &$taxItem) { + // $taxItem['amount'] += $diffAmount; + // ${'total' . ucfirst($taxesGroup)} += $diffAmount; + // break 2; + // } + // } + // } + // } // Add rest of properties $addProps['quantity'] = $quantity; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index a282b8e..a0ff1d5 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -57,13 +57,13 @@ public function export($filePath=null) { '' . $batchIdentifier . '' . '1' . '' . - '' . $totals['invoiceAmount'] . '' . + '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . '' . '' . - '' . $totals['invoiceAmount'] . '' . + '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . '' . '' . - '' . $totals['invoiceAmount'] . '' . + '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . '' . '' . $this->currency . '' . ''; @@ -126,17 +126,17 @@ public function export($filePath=null) { foreach ($taxRows as $tax) { $xml .= '' . '' . $type . '' . - '' . $this->pad($tax['rate'], 'Tax/Rate') . '' . + '' . $this->pad($tax['rate'], 'Tax/TaxRate') . '' . '' . - '' . $this->pad($tax['base'], 'Tax/Base') . '' . + '' . $this->pad($tax['base'], 'Tax/TaxableBase') . '' . '' . '' . - '' . $this->pad($tax['amount'], 'Tax/Amount') . '' . + '' . $this->pad($tax['amount'], 'Tax/TaxAmount') . '' . ''; if ($tax['surcharge'] != 0) { - $xml .= '' . $this->pad($tax['surcharge'], 'Tax/Surcharge') . '' . + $xml .= '' . $this->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge') . '' . '' . - '' . $this->pad($tax['surchargeAmount'], 'Tax/SurchargeAmount') . '' . + '' . $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . '' . ''; } $xml .= ''; @@ -147,7 +147,7 @@ public function export($filePath=null) { // Add invoice totals $xml .= ''; - $xml .= '' . $totals['grossAmount'] . ''; + $xml .= '' . $this->pad($totals['grossAmount'], 'TotalGrossAmount') . ''; // Add general discounts and charges $generalGroups = array( @@ -162,28 +162,28 @@ public function export($filePath=null) { $xml .= "<$xmlTag>"; $xml .= "<${xmlTag}Reason>" . $tools->escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { - $xml .= "<${xmlTag}Rate>" . $elem['rate'] . ""; + $xml .= "<${xmlTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } - $xml .="<${xmlTag}Amount>" . $elem['amount'] . ""; + $xml .="<${xmlTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; $xml .= ""; } $xml .= ''; } - $xml .= '' . $totals['totalGeneralDiscounts'] . ''; - $xml .= '' . $totals['totalGeneralCharges'] . ''; - $xml .= '' . $totals['grossAmountBeforeTaxes'] . ''; - $xml .= '' . $totals['totalTaxesOutputs'] . ''; - $xml .= '' . $totals['totalTaxesWithheld'] . ''; - $xml .= '' . $totals['invoiceAmount'] . ''; - $xml .= '' . $totals['invoiceAmount'] . ''; - $xml .= '' . $totals['invoiceAmount'] . ''; + $xml .= '' . $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts') . ''; + $xml .= '' . $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges') . ''; + $xml .= '' . $this->pad($totals['grossAmountBeforeTaxes'], 'TotalGrossAmountBeforeTaxes') . ''; + $xml .= '' . $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs') . ''; + $xml .= '' . $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld') . ''; + $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; + $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; + $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; $xml .= ''; // Add invoice items $xml .= ''; foreach ($this->items as $itemObj) { - $item = $itemObj->getData($this); + $item = $itemObj->getData(); $xml .= ''; // Add optional fields @@ -197,10 +197,10 @@ public function export($filePath=null) { // Add required fields $xml .= '' . $tools->escape($item['name']) . '' . - '' . $item['quantity'] . '' . + '' . $this->pad($item['quantity'], 'Item/Quantity') . '' . '' . $item['unitOfMeasure'] . '' . - '' . $item['unitPriceWithoutTax'] . '' . - '' . $item['totalAmountWithoutTax'] . ''; + '' . $this->pad($item['unitPriceWithoutTax'], 'Item/UnitPriceWithoutTax') . '' . + '' . $this->pad($item['totalAmountWithoutTax'], 'Item/TotalCost') . ''; // Add discounts and charges $itemGroups = array( @@ -215,16 +215,16 @@ public function export($filePath=null) { $xml .= "<$groupTag>"; $xml .= "<${groupTag}Reason>" . $tools->escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { - $xml .= "<${groupTag}Rate>" . $elem['rate'] . ""; + $xml .= "<${groupTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } - $xml .="<${groupTag}Amount>" . $elem['amount'] . ""; + $xml .="<${groupTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; $xml .= ""; } $xml .= ''; } // Add gross amount - $xml .= '' . $item['grossAmount'] . ''; + $xml .= '' . $this->pad($item['grossAmount'], 'Item/GrossAmount') . ''; // Add item taxes // NOTE: As you can see here, taxesWithheld is before taxesOutputs. @@ -237,17 +237,17 @@ public function export($filePath=null) { foreach ($item[$taxesGroup] as $type=>$tax) { $xml .= '' . '' . $type . '' . - '' . $this->pad($tax['rate'], 'Tax/Rate') . '' . + '' . $this->pad($tax['rate'], 'Tax/TaxRate') . '' . '' . - '' . $this->pad($tax['base'], 'Tax/Base') . '' . + '' . $this->pad($tax['base'], 'Tax/TaxableBase') . '' . '' . '' . - '' . $this->pad($tax['amount'], 'Tax/Amount') . '' . + '' . $this->pad($tax['amount'], 'Tax/TaxAmount') . '' . ''; if ($tax['surcharge'] != 0) { - $xml .= '' . $this->pad($tax['surcharge'], 'Tax/Surcharge') . '' . + $xml .= '' . $this->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge') . '' . '' . - '' . $this->pad($tax['surchargeAmount'], 'Tax/SurchargeAmount') . '' . + '' . $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . '' . ''; } $xml .= ''; @@ -310,7 +310,7 @@ private function getPaymentDetailsXML($totals) { $xml = ''; $xml .= ''; $xml .= '' . date('Y-m-d', $dueDate) . ''; - $xml .= '' . $totals['invoiceAmount'] . ''; + $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; $xml .= '' . $this->header['paymentMethod'] . ''; if (!is_null($this->header['paymentIBAN'])) { $accountType = ($this->header['paymentMethod'] == self::PAYMENT_DEBIT) ? "AccountToBeDebited" : "AccountToBeCredited"; diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 2b367c6..2572dbc 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -615,7 +615,7 @@ public function getTotals() { // Run through every item foreach ($this->items as $itemObj) { - $item = $itemObj->getData($this); + $item = $itemObj->getData(); $totals['grossAmount'] += $item['grossAmount']; $totals['totalTaxesOutputs'] += $item['totalTaxesOutputs']; $totals['totalTaxesWithheld'] += $item['totalTaxesWithheld']; @@ -643,9 +643,6 @@ public function getTotals() { } } - // Normalize gross amount (needed for next step) - $totals['grossAmount'] = $this->pad($totals['grossAmount']); - // Get general discounts and charges foreach (['discounts', 'charges'] as $groupTag) { foreach ($this->{$groupTag} as $item) { @@ -653,10 +650,9 @@ public function getTotals() { $rate = null; $amount = $item['amount']; } else { - $rate = $this->pad($item['rate'], 'Discount/Rate'); + $rate = $item['rate']; $amount = $totals['grossAmount'] * ($rate / 100); } - $amount = $this->pad($amount, 'Discount/Amount'); $totals['general' . ucfirst($groupTag)][] = array( "reason" => $item['reason'], "rate" => $rate, @@ -666,17 +662,9 @@ public function getTotals() { } } - // Normalize rest of values - $totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs']); - $totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld']); - $totals['totalGeneralDiscounts'] = $this->pad($totals['totalGeneralDiscounts']); - $totals['totalGeneralCharges'] = $this->pad($totals['totalGeneralCharges']); - // Fill missing values - $totals['grossAmountBeforeTaxes'] = $this->pad($totals['grossAmount'] - - $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges']); - $totals['invoiceAmount'] = $this->pad($totals['grossAmountBeforeTaxes'] + - $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld']); + $totals['grossAmountBeforeTaxes'] = $totals['grossAmount'] - $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges']; + $totals['invoiceAmount'] = $totals['grossAmountBeforeTaxes'] + $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld']; return $totals; } diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index 854ed42..b4c313f 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -20,23 +20,22 @@ public static function isWithheldTax($taxCode) { /** * Pad amount - * @param float $val Input value - * @param string|null $field Field - * @return string Padded value + * @param float $val Input value + * @param string $field Field + * @return string Padded value */ - public function pad($val, $field=null) { + public function pad($val, $field) { // Get decimals - $vKey = isset(self::$DECIMALS[$this->version]) ? $this->version : null; - $decimals = self::$DECIMALS[$vKey]; - if (!isset($decimals[$field])) $field = null; - $decimals = $decimals[$field]; + $decimals = isset(self::$DECIMALS[$this->version]) ? self::$DECIMALS[$this->version] : self::$DECIMALS['']; + $decimals = isset($decimals[$field]) ? $decimals[$field] : $decimals['']; // Pad value - $res = number_format(round($val, $decimals['max']), $decimals['max'], ".", ""); + $res = number_format($val, $decimals['max'], '.', ''); for ($i=0; $i<$decimals['max']-$decimals['min']; $i++) { if (substr($res, -1) !== "0") break; $res = substr($res, 0, -1); } + $res = rtrim($res, '.'); return $res; } diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php index 24526a6..26f5f87 100644 --- a/tests/DecimalsTest.php +++ b/tests/DecimalsTest.php @@ -83,11 +83,21 @@ public function testAutofixDecimals() { foreach ($amounts as $i=>$itemAmount) { $fac->addItem(new FacturaeItem([ "name" => "Tengo un importe peculiar #" . ($i+1), - "quantity" => $i+1, - "unitPrice" => $itemAmount / ($i+1), - "taxes" => [Facturae::TAX_IVA => 21] + "quantity" => $i+1.54321, + "unitPrice" => $itemAmount / ($i+1.54321), + "taxes" => [Facturae::TAX_IVA => 12.3456] ])); } + $fac->addItem(new FacturaeItem([ + "name" => "Importe redondo", + "quantity" => 5, + "unitPrice" => 100, + "taxes" => [Facturae::TAX_IVA => 21] + ])); + + // Add discounts & charges + $fac->addDiscount('Un descuento', 34.1234); + $fac->addCharge('Un cargo', 34.1234); // Export invoice $outputPath = self::OUTPUT_DIR . "/salida-autodecimales.xml"; @@ -96,7 +106,7 @@ public function testAutofixDecimals() { // Validate invoice $invoiceTotal = (float) $invoiceXml->Invoices->Invoice[0]->InvoiceTotals->InvoiceTotal; - $this->assertEquals($invoiceTotal, array_sum($amounts)); + $this->assertEquals($invoiceTotal, array_sum($amounts)+500); $this->validateInvoiceXML($outputPath); } From dcec098213ac062d5db53a705f7ba2507f8e6684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 17 Dec 2021 21:40:11 +0100 Subject: [PATCH 059/146] Eliminado ajustador de decimales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado FacturaeItem - Actualizado test unitario > Con el nuevo sistema de redondeo ya no es necesario ajustar la diferencia de un céntimo. --- src/FacturaeItem.php | 17 ----------------- tests/DecimalsTest.php | 39 --------------------------------------- 2 files changed, 56 deletions(-) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 1003f4c..f2a5d36 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -151,23 +151,6 @@ public function getData() { } } - // Fix decimals - // TODO: remove, is this no longer necessary? - // if (!is_null($this->unitPrice)) { - // $expectedTotal = $this->unitPrice * $this->quantity; - // $generatedTotal = $totalAmountWithoutTax + $totalTaxesOutputs - $totalTaxesWithheld; - // $diffAmount = $expectedTotal - $generatedTotal; - // if (abs($diffAmount) == 0.01) { - // foreach (['taxesOutputs', 'taxesWithheld'] as $taxesGroup) { - // foreach ($addProps[$taxesGroup] as &$taxItem) { - // $taxItem['amount'] += $diffAmount; - // ${'total' . ucfirst($taxesGroup)} += $diffAmount; - // break 2; - // } - // } - // } - // } - // Add rest of properties $addProps['quantity'] = $quantity; $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax; diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php index 26f5f87..663628c 100644 --- a/tests/DecimalsTest.php +++ b/tests/DecimalsTest.php @@ -71,43 +71,4 @@ public function testDecimals() { $this->assertEquals($totalCount, $successCount); } - - /** - * Test autofix decimals - */ - public function testAutofixDecimals() { - $fac = $this->getBaseInvoice(); - - // Add items - $amounts = [671, 69, 11.21]; - foreach ($amounts as $i=>$itemAmount) { - $fac->addItem(new FacturaeItem([ - "name" => "Tengo un importe peculiar #" . ($i+1), - "quantity" => $i+1.54321, - "unitPrice" => $itemAmount / ($i+1.54321), - "taxes" => [Facturae::TAX_IVA => 12.3456] - ])); - } - $fac->addItem(new FacturaeItem([ - "name" => "Importe redondo", - "quantity" => 5, - "unitPrice" => 100, - "taxes" => [Facturae::TAX_IVA => 21] - ])); - - // Add discounts & charges - $fac->addDiscount('Un descuento', 34.1234); - $fac->addCharge('Un cargo', 34.1234); - - // Export invoice - $outputPath = self::OUTPUT_DIR . "/salida-autodecimales.xml"; - $fac->export($outputPath); - $invoiceXml = new \SimpleXMLElement(file_get_contents($outputPath)); - - // Validate invoice - $invoiceTotal = (float) $invoiceXml->Invoices->Invoice[0]->InvoiceTotals->InvoiceTotal; - $this->assertEquals($invoiceTotal, array_sum($amounts)+500); - $this->validateInvoiceXML($outputPath); - } - } From 91b7305a07804675aae13bf27248528fc06e6080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 17 Dec 2021 21:43:21 +0100 Subject: [PATCH 060/146] Arreglado redondeo de totales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redondeados valores totales en PropertiesTrait::getTotals() - Actualizado test unitario - Eliminadas líneas comentadas en src/Facturae.php > Relacionado con #81 --- src/Facturae.php | 14 +------------- src/FacturaeTraits/PropertiesTrait.php | 12 +++++++++++- tests/DecimalsTest.php | 5 +++++ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/Facturae.php b/src/Facturae.php index c9eddeb..f150b86 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -117,23 +117,11 @@ class Facturae { protected static $DECIMALS = array( '' => [ '' => ['min'=>2, 'max'=>2], - // 'InvoiceTotal' => ['min'=>2, 'max'=>8], - // 'TotalGrossAmount' => ['min'=>2, 'max'=>8], - // 'TotalGrossAmountBeforeTaxes' => ['min'=>2, 'max'=>8], - // 'TotalGeneralDiscounts' => ['min'=>2, 'max'=>8], - // 'TotalGeneralSurcharges' => ['min'=>2, 'max'=>8], - // 'TotalTaxOutputs' => ['min'=>2, 'max'=>8], - // 'TotalTaxesWithheld' => ['min'=>2, 'max'=>8], 'Tax/TaxRate' => ['min'=>2, 'max'=>8], - // 'Tax/TaxableBase' => ['min'=>2, 'max'=>8], - // 'Tax/TaxAmount' => ['min'=>2, 'max'=>8], 'Tax/EquivalenceSurchargeAmount' => ['min'=>2, 'max'=>8], - 'DiscountCharge/Rate' => ['min'=>2, 'max'=>8], - // 'DiscountCharge/Amount' => ['min'=>2, 'max'=>8], + 'DiscountCharge/Rate' => ['min'=>2, 'max'=>8], 'Item/Quantity' => ['min'=>0, 'max'=>8], 'Item/UnitPriceWithoutTax' => ['min'=>2, 'max'=>8], - // 'Item/TotalCost' => ['min'=>2, 'max'=>8], - // 'Item/GrossAmount' => ['min'=>2, 'max'=>8], ], self::SCHEMA_3_2 => [ '' => ['min'=>2, 'max'=>2], diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 2572dbc..d067bb4 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -662,8 +662,18 @@ public function getTotals() { } } + // Pre-round some total values (needed to create a sum-reasonable invoice total) + $totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs'); + $totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld'); + $totals['totalGeneralDiscounts'] = $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts'); + $totals['totalGeneralCharges'] = $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges'); + $totals['grossAmount'] = $this->pad($totals['grossAmount'], 'TotalGrossAmount'); + // Fill missing values - $totals['grossAmountBeforeTaxes'] = $totals['grossAmount'] - $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges']; + $totals['grossAmountBeforeTaxes'] = $this->pad( + $totals['grossAmount'] - $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges'], + 'TotalGrossAmountBeforeTaxes' + ); $totals['invoiceAmount'] = $totals['grossAmountBeforeTaxes'] + $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld']; return $totals; diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php index 663628c..c6f7052 100644 --- a/tests/DecimalsTest.php +++ b/tests/DecimalsTest.php @@ -8,6 +8,7 @@ final class DecimalsTest extends AbstractTest { const NUM_OF_TESTS = 1000; const ITEMS_PER_INVOICE = 3; + const CHARGES_PER_INVOICE = 3; const PRICE_DECIMALS = 8; const QUANTITY_DECIMALS = 6; @@ -41,6 +42,10 @@ private function _runTest($schema) { ] ])); } + for ($i=0; $iaddCharge("Cargo #$i", $charge); + } // Validate invoice totals $invoiceXml = new \SimpleXMLElement($fac->export()); From 34f9c7a0bfbab96c7f4416e1a82c19d25d1d584c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 18 Dec 2021 12:17:10 +0100 Subject: [PATCH 061/146] Arreglado redondeo en recargo de equivalencia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reducido número de decimales en el elemento "Tax/EquivalenceSurchargeAmount" --- src/Facturae.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Facturae.php b/src/Facturae.php index f150b86..c730ef0 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -117,8 +117,7 @@ class Facturae { protected static $DECIMALS = array( '' => [ '' => ['min'=>2, 'max'=>2], - 'Tax/TaxRate' => ['min'=>2, 'max'=>8], - 'Tax/EquivalenceSurchargeAmount' => ['min'=>2, 'max'=>8], + 'Tax/TaxRate' => ['min'=>2, 'max'=>8], 'DiscountCharge/Rate' => ['min'=>2, 'max'=>8], 'Item/Quantity' => ['min'=>0, 'max'=>8], 'Item/UnitPriceWithoutTax' => ['min'=>2, 'max'=>8], From ac86012d0f2ff3851e0c24dfea3745d96d5acb48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 22 Dec 2021 18:38:58 +0100 Subject: [PATCH 062/146] =?UTF-8?q?A=C3=B1adidos=20modos=20de=20precisi?= =?UTF-8?q?=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidas constantes a clase Facturae - Actualizado FacturaeItem - Actualizados ExportableTrait, PropertiesTrait y UtilsTrait - Sustituido DecimalsTest por PrecisionTest - Actualizado InvoiceTest > Relacionado con #81 --- src/Facturae.php | 3 + src/FacturaeItem.php | 17 +++--- src/FacturaeTraits/ExportableTrait.php | 2 +- src/FacturaeTraits/PropertiesTrait.php | 23 +++++++- src/FacturaeTraits/UtilsTrait.php | 14 +++-- tests/DecimalsTest.php | 79 -------------------------- tests/InvoiceTest.php | 9 ++- tests/PrecisionTest.php | 65 +++++++++++++++++++++ 8 files changed, 118 insertions(+), 94 deletions(-) delete mode 100644 tests/DecimalsTest.php create mode 100644 tests/PrecisionTest.php diff --git a/src/Facturae.php b/src/Facturae.php index c730ef0..a461f68 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -22,6 +22,9 @@ class Facturae { "digest" => "Ohixl6upD6av8N7pEvDABhEL6hM=" ); + const PRECISION_LINE = 1; + const PRECISION_INVOICE = 2; + const PAYMENT_CASH = "01"; const PAYMENT_DEBIT = "02"; const PAYMENT_RECEIPT = "03"; diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index f2a5d36..cd06457 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -92,9 +92,10 @@ public function __construct($properties=array()) { /** * Get data for this item fixing decimals to match invoice settings * - * @return array Item data + * @param Facturae $fac Invoice instance + * @return array Item data */ - public function getData() { + public function getData($fac) { $addProps = [ 'taxesOutputs' => [], 'taxesWithheld' => [], @@ -152,12 +153,12 @@ public function getData() { } // Add rest of properties - $addProps['quantity'] = $quantity; - $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax; - $addProps['totalAmountWithoutTax'] = $totalAmountWithoutTax; - $addProps['grossAmount'] = $grossAmount; - $addProps['totalTaxesOutputs'] = $totalTaxesOutputs; - $addProps['totalTaxesWithheld'] = $totalTaxesWithheld; + $addProps['quantity'] = $fac->pad($quantity, 'Item/Quantity', Facturae::PRECISION_LINE); + $addProps['unitPriceWithoutTax'] = $fac->pad($unitPriceWithoutTax, 'Item/UnitPriceWithoutTax', Facturae::PRECISION_LINE); + $addProps['totalAmountWithoutTax'] = $fac->pad($totalAmountWithoutTax, 'Item/TotalCost', Facturae::PRECISION_LINE); + $addProps['grossAmount'] = $fac->pad($grossAmount, 'Item/GrossAmount', Facturae::PRECISION_LINE); + $addProps['totalTaxesOutputs'] = $fac->pad($totalTaxesOutputs, 'TotalTaxOutputs', Facturae::PRECISION_LINE); + $addProps['totalTaxesWithheld'] = $fac->pad($totalTaxesWithheld, 'TotalTaxesWithheld', Facturae::PRECISION_LINE); return array_merge(get_object_vars($this), $addProps); } diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index a0ff1d5..2e668fc 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -183,7 +183,7 @@ public function export($filePath=null) { // Add invoice items $xml .= ''; foreach ($this->items as $itemObj) { - $item = $itemObj->getData(); + $item = $itemObj->getData($this); $xml .= ''; // Add optional fields diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index d067bb4..9ccaf0c 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -13,6 +13,7 @@ trait PropertiesTrait { protected $currency = "EUR"; protected $language = "es"; protected $version = null; + protected $precision = self::PRECISION_LINE; protected $header = array( "serie" => null, "number" => null, @@ -72,6 +73,26 @@ public function getSchemaVersion() { } + /** + * Get rounding precision + * @return int Rounding precision + */ + public function getPrecision() { + return $this->precision; + } + + + /** + * Set rounding precision + * @param int $precision Rounding precision + * @return Facturae Invoice instance + */ + public function setPrecision($precision) { + $this->precision = $precision; + return $this; + } + + /** * Set assignee * @param FacturaeParty $assignee Assignee information @@ -615,7 +636,7 @@ public function getTotals() { // Run through every item foreach ($this->items as $itemObj) { - $item = $itemObj->getData(); + $item = $itemObj->getData($this); $totals['grossAmount'] += $item['grossAmount']; $totals['totalTaxesOutputs'] += $item['totalTaxesOutputs']; $totals['totalTaxesWithheld'] += $item['totalTaxesWithheld']; diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index b4c313f..f815460 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -20,11 +20,17 @@ public static function isWithheldTax($taxCode) { /** * Pad amount - * @param float $val Input value - * @param string $field Field - * @return string Padded value + * @param float $val Input value + * @param string $field Field + * @param int|null $precision Precision on which to pad amount, `null` for always + * @return string Padded value (or input value if precision unmet) */ - public function pad($val, $field) { + public function pad($val, $field, $precision=null) { + // Do not pad if precision unmet + if (!is_null($precision) && $precision !== $this->precision) { + return $val; + } + // Get decimals $decimals = isset(self::$DECIMALS[$this->version]) ? self::$DECIMALS[$this->version] : self::$DECIMALS['']; $decimals = isset($decimals[$field]) ? $decimals[$field] : $decimals['']; diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php deleted file mode 100644 index c6f7052..0000000 --- a/tests/DecimalsTest.php +++ /dev/null @@ -1,79 +0,0 @@ -getBaseInvoice($schema); - - // Add items with random values - $pricePow = 10 ** self::PRICE_DECIMALS; - $quantityPow = 10 ** self::QUANTITY_DECIMALS; - $taxPow = 10 ** self::TAX_DECIMALS; - for ($i=0; $iaddItem(new FacturaeItem([ - "name" => "Línea de producto #$i", - "quantity" => $quantity, - "unitPrice" => $unitPrice, - "taxes" => [ - Facturae::TAX_IVA => 10, - Facturae::TAX_IRPF => 15, - Facturae::TAX_OTHER => $specialTax - ] - ])); - } - for ($i=0; $iaddCharge("Cargo #$i", $charge); - } - - // Validate invoice totals - $invoiceXml = new \SimpleXMLElement($fac->export()); - $invoiceXml = $invoiceXml->Invoices->Invoice[0]; - - $beforeTaxes = floatval($invoiceXml->InvoiceTotals->TotalGrossAmountBeforeTaxes); - $taxOutputs = floatval($invoiceXml->InvoiceTotals->TotalTaxOutputs); - $taxesWithheld = floatval($invoiceXml->InvoiceTotals->TotalTaxesWithheld); - $invoiceTotal = floatval($invoiceXml->InvoiceTotals->InvoiceTotal); - $actualTotal = floatval($beforeTaxes + $taxOutputs - $taxesWithheld); - - return (abs($invoiceTotal-$actualTotal) < 0.000000001); - } - - - /** - * Test decimals - */ - public function testDecimals() { - $totalCount = 0; - $successCount = 0; - foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { - for ($i=0; $i_runTest($schema)) $successCount++; - $totalCount++; - } - } - $this->assertEquals($totalCount, $successCount); - } - -} diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index c46f84d..54a5dc0 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -22,6 +22,9 @@ final class InvoiceTest extends AbstractTest { public function testCreateInvoice($schemaVersion, $isPfx) { // Creamos la factura $fac = new Facturae($schemaVersion); + if ($isPfx) { + $fac->setPrecision(Facturae::PRECISION_INVOICE); + } // Asignamos el número EMP2017120003 a la factura // Nótese que Facturae debe recibir el lote y el @@ -156,7 +159,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { ] ])); - // Para terminar, añadimos 3 bombillas LED con un coste de 6,50 € ... + // Añadimos 3 bombillas LED con un coste de 6,50 € ... // ... pero con los impuestos NO INCLUÍDOS en el precio unitario $fac->addItem(new FacturaeItem([ "name" => "Bombilla LED", @@ -165,6 +168,10 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "taxes" => array(Facturae::TAX_IVA => 21) ])); + // Añadimos varias líneas para jugar con la precisión + $fac->addItem("Para precisión #1", 37.76, 1, Facturae::TAX_IVA, 21); + $fac->addItem("Para precisión #2", 21.67, 1, Facturae::TAX_IVA, 21); + // Añadimos una declaración responsable $fac->addLegalLiteral("Este es un mensaje de prueba que se incluirá " . "dentro del campo LegalLiterals del XML de la factura"); diff --git a/tests/PrecisionTest.php b/tests/PrecisionTest.php new file mode 100644 index 0000000..8016bf1 --- /dev/null +++ b/tests/PrecisionTest.php @@ -0,0 +1,65 @@ +getBaseInvoice($schema); + $fac->setPrecision($precision); + + // Add items + $amounts = [37.76, 26.8, 5.5]; + foreach ($amounts as $i=>$amount) { + $fac->addItem(new FacturaeItem([ + "name" => "Línea de producto #$i", + "quantity" => 1, + "unitPriceWithoutTax" => $amount, + "taxes" => [Facturae::TAX_IVA => 21] + ])); + } + + // Generate invoice + $invoiceXml = new \SimpleXMLElement($fac->export()); + $invoiceXml = $invoiceXml->Invoices->Invoice[0]; + + // Validate + $beforeTaxes = floatval($invoiceXml->InvoiceTotals->TotalGrossAmountBeforeTaxes); + $taxOutputs = floatval($invoiceXml->InvoiceTotals->TotalTaxOutputs); + $taxesWithheld = floatval($invoiceXml->InvoiceTotals->TotalTaxesWithheld); + $invoiceTotal = floatval($invoiceXml->InvoiceTotals->InvoiceTotal); + $actualTotal = floatval($beforeTaxes + $taxOutputs - $taxesWithheld); + $this->assertEquals($actualTotal, $invoiceTotal, 'Incorrect invoice totals element', 0.000000001); + + // Validate total invoice amount + if ($precision === Facturae::PRECISION_INVOICE) { + $expectedTotal = round(array_sum($amounts)*1.21, 2); + } else { + $expectedTotal = array_sum(array_map(function($amount) { + return round($amount*1.21, 2); + }, $amounts)); + } + $this->assertEquals($expectedTotal, $invoiceTotal, 'Incorrect total invoice amount', 0.000000001); + } + + + /** + * Test line precision + */ + public function testLinePrecision() { + foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { + $this->_runTest($schema, Facturae::PRECISION_LINE); + } + } + + + /** + * Test invoice precision + */ + public function testInvoicePrecision() { + foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { + $this->_runTest($schema, Facturae::PRECISION_INVOICE); + } + } +} From 5096d44eae65423e7f5fbd296bd70ca57773598c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 22 Dec 2021 18:48:21 +0100 Subject: [PATCH 063/146] =?UTF-8?q?A=C3=B1adida=20documentaci=C3=B3n=20de?= =?UTF-8?q?=20modos=20de=20precisi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado anexo de constantes - Añadido propiedades/precision.md > Relacionado con #81 --- doc/anexos/constantes.md | 9 +++++++ doc/propiedades/precision.md | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 doc/propiedades/precision.md diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 0bb2b9e..3fa81d2 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -18,6 +18,15 @@ permalink: /anexos/constantes.html --- +## Modos de precisión + +|Constante|Descripción| +|--------:|:----------| +|`Facturae::PRECISION_LINE`|Precisión a nivel de línea| +|`Facturae::PRECISION_INVOICE`|Precisión a nivel de factura| + +--- + ## Formas de pago |Constante|Descripción| diff --git a/doc/propiedades/precision.md b/doc/propiedades/precision.md new file mode 100644 index 0000000..1f02620 --- /dev/null +++ b/doc/propiedades/precision.md @@ -0,0 +1,51 @@ +--- +title: Precisión +parent: Propiedades de una factura +nav_order: 7 +permalink: /propiedades/precision.html +--- + +# Precisión +Facturae-PHP ofrece dos formas distintas (modos de precisión) de calcular los totales de una factura. + +Por defecto, el modo de precisión a utilizar es `Facturae::PRECISION_LINE` por compatibilidad con versiones anteriores +de la librería, aunque se puede cambiar llamando al siguiente método: +```php +$fac->setPrecision(Facturae::PRECISION_INVOICE); +``` + +## Precisión a nivel de línea +En este modo se prefiere que la suma de los totales de líneas de producto sea más precisa aunque como consecuencia cambien +los importes totales de la factura. Se corresponde con la constante `Facturae::PRECISION_LINE`. + +Supongamos que tenemos una factura con las siguientes líneas de producto: + +- 37,76 € de base imponible + IVA al 21% +- 26,80 € de base imponible + IVA al 21% +- 5,50 € de base imponible + IVA al 21% + +Para esta configuración el total de la factura sería de **84,78 €**: + +- 37,76 € × 1,21 = 45,6896 € ≈ 45,69 € +- 26,80 € × 1,21 = 32,428 € ≈ 32,43 € +- 5,50 € × 1,21 = 6,655 € ≈ 6,66 € + +Total de la factura: 45,69 + 32,43 + 6,66 = 84,78 € + +## Precisión a nivel de factura +Al contrario que en el modo anterior, esta precisión prefiere mantener el total de la factura lo más fiel posible a los +importes originales. Se corresponde con la constante `Facturae::PRECISION_INVOICE`. + +Supongamos que tenemos una factura con las siguientes líneas de producto: + +- 37,76 € de base imponible + IVA al 21% +- 26,80 € de base imponible + IVA al 21% +- 5,50 € de base imponible + IVA al 21% + +Para esta configuración el total de la factura sería de **84,77 €**: + +- 37,76 € × 1,21 = 45,6896 € (aunque en la factura se muestra 45,69 €) +- 26,80 € × 1,21 = 32,428 € (aunque en la factura se muestra 32,43 €) +- 5,50 € × 1,21 = 6,655 € (aunque en la factura se muestra 6,66 €) + +Total de la factura: 45,6896 + 32,428 + 6,655 = 84,7726 € ≈ 84,77 € From dea6a9ce1c1f68b20f5171133a87005a0938a0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 5 Feb 2022 10:03:25 +0100 Subject: [PATCH 064/146] Actualizado workflow de tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadido PHP 8.1 y 8.2 a matriz de tests - Actualizado PHPUnit bridge --- .github/workflows/tests.yml | 6 +++--- composer.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9aca49b..0b3e2e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,12 +13,12 @@ jobs: continue-on-error: ${{ matrix.experimental || false }} strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4'] + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] include: - - php-version: '8.0' + - php-version: '8.1' test-ws: true send-coverage: true - - php-version: '8.1' + - php-version: '8.2' experimental: true steps: # Download code from repository diff --git a/composer.json b/composer.json index c6c04ea..60129e8 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "lib-libxml": "For parsing SOAP XML responses for FACe and FACeB2B" }, "require-dev": { - "symfony/phpunit-bridge": "^5.2" + "symfony/phpunit-bridge": "^6.0" }, "autoload-dev": { "psr-4": { From 8b9a3332f4a1feb1dfae56874f516409f54fb2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 5 Feb 2022 10:09:49 +0100 Subject: [PATCH 065/146] Arreglado PHPUnit bridge - Actualizado composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 60129e8..6f183ec 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "lib-libxml": "For parsing SOAP XML responses for FACe and FACeB2B" }, "require-dev": { - "symfony/phpunit-bridge": "^6.0" + "symfony/phpunit-bridge": "*" }, "autoload-dev": { "psr-4": { From ea8622702bb3ae25797ff97705630b143aa02d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 5 Feb 2022 10:13:43 +0100 Subject: [PATCH 066/146] v1.7.0 [skip ci] --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index a461f68..e2089ad 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.6.1"; + const VERSION = "1.7.0"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 11515a6b3b01f4bec7e830cfc26353107820f72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 18 Apr 2022 18:40:20 +0200 Subject: [PATCH 067/146] Actualizado certificado webservices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sustituido certificado para webservices de FACe/FACeB2B al caducar el anterior > NOTA: el nuevo certificado pertenece al kit de pruebas de producción de IZENPE S.A., y es concretamente "representante_spj_scard_act.p12" --- tests/AbstractTest.php | 2 +- tests/certs/webservices.p12 | Bin 5196 -> 5178 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 6201c00..02b4b1c 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -10,7 +10,7 @@ abstract class AbstractTest extends TestCase { const OUTPUT_DIR = __DIR__ . "/output"; const CERTS_DIR = __DIR__ . "/certs"; const FACTURAE_CERT_PASS = "1234"; - const WEBSERVICES_CERT_PASS = "741258"; + const WEBSERVICES_CERT_PASS = "IZProd2021"; const NOTIFICATIONS_EMAIL = "josemmo@pm.me"; const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; diff --git a/tests/certs/webservices.p12 b/tests/certs/webservices.p12 index 5b1d3e2482e0a0119170eb086305920fa7e06f98..53c7051f3f8b7a47dfbfba7c39ea5d36777b454f 100644 GIT binary patch delta 4700 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 STnlJTTSaJ3q3gf@$bSHOnjWkG From 3d6f1d87c040e3a01c05816bf369f3ae5cf1af03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 18 Apr 2022 18:44:40 +0200 Subject: [PATCH 068/146] =?UTF-8?q?A=C3=B1adida=20compatibilidad=20con=20P?= =?UTF-8?q?HP=208.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arreglado aviso "Creation of dynamic property [...] is deprecated" --- src/FacturaeParty.php | 4 ++-- src/FacturaeTraits/SignableTrait.php | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 93db675..4e2df46 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -51,8 +51,8 @@ class FacturaeParty { */ public function __construct($properties=array()) { foreach ($properties as $key=>$value) $this->{$key} = $value; - if (isset($this->merchantRegister)) { - $this->registerOfCompaniesLocation = $this->merchantRegister; + if (isset($properties['merchantRegister'])) { + $this->registerOfCompaniesLocation = $properties['merchantRegister']; } } diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index e9134b0..205ce9d 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -17,6 +17,15 @@ trait SignableTrait { private $publicChain = []; private $privateKey = null; + private $signatureID; + private $signedInfoID; + private $signedPropertiesID; + private $signatureValueID; + private $certificateID; + private $referenceID; + private $signatureSignedPropertiesID; + private $signatureObjectID; + /** * Set sign time * @param int|string $time Time of the signature From e09e4d5952023d7c0e967c28dc50b0c7e62e17aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 18 Apr 2022 18:50:57 +0200 Subject: [PATCH 069/146] =?UTF-8?q?A=C3=B1adida=20compatibilidad=20con=20P?= =?UTF-8?q?HP=208.2=20(II)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeParty --- src/FacturaeParty.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 4e2df46..c8a3feb 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -50,9 +50,9 @@ class FacturaeParty { * @param array $properties Party properties as an array */ public function __construct($properties=array()) { - foreach ($properties as $key=>$value) $this->{$key} = $value; - if (isset($properties['merchantRegister'])) { - $this->registerOfCompaniesLocation = $properties['merchantRegister']; + foreach ($properties as $key=>$value) { + if ($key === "merchantRegister") $key = "registerOfCompaniesLocation"; + $this->{$key} = $value; } } From bdc428e3e76646badd7bb913928758ad55bcf0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 8 May 2022 10:30:04 +0200 Subject: [PATCH 070/146] v1.7.1 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index e2089ad..4ddade0 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.0"; + const VERSION = "1.7.1"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 199b412cb6f623a60671aa6adb3c98bfbd936653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 8 May 2022 10:40:29 +0200 Subject: [PATCH 071/146] =?UTF-8?q?Arreglada=20interpolaci=C3=B3n=20de=20s?= =?UTF-8?q?trings=20en=20PHP=208.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado ExportableTrait > Soluciona "Deprecated: Using ${var} in strings is deprecated, use {$var} instead" --- src/FacturaeTraits/ExportableTrait.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 2e668fc..14bd46f 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -160,11 +160,11 @@ public function export($filePath=null) { $xml .= '<' . $generalGroups[$g][0] . '>'; foreach ($totals[$groupTag] as $elem) { $xml .= "<$xmlTag>"; - $xml .= "<${xmlTag}Reason>" . $tools->escape($elem['reason']) . ""; + $xml .= "<{$xmlTag}Reason>" . $tools->escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { - $xml .= "<${xmlTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; + $xml .= "<{$xmlTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } - $xml .="<${xmlTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; + $xml .="<{$xmlTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; $xml .= ""; } $xml .= ''; @@ -213,11 +213,11 @@ public function export($filePath=null) { $xml .= '<' . $itemGroups[$g][0] . '>'; foreach ($item[$group] as $elem) { $xml .= "<$groupTag>"; - $xml .= "<${groupTag}Reason>" . $tools->escape($elem['reason']) . ""; + $xml .= "<{$groupTag}Reason>" . $tools->escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { - $xml .= "<${groupTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; + $xml .= "<{$groupTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } - $xml .="<${groupTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; + $xml .="<{$groupTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . ""; $xml .= ""; } $xml .= ''; From a831d192303018d0b2e1e564f01db4eba28d9dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 19 May 2022 18:34:00 +0200 Subject: [PATCH 072/146] =?UTF-8?q?Arreglado=20c=C3=A1lculo=20de=20importe?= =?UTF-8?q?=20bruto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado Facturae::getTotals() - Actualizado Facturae::export() - Actualizados tests > Fixes #91 --- src/FacturaeTraits/ExportableTrait.php | 11 +++-- src/FacturaeTraits/PropertiesTrait.php | 56 +++++++++++++++----------- tests/DiscountsTest.php | 30 +++++++++----- 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 14bd46f..d41dc4a 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -235,19 +235,24 @@ public function export($filePath=null) { $xmlTag = ucfirst($taxesGroup); // Just capitalize variable name $xml .= "<$xmlTag>"; foreach ($item[$taxesGroup] as $type=>$tax) { + $initialBase = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts']; + $initialAmount = $initialBase * ($tax['rate'] / 100); $xml .= '' . '' . $type . '' . '' . $this->pad($tax['rate'], 'Tax/TaxRate') . '' . '' . - '' . $this->pad($tax['base'], 'Tax/TaxableBase') . '' . + '' . $this->pad($initialBase+$tax['base'], 'Tax/TaxableBase') . '' . '' . '' . - '' . $this->pad($tax['amount'], 'Tax/TaxAmount') . '' . + '' . $this->pad($initialAmount+$tax['amount'], 'Tax/TaxAmount') . '' . ''; if ($tax['surcharge'] != 0) { + $initialSurcharge = $initialBase * ($tax['surcharge'] / 100); $xml .= '' . $this->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge') . '' . '' . - '' . $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . '' . + '' . + $this->pad($initialSurcharge+$tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . + '' . ''; } $xml .= ''; diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 9ccaf0c..2ef8c9d 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -634,10 +634,35 @@ public function getTotals() { "totalTaxesWithheld" => 0 ); - // Run through every item + // Precalculate total global amount (needed for general discounts and charges) + $items = []; foreach ($this->items as $itemObj) { $item = $itemObj->getData($this); $totals['grossAmount'] += $item['grossAmount']; + $items[] = $item; + } + + // Get general discounts and charges + foreach (['discounts', 'charges'] as $groupTag) { + foreach ($this->{$groupTag} as $item) { + if ($item['rate'] === null) { + $rate = null; + $amount = $item['amount']; + } else { + $rate = $item['rate']; + $amount = $totals['grossAmount'] * ($rate / 100); + } + $totals['general' . ucfirst($groupTag)][] = [ + "reason" => $item['reason'], + "rate" => $rate, + "amount" => $amount + ]; + $totals['totalGeneral' . ucfirst($groupTag)] += $amount; + } + } + + // Run through every item + foreach ($items as &$item) { $totals['totalTaxesOutputs'] += $item['totalTaxesOutputs']; $totals['totalTaxesWithheld'] += $item['totalTaxesWithheld']; @@ -649,13 +674,17 @@ public function getTotals() { } $taxKey = $tax['rate'] . ":" . $tax['surcharge']; if (!isset($totals[$taxGroup][$type][$taxKey])) { + $initialBase = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts']; + $initialAmount = $initialBase * ($tax['rate'] / 100); + $initialSurcharge = $initialBase * ($tax['surcharge'] / 100); $totals[$taxGroup][$type][$taxKey] = array( - "base" => 0, + "base" => $initialBase, "rate" => $tax['rate'], "surcharge" => $tax['surcharge'], - "amount" => 0, - "surchargeAmount" => 0 + "amount" => $initialAmount, + "surchargeAmount" => $initialSurcharge ); + $totals['total' . ucfirst($taxGroup)] += $initialAmount + $initialSurcharge; } $totals[$taxGroup][$type][$taxKey]['base'] += $tax['base']; $totals[$taxGroup][$type][$taxKey]['amount'] += $tax['amount']; @@ -664,25 +693,6 @@ public function getTotals() { } } - // Get general discounts and charges - foreach (['discounts', 'charges'] as $groupTag) { - foreach ($this->{$groupTag} as $item) { - if (is_null($item['rate'])) { - $rate = null; - $amount = $item['amount']; - } else { - $rate = $item['rate']; - $amount = $totals['grossAmount'] * ($rate / 100); - } - $totals['general' . ucfirst($groupTag)][] = array( - "reason" => $item['reason'], - "rate" => $rate, - "amount" => $amount - ); - $totals['totalGeneral' . ucfirst($groupTag)] += $amount; - } - } - // Pre-round some total values (needed to create a sum-reasonable invoice total) $totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs'); $totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld'); diff --git a/tests/DiscountsTest.php b/tests/DiscountsTest.php index de85b84..6fb4982 100644 --- a/tests/DiscountsTest.php +++ b/tests/DiscountsTest.php @@ -90,10 +90,23 @@ public function testItemDiscounts() { */ public function testGeneralDiscounts() { $fac = $this->getBaseInvoice(); - $fac->addItem('Test item', 100, 1, Facturae::TAX_IVA, 25); - $fac->addDiscount('Half price', 50); - $fac->addDiscount('5€ off', 5, false); - $fac->addCharge('Twice as much', 50); + $fac->addItem(new FacturaeItem([ + "name" => "Test item #1", + "unitPriceWithoutTax" => 90, + "taxes" => [ + Facturae::TAX_IVA => ["rate"=>21, "surcharge"=>5.2] + ] + ])); + $fac->addItem(new FacturaeItem([ + "name" => "Test item #2", + "unitPriceWithoutTax" => 50, + "taxes" => [ + Facturae::TAX_IVA => ["rate"=>10, "surcharge"=>1.4] + ] + ])); + $fac->addCharge('10% charge', 10); + $fac->addDiscount('10% discount', 10); + $fac->addDiscount('Fixed amount discount', 25.20, false); // Generate invoice and validate output $invoiceXml = new \SimpleXMLElement($fac->export()); @@ -101,12 +114,9 @@ public function testGeneralDiscounts() { $totalDiscounts = floatval($invoiceXml->InvoiceTotals->TotalGeneralDiscounts); $totalCharges = floatval($invoiceXml->InvoiceTotals->TotalGeneralSurcharges); $invoiceTotal = floatval($invoiceXml->InvoiceTotals->InvoiceTotal); - $expectedDiscounts = (100 / 1.25) * 0.5 + 5; - $expectedCharges = (100 / 1.25) * 0.5; - $expectedTotal = 100 - $expectedDiscounts + $expectedCharges; - $this->assertEqualsWithDelta($totalDiscounts, $expectedDiscounts, 0.00001); - $this->assertEqualsWithDelta($totalCharges, $expectedCharges, 0.00001); - $this->assertEqualsWithDelta($invoiceTotal, $expectedTotal, 0.00001); + $this->assertEqualsWithDelta($totalDiscounts, 39.20, 0.00001); + $this->assertEqualsWithDelta($totalCharges, 14.00, 0.00001); + $this->assertEqualsWithDelta($invoiceTotal, 134.60, 0.00001); } } From 5312603872823e353e4e9465513af49f670fd947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 29 May 2022 11:24:38 +0200 Subject: [PATCH 073/146] =?UTF-8?q?A=C3=B1adido=20periodo=20de=20prestaci?= =?UTF-8?q?=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidas propiedades "periodStart" y "periodEnd" a FacturaeItem - Actualizado Facturae::export() - Actualizados test unitarios - Actualizada documentación > Closes #92 --- doc/productos/campos-opcionales.md | 2 ++ src/FacturaeItem.php | 2 ++ src/FacturaeTraits/ExportableTrait.php | 8 ++++++++ tests/InvoiceTest.php | 4 +++- 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/productos/campos-opcionales.md b/doc/productos/campos-opcionales.md index 0b06a54..c4bf638 100644 --- a/doc/productos/campos-opcionales.md +++ b/doc/productos/campos-opcionales.md @@ -14,6 +14,8 @@ $fac->addItem(new FacturaeItem([ "fileReference" => "000298172", // Referencia del expediente "fileDate" => "2010-03-10", // Fecha del expediente "sequenceNumber" => "1.0", // Número de secuencia o línea del pedido + "periodStart" => "2022-01-01", // Inicio del periodo de prestación de un servicio + "periodEnd" => "2022-01-31", // Fin del periodo de prestación de un servicio // Campos relativos al contrato del emisor "issuerContractReference" => "A9938281", // Referencia diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index cd06457..e64a222 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -31,6 +31,8 @@ class FacturaeItem { private $fileReference = null; private $fileDate = null; private $sequenceNumber = null; + private $periodStart = null; + private $periodEnd = null; /** diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index d41dc4a..2e49a7c 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -260,6 +260,14 @@ public function export($filePath=null) { $xml .= ""; } + // Add line period dates + if (!empty($item['periodStart']) && !empty($item['periodEnd'])) { + $xml .= ''; + $xml .= '' . $tools->escape($item['periodStart']) . ''; + $xml .= '' . $tools->escape($item['periodEnd']) . ''; + $xml .= ''; + } + // Add more optional fields $xml .= $this->addOptionalFields($item, [ "description" => "AdditionalLineItemInformation", diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 54a5dc0..7e5537c 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -130,7 +130,9 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "receiverTransactionDate" => "2010-03-10", "fileReference" => "000298172", "fileDate" => "2010-03-10", - "sequenceNumber" => "1.0" + "sequenceNumber" => "1.0", + "periodStart" => "2022-01-01", + "periodEnd" => "2022-01-31" ])); // Por defecto, Facturae-PHP asume que el IRPF es un impuesto retenido y el From 232f88fcc86d91c62ce912a7d49b0c30e05fb925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 29 May 2022 14:27:49 +0200 Subject: [PATCH 074/146] =?UTF-8?q?Arreglado=20c=C3=A1lculo=20de=20impuest?= =?UTF-8?q?os=20totales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado Facturae::export() - Actualizado Facturae::getTotals() - Actualizados test unitarios > Fixes #91 --- src/FacturaeTraits/ExportableTrait.php | 9 +++------ src/FacturaeTraits/PropertiesTrait.php | 27 +++++++++++++++++++------- tests/DiscountsTest.php | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 2e49a7c..fe67dd7 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -235,23 +235,20 @@ public function export($filePath=null) { $xmlTag = ucfirst($taxesGroup); // Just capitalize variable name $xml .= "<$xmlTag>"; foreach ($item[$taxesGroup] as $type=>$tax) { - $initialBase = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts']; - $initialAmount = $initialBase * ($tax['rate'] / 100); $xml .= '' . '' . $type . '' . '' . $this->pad($tax['rate'], 'Tax/TaxRate') . '' . '' . - '' . $this->pad($initialBase+$tax['base'], 'Tax/TaxableBase') . '' . + '' . $this->pad($tax['base'], 'Tax/TaxableBase') . '' . '' . '' . - '' . $this->pad($initialAmount+$tax['amount'], 'Tax/TaxAmount') . '' . + '' . $this->pad($tax['amount'], 'Tax/TaxAmount') . '' . ''; if ($tax['surcharge'] != 0) { - $initialSurcharge = $initialBase * ($tax['surcharge'] / 100); $xml .= '' . $this->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge') . '' . '' . '' . - $this->pad($initialSurcharge+$tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . + $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . '' . ''; } diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 2ef8c9d..599e55f 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -660,6 +660,7 @@ public function getTotals() { $totals['totalGeneral' . ucfirst($groupTag)] += $amount; } } + $effectiveGeneralCharge = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts']; // Run through every item foreach ($items as &$item) { @@ -674,17 +675,13 @@ public function getTotals() { } $taxKey = $tax['rate'] . ":" . $tax['surcharge']; if (!isset($totals[$taxGroup][$type][$taxKey])) { - $initialBase = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts']; - $initialAmount = $initialBase * ($tax['rate'] / 100); - $initialSurcharge = $initialBase * ($tax['surcharge'] / 100); $totals[$taxGroup][$type][$taxKey] = array( - "base" => $initialBase, + "base" => 0, "rate" => $tax['rate'], "surcharge" => $tax['surcharge'], - "amount" => $initialAmount, - "surchargeAmount" => $initialSurcharge + "amount" => 0, + "surchargeAmount" => 0 ); - $totals['total' . ucfirst($taxGroup)] += $initialAmount + $initialSurcharge; } $totals[$taxGroup][$type][$taxKey]['base'] += $tax['base']; $totals[$taxGroup][$type][$taxKey]['amount'] += $tax['amount']; @@ -693,6 +690,22 @@ public function getTotals() { } } + // Apply effective general discounts and charges to total taxes + if ($effectiveGeneralCharge != 0) { + foreach (["taxesOutputs", "taxesWithheld"] as $taxGroup) { + $totals['total' . ucfirst($taxGroup)] = 0; // Reset total (needs to be recalculated) + foreach ($totals[$taxGroup] as $type=>&$taxes) { + foreach ($taxes as &$tax) { + $proportion = $tax['base'] / $totals['grossAmount']; + $tax['base'] += $effectiveGeneralCharge * $proportion; + $tax['amount'] = $tax['base'] * ($tax['rate'] / 100); + $tax['surchargeAmount'] = $tax['base'] * ($tax['surcharge'] / 100); + $totals['total' . ucfirst($taxGroup)] += $tax['amount'] + $tax['surchargeAmount']; + } + } + } + } + // Pre-round some total values (needed to create a sum-reasonable invoice total) $totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs'); $totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld'); diff --git a/tests/DiscountsTest.php b/tests/DiscountsTest.php index 6fb4982..d661b2d 100644 --- a/tests/DiscountsTest.php +++ b/tests/DiscountsTest.php @@ -116,7 +116,7 @@ public function testGeneralDiscounts() { $invoiceTotal = floatval($invoiceXml->InvoiceTotals->InvoiceTotal); $this->assertEqualsWithDelta($totalDiscounts, 39.20, 0.00001); $this->assertEqualsWithDelta($totalCharges, 14.00, 0.00001); - $this->assertEqualsWithDelta($invoiceTotal, 134.60, 0.00001); + $this->assertEqualsWithDelta($invoiceTotal, 138.81, 0.00001); } } From 71de8e5908fd8d32781989b857520476d5a6a049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 13 Sep 2022 19:47:49 +0200 Subject: [PATCH 075/146] Cambiado digest de firma a SHA-512 - Actualizado SignableTrait.php - Actualizado XmlTools.php --- src/Common/XmlTools.php | 4 ++-- src/FacturaeTraits/SignableTrait.php | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index a531194..3e9770d 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -120,14 +120,14 @@ public function getCertDigest($publicKey, $pretty=false) { /** - * Get signature in SHA-1 + * Get signature in SHA-512 * @param string $payload Data to sign * @param string $privateKey Private Key * @param boolean $pretty Pretty Base64 response * @return string Base64 Signature */ public function getSignature($payload, $privateKey, $pretty=true) { - openssl_sign($payload, $signature, $privateKey); + openssl_sign($payload, $signature, $privateKey, OPENSSL_ALGO_SHA512); return $this->toBase64($signature, $pretty); } diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 205ce9d..a631d0d 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -179,7 +179,7 @@ protected function injectSignature($xml) { $sInfo = '' . "\n" . '' . '' . "\n" . - '' . + '' . '' . "\n" . ''; $payload = $tools->injectNamespaces($payload, $this->getNamespaces()); - // Create TimeStampQuery in ASN1 using SHA-1 - $tsq = "302c0201013021300906052b0e03021a05000414"; - $tsq .= hash('sha1', $payload); - $tsq .= "0201000101ff"; - $tsq = hex2bin($tsq); + // Create TimeStampQuery in ASN1 using SHA-512 + $tsq = "\x30\x59\x02\x01\x01\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40"; + $tsq .= hash('sha512', $payload, true); + $tsq .= "\x01\x01\xff"; // Await TimeStampRequest $chOpts = array( @@ -280,7 +279,7 @@ private function injectTimestamp($signedXml) { // Validate TimeStampRequest $responseCode = substr($tsr, 6, 3); - if ($responseCode !== "\02\01\00") { // Bytes for INTEGER 0 in ASN1 + if ($responseCode !== "\x02\x01\x00") { // Bytes for INTEGER 0 in ASN1 throw new \Exception('Invalid TSR response code'); } From 3955e0a1518af3652fc614c25b7b488b1f7636cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Tue, 13 Sep 2022 19:53:50 +0200 Subject: [PATCH 076/146] Cambiado digest de firma en webservices - Actualizada clase SoapClient --- src/Face/SoapClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 074ebae..215f53f 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -96,7 +96,7 @@ protected function request($body) { $signedInfo = '' . '' . '' . - '' . + '' . '' . '' . '' . $timestampDigest . '' . From 179426a536bf8f61706f25891a16e3ed95d314e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 26 Sep 2022 21:00:11 +0200 Subject: [PATCH 077/146] =?UTF-8?q?Arreglado=20test=20de=20precisi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase PrecisionTest --- tests/PrecisionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PrecisionTest.php b/tests/PrecisionTest.php index 8016bf1..344c480 100644 --- a/tests/PrecisionTest.php +++ b/tests/PrecisionTest.php @@ -30,7 +30,7 @@ private function _runTest($schema, $precision) { $taxesWithheld = floatval($invoiceXml->InvoiceTotals->TotalTaxesWithheld); $invoiceTotal = floatval($invoiceXml->InvoiceTotals->InvoiceTotal); $actualTotal = floatval($beforeTaxes + $taxOutputs - $taxesWithheld); - $this->assertEquals($actualTotal, $invoiceTotal, 'Incorrect invoice totals element', 0.000000001); + $this->assertEqualsWithDelta($actualTotal, $invoiceTotal, 0.000000001, 'Incorrect invoice totals element'); // Validate total invoice amount if ($precision === Facturae::PRECISION_INVOICE) { @@ -40,7 +40,7 @@ private function _runTest($schema, $precision) { return round($amount*1.21, 2); }, $amounts)); } - $this->assertEquals($expectedTotal, $invoiceTotal, 'Incorrect total invoice amount', 0.000000001); + $this->assertEqualsWithDelta($expectedTotal, $invoiceTotal, 0.000000001, 'Incorrect total invoice amount'); } From e5c5d0ffeae75494d7edd936785ab396419f8bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 26 Sep 2022 21:01:19 +0200 Subject: [PATCH 078/146] Arreglado typo en FaceB2B - Actualizado trait Faceb2bTrait > Related to #98 --- src/Face/Traits/Faceb2bTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php index c5937d6..8a90a7a 100644 --- a/src/Face/Traits/Faceb2bTrait.php +++ b/src/Face/Traits/Faceb2bTrait.php @@ -80,7 +80,7 @@ public function requestInvoiceCancellation($regId, $reason, $comment=null) { */ public function getRegisteredInvoices($receivingUnit=null) { $req = ''; - if (is_null($receivingUnit)) { + if (!is_null($receivingUnit)) { $req .= '' . $receivingUnit . ''; } $req .= ''; From 37a2f3c82ea88c49a216364dc857e40bb4fe36b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 1 Oct 2022 14:16:11 +0200 Subject: [PATCH 079/146] =?UTF-8?q?A=C3=B1adido=20soporte=20para=20m=C3=BA?= =?UTF-8?q?ltiples=20pagos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creada clase FacturaePayment - Actualizados métodos de PropertiesTrait - Actualizado ExportableTrait::getPaymentDetailsXML() - Actualizado InvoiceTest - Marcadas constantes de pagos como "deprecated" en clase Facturae > Closes #100 --- src/Facturae.php | 19 +++++++ src/FacturaePayment.php | 68 +++++++++++++++++++++++++ src/FacturaeTraits/ExportableTrait.php | 35 +++++++------ src/FacturaeTraits/PropertiesTrait.php | 70 +++++++++++++++++++------- tests/InvoiceTest.php | 19 ++++++- 5 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 src/FacturaePayment.php diff --git a/src/Facturae.php b/src/Facturae.php index 4ddade0..764c7bc 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -25,24 +25,43 @@ class Facturae { const PRECISION_LINE = 1; const PRECISION_INVOICE = 2; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CASH = "01"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_DEBIT = "02"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_RECEIPT = "03"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_TRANSFER = "04"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_ACCEPTED_BILL_OF_EXCHANGE = "05"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_DOCUMENTARY_CREDIT = "06"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CONTRACT_AWARD = "07"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_BILL_OF_EXCHANGE = "08"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_TRANSFERABLE_IOU = "09"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_IOU = "10"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CHEQUE = "11"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_REIMBURSEMENT = "12"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_SPECIAL = "13"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_SETOFF = "14"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_POSTGIRO = "15"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CERTIFIED_CHEQUE = "16"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_BANKERS_DRAFT = "17"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CASH_ON_DELIVERY = "18"; + /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CARD = "19"; const TAX_IVA = "01"; diff --git a/src/FacturaePayment.php b/src/FacturaePayment.php new file mode 100644 index 0000000..a8ca0a4 --- /dev/null +++ b/src/FacturaePayment.php @@ -0,0 +1,68 @@ +$value) $this->{$key} = $value; + } +} diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index fe67dd7..66ac19a 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -2,6 +2,7 @@ namespace josemmo\Facturae\FacturaeTraits; use josemmo\Facturae\Common\XmlTools; +use josemmo\Facturae\FacturaePayment; /** * Allows a Facturae instance to be exported to XML. @@ -314,24 +315,30 @@ public function export($filePath=null) { * @return string Payment details XML, empty string if not available */ private function getPaymentDetailsXML($totals) { - if (is_null($this->header['paymentMethod'])) return ""; + if (empty($this->payments)) return ""; - $dueDate = is_null($this->header['dueDate']) ? $this->header['issueDate'] : $this->header['dueDate']; $xml = ''; - $xml .= ''; - $xml .= '' . date('Y-m-d', $dueDate) . ''; - $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; - $xml .= '' . $this->header['paymentMethod'] . ''; - if (!is_null($this->header['paymentIBAN'])) { - $accountType = ($this->header['paymentMethod'] == self::PAYMENT_DEBIT) ? "AccountToBeDebited" : "AccountToBeCredited"; - $xml .= "<$accountType>"; - $xml .= '' . $this->header['paymentIBAN'] . ''; - if (!is_null($this->header['paymentBIC'])) { - $xml .= '' . $this->header['paymentBIC'] . ''; + /** @var FacturaePayment $payment */ + foreach ($this->payments as $payment) { + $dueDate = is_null($payment->dueDate) ? + $this->header['issueDate'] : + (is_string($payment->dueDate) ? strtotime($payment->dueDate) : $payment->dueDate); + $amount = is_null($payment->amount) ? $totals['invoiceAmount'] : $payment->amount; + $xml .= ''; + $xml .= '' . date('Y-m-d', $dueDate) . ''; + $xml .= '' . $this->pad($amount, 'InvoiceTotal') . ''; + $xml .= '' . $payment->method . ''; + if (!is_null($payment->iban)) { + $accountType = ($payment->method == FacturaePayment::TYPE_DEBIT) ? "AccountToBeDebited" : "AccountToBeCredited"; + $xml .= "<$accountType>"; + $xml .= '' . preg_replace('/[^A-Z0-9]/', '', $payment->iban) . ''; + if (!is_null($payment->bic)) { + $xml .= '' . str_pad(preg_replace('/[^A-Z0-9]/', '', $payment->bic), 11, 'X') . ''; + } + $xml .= ""; } - $xml .= ""; + $xml .= ''; } - $xml .= ''; $xml .= ''; return $xml; diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 599e55f..81abb43 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -3,6 +3,7 @@ use josemmo\Facturae\FacturaeFile; use josemmo\Facturae\FacturaeItem; +use josemmo\Facturae\FacturaePayment; /** * Implements all attributes and methods needed to make Facturae instantiable. @@ -18,13 +19,9 @@ trait PropertiesTrait { "serie" => null, "number" => null, "issueDate" => null, - "dueDate" => null, "startDate" => null, "endDate" => null, "assignmentClauses" => null, - "paymentMethod" => null, - "paymentIBAN" => null, - "paymentBIC" => null, "description" => null, "receiverTransactionReference" => null, "fileReference" => null, @@ -42,6 +39,8 @@ trait PropertiesTrait { protected $discounts = array(); protected $charges = array(); protected $attachments = array(); + /** @var FacturaePayment[] */ + protected $payments = array(); /** @@ -222,9 +221,14 @@ public function getIssueDate() { * Set due date * @param int|string $date Due date * @return Facturae Invoice instance + * @deprecated 1.7.2 Due date is now associated to payment information. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function setDueDate($date) { - $this->header['dueDate'] = is_string($date) ? strtotime($date) : $date; + if (empty($this->payments)) { + $this->payments[] = new FacturaePayment(); + } + $this->payments[0]->dueDate = $date; return $this; } @@ -232,9 +236,13 @@ public function setDueDate($date) { /** * Get due date * @return int|null Due timestamp + * @deprecated 1.7.2 Due date is now associated to payment information. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getDueDate() { - return $this->header['dueDate']; + return empty($this->payments) ? + null : + (is_string($this->payments[0]->dueDate) ? strtotime($this->payments[0]->dueDate) : $this->payments[0]->dueDate); } @@ -271,6 +279,8 @@ public function getBillingPeriod() { * @param int|string $issueDate Issue date * @param int|string $dueDate Due date * @return Facturae Invoice instance + * @deprecated 1.7.2 Due date is now associated to payment information. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function setDates($issueDate, $dueDate=null) { $this->setIssueDate($issueDate); @@ -279,22 +289,42 @@ public function setDates($issueDate, $dueDate=null) { } + /** + * Add payment installment + * @param FacturaePayment $payment Payment details + * @return Facturae Invoice instance + */ + public function addPayment($payment) { + $this->payments[] = $payment; + return $this; + } + + + /** + * Get payment installments + * @return FacturaePayment[] Payment installments + */ + public function getPayments() { + return $this->payments; + } + + /** * Set payment method * @param string $method Payment method * @param string|null $iban Bank account number (IBAN) * @param string|null $bic SWIFT/BIC code of bank account * @return Facturae Invoice instance + * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::addPayment()` instead. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ - public function setPaymentMethod($method=self::PAYMENT_CASH, $iban=null, $bic=null) { - if (!is_null($iban)) $iban = preg_replace('/[^A-Z0-9]/', '', $iban); - if (!is_null($bic)) { - $bic = preg_replace('/[^A-Z0-9]/', '', $bic); - $bic = str_pad($bic, 11, 'X'); + public function setPaymentMethod($method=FacturaePayment::TYPE_CASH, $iban=null, $bic=null) { + if (empty($this->payments)) { + $this->payments[] = new FacturaePayment(); } - $this->header['paymentMethod'] = $method; - $this->header['paymentIBAN'] = $iban; - $this->header['paymentBIC'] = $bic; + $this->payments[0]->method = $method; + $this->payments[0]->iban = $iban; + $this->payments[0]->bic = $bic; return $this; } @@ -302,27 +332,33 @@ public function setPaymentMethod($method=self::PAYMENT_CASH, $iban=null, $bic=nu /** * Get payment method * @return string|null Payment method + * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentMethod() { - return $this->header['paymentMethod']; + return empty($this->payments) ? null : $this->payments[0]->method; } /** * Get payment IBAN * @return string|null Payment bank account IBAN + * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentIBAN() { - return $this->header['paymentIBAN']; + return empty($this->payments) ? null : $this->payments[0]->iban; } /** * Get payment BIC * @return string|null Payment bank account BIC + * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentBIC() { - return $this->header['paymentBIC']; + return empty($this->payments) ? null : $this->payments[0]->bic; } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 7e5537c..bc3c670 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -5,6 +5,7 @@ use josemmo\Facturae\FacturaeFile; use josemmo\Facturae\FacturaeItem; use josemmo\Facturae\FacturaeParty; +use josemmo\Facturae\FacturaePayment; use josemmo\Facturae\FacturaeCentre; final class InvoiceTest extends AbstractTest { @@ -183,7 +184,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $fac->addDiscount('A mitad de precio', 50); $fac->addCharge('Recargo del 50%', 50); - // Establecemos un método de pago con cesión de crédito (solo en algunos casos) + // Establecemos un un cesionario (solo en algunos casos) if ($isPfx) { $fac->setAssignee(new FacturaeParty([ "taxNumber" => "B00000000", @@ -197,6 +198,22 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "email" => "cesionario@ejemplo.com" ])); $fac->setAssignmentClauses('Cláusula de cesión'); + } + + // Establecemos el/los método(s) de pago + if ($isPfx) { + $fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_CASH, + "amount" => 100 + ])); + $fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "amount" => 199.90002, + "dueDate" => "2017-12-31", + "iban" => "ES7620770024003102575766", + "bic" => "CAHMESMM" + ])); + } else { $fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766", "CAHMESMM"); $fac->setDueDate("2017-12-31"); } From facbedf9b6a22320877af1d736a155dcc5fda55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 1 Oct 2022 14:20:49 +0200 Subject: [PATCH 080/146] =?UTF-8?q?Actualizada=20documentaci=C3=B3n=20de?= =?UTF-8?q?=20pagos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizados datos-del-pago.md y cesionarios.md - Actualizada tabla de constantes > Related to #100 --- doc/anexos/constantes.md | 38 +++++++++++------------ doc/entidades/cesionarios.md | 8 +++-- doc/propiedades/datos-del-pago.md | 50 ++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 3fa81d2..964066e 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -31,25 +31,25 @@ permalink: /anexos/constantes.html |Constante|Descripción| |--------:|:----------| -|`Facturae::PAYMENT_CASH`|Al contado| -|`Facturae::PAYMENT_DEBIT`|Recibo Domiciliado| -|`Facturae::PAYMENT_RECEIPT`|Recibo| -|`Facturae::PAYMENT_TRANSFER`|Transferencia| -|`Facturae::PAYMENT_ACCEPTED_BILL_OF_EXCHANGE`|Letra Aceptada| -|`Facturae::PAYMENT_DOCUMENTARY_CREDIT`|Crédito Documentario| -|`Facturae::PAYMENT_CONTRACT_AWARD`|Adjudicación de contrato| -|`Facturae::PAYMENT_BILL_OF_EXCHANGE`|Letra de cambio| -|`Facturae::PAYMENT_TRANSFERABLE_IOU`|Pagaré a la Orden| -|`Facturae::PAYMENT_IOU`|Pagaré No a la Orden| -|`Facturae::PAYMENT_CHEQUE`|Cheque| -|`Facturae::PAYMENT_REIMBURSEMENT`|Reposición| -|`Facturae::PAYMENT_SPECIAL`|Especiales| -|`Facturae::PAYMENT_SETOFF`|Compensación| -|`Facturae::PAYMENT_POSTGIRO`|Giro postal| -|`Facturae::PAYMENT_CERTIFIED_CHEQUE`|Cheque conformado| -|`Facturae::PAYMENT_BANKERS_DRAFT`|Cheque bancario| -|`Facturae::PAYMENT_CASH_ON_DELIVERY`|Pago contra reembolso| -|`Facturae::PAYMENT_CARD`|Pago mediante tarjeta| +|`FacturaePayment::TYPE_CASH`|Al contado| +|`FacturaePayment::TYPE_DEBIT`|Recibo Domiciliado| +|`FacturaePayment::TYPE_RECEIPT`|Recibo| +|`FacturaePayment::TYPE_TRANSFER`|Transferencia| +|`FacturaePayment::TYPE_ACCEPTED_BILL_OF_EXCHANGE`|Letra Aceptada| +|`FacturaePayment::TYPE_DOCUMENTARY_CREDIT`|Crédito Documentario| +|`FacturaePayment::TYPE_CONTRACT_AWARD`|Adjudicación de contrato| +|`FacturaePayment::TYPE_BILL_OF_EXCHANGE`|Letra de cambio| +|`FacturaePayment::TYPE_TRANSFERABLE_IOU`|Pagaré a la Orden| +|`FacturaePayment::TYPE_IOU`|Pagaré No a la Orden| +|`FacturaePayment::TYPE_CHEQUE`|Cheque| +|`FacturaePayment::TYPE_REIMBURSEMENT`|Reposición| +|`FacturaePayment::TYPE_SPECIAL`|Especiales| +|`FacturaePayment::TYPE_SETOFF`|Compensación| +|`FacturaePayment::TYPE_POSTGIRO`|Giro postal| +|`FacturaePayment::TYPE_CERTIFIED_CHEQUE`|Cheque conformado| +|`FacturaePayment::TYPE_BANKERS_DRAFT`|Cheque bancario| +|`FacturaePayment::TYPE_CASH_ON_DELIVERY`|Pago contra reembolso| +|`FacturaePayment::TYPE_CARD`|Pago mediante tarjeta| --- diff --git a/doc/entidades/cesionarios.md b/doc/entidades/cesionarios.md index 3f2919c..4a9b4d4 100644 --- a/doc/entidades/cesionarios.md +++ b/doc/entidades/cesionarios.md @@ -26,6 +26,10 @@ $fac->setAssignmentClauses('Cláusula de cesión'); Además, para cumplir con la especificación, es necesario establecer los datos relativos al pago tal y como se explica en [este apartado](../propiedades/datos-del-pago.md): ```php -$fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766", "CAHMESMM"); -$fac->setDueDate("2017-12-31"); +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "dueDate" => "2017-12-31", + "iban" => "ES7620770024003102575766", + "bic" => "CAHMESMM" +])); ``` diff --git a/doc/propiedades/datos-del-pago.md b/doc/propiedades/datos-del-pago.md index fef81ca..7a44fe8 100644 --- a/doc/propiedades/datos-del-pago.md +++ b/doc/propiedades/datos-del-pago.md @@ -10,31 +10,67 @@ permalink: /propiedades/datos-del-pago.html ## Forma de pago Es posible indicar la forma de pago de una factura. Por ejemplo, en caso de pagarse al contado: ```php -$fac->setPaymentMethod(Facturae::PAYMENT_CASH); +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_CASH +])); ``` Los posibles valores que puede tomar este argumento se encuentra en la [tabla de constantes](../anexos/constantes.html#formas-de-pago) del anexo. En caso de transferencia (entre otras formas de pago) también debe indicarse la cuenta bancaria destinataria: ```php -$fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766"); +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "iban" => "ES7620770024003102575766" +])); ``` Si fuera necesario, se puede añadir el código BIC/SWIFT junto con el IBAN en el momento de establecer la forma de pago: ```php -$fac->setPaymentMethod(Facturae::PAYMENT_TRANSFER, "ES7620770024003102575766", "CAHMESMM"); +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "iban" => "ES7620770024003102575766", + "bic" => "CAHMESMM" +])); ``` --- ## Vencimiento -Para establecer la fecha de vencimiento del pago: +Por defecto, Facturae-PHP asume la fecha de emisión de la factura como la fecha de vencimiento de un pago. +Para establecer una fecha de vencimiento concreta, esta debe indicarse junto a los datos del pago: ```php -$fac->setDueDate("2017-12-31"); +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "dueDate" => "2017-12-31", + "iban" => "ES7620770024003102575766", + "bic" => "CAHMESMM" +])); ``` -> #### NOTA -> Por defecto, si se establece una forma de pago y no se indica la fecha de vencimiento se interpreta la fecha de la factura como tal. +--- + +## Múltiples vencimientos o formas de pago +La especificación de FacturaE permite establecer múltiples vencimientos en una misma factura. +Esto se consigue llamando varias veces al método `Facturae::addPayment()`: +```php +// Primer pago de 100,00 € al contado +// (fecha de vencimiento = fecha de emisión) +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_CASH, + "amount" => 100 +])); + +// Segundo pago de 199,90 € por transferencia bancaria +// (fecha de vencimiento el 31/12/2017) +$fac->addPayment(new FacturaePayment([ + "method" => FacturaePayment::TYPE_TRANSFER, + "amount" => 199.90, + "dueDate" => "2017-12-31", + "iban" => "ES7620770024003102575766", + "bic" => "CAHMESMM" +])); +``` --- From 0821e3bbbba20e98c217796a66f74b73bed9442f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 1 Oct 2022 14:28:55 +0200 Subject: [PATCH 081/146] =?UTF-8?q?Corregida=20versi=C3=B3n=20en=20anotaci?= =?UTF-8?q?ones=20"deprecated"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase Facturae - Actualizado trait PropertiesTrait > Related to #100 --- src/Facturae.php | 38 +++++++++++++------------- src/FacturaeTraits/PropertiesTrait.php | 14 +++++----- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Facturae.php b/src/Facturae.php index 764c7bc..80db0d5 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -25,43 +25,43 @@ class Facturae { const PRECISION_LINE = 1; const PRECISION_INVOICE = 2; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CASH = "01"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_DEBIT = "02"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_RECEIPT = "03"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_TRANSFER = "04"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_ACCEPTED_BILL_OF_EXCHANGE = "05"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_DOCUMENTARY_CREDIT = "06"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CONTRACT_AWARD = "07"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_BILL_OF_EXCHANGE = "08"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_TRANSFERABLE_IOU = "09"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_IOU = "10"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CHEQUE = "11"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_REIMBURSEMENT = "12"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_SPECIAL = "13"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_SETOFF = "14"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_POSTGIRO = "15"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CERTIFIED_CHEQUE = "16"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_BANKERS_DRAFT = "17"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CASH_ON_DELIVERY = "18"; - /** @deprecated 1.7.2 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ const PAYMENT_CARD = "19"; const TAX_IVA = "01"; diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 81abb43..02c3347 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -221,7 +221,7 @@ public function getIssueDate() { * Set due date * @param int|string $date Due date * @return Facturae Invoice instance - * @deprecated 1.7.2 Due date is now associated to payment information. + * @deprecated 1.7.3 Due date is now associated to payment information. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function setDueDate($date) { @@ -236,7 +236,7 @@ public function setDueDate($date) { /** * Get due date * @return int|null Due timestamp - * @deprecated 1.7.2 Due date is now associated to payment information. + * @deprecated 1.7.3 Due date is now associated to payment information. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getDueDate() { @@ -279,7 +279,7 @@ public function getBillingPeriod() { * @param int|string $issueDate Issue date * @param int|string $dueDate Due date * @return Facturae Invoice instance - * @deprecated 1.7.2 Due date is now associated to payment information. + * @deprecated 1.7.3 Due date is now associated to payment information. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function setDates($issueDate, $dueDate=null) { @@ -315,7 +315,7 @@ public function getPayments() { * @param string|null $iban Bank account number (IBAN) * @param string|null $bic SWIFT/BIC code of bank account * @return Facturae Invoice instance - * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::addPayment()` instead. + * @deprecated 1.7.3 Invoice can now have multiple payment installments, use `Facturae::addPayment()` instead. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function setPaymentMethod($method=FacturaePayment::TYPE_CASH, $iban=null, $bic=null) { @@ -332,7 +332,7 @@ public function setPaymentMethod($method=FacturaePayment::TYPE_CASH, $iban=null, /** * Get payment method * @return string|null Payment method - * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @deprecated 1.7.3 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentMethod() { @@ -343,7 +343,7 @@ public function getPaymentMethod() { /** * Get payment IBAN * @return string|null Payment bank account IBAN - * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @deprecated 1.7.3 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentIBAN() { @@ -354,7 +354,7 @@ public function getPaymentIBAN() { /** * Get payment BIC * @return string|null Payment bank account BIC - * @deprecated 1.7.2 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. + * @deprecated 1.7.3 Invoice can now have multiple payment installments, use `Facturae::getPayments()` instead. * @see https://josemmo.github.io/Facturae-PHP/propiedades/datos-del-pago.html */ public function getPaymentBIC() { From 13577048a487785bbfa40fb07ac56be0af83750c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 8 Oct 2022 11:42:34 +0200 Subject: [PATCH 082/146] =?UTF-8?q?A=C3=B1adido=20aviso=20sobre=20OpenSSL?= =?UTF-8?q?=20en=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado firma-electronica.md > Closes #101 --- doc/firma-electronica/firma-electronica.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md index 59c1e0c..2a8bee5 100644 --- a/doc/firma-electronica/firma-electronica.md +++ b/doc/firma-electronica/firma-electronica.md @@ -37,6 +37,15 @@ $fac->sign("certificado.pfx", null, "passphrase"); > Aunque en la mayoría de los casos esto no supone ninguna diferencia con respecto a firmar desde ficheros PEM, el validador del Gobierno de España **presenta problemas para verificar firmas de certificados expedidos recientemente por la FNMT**. > Dicho problema se soluciona cuando se incluyen los certificados raíz e intermedios de la Entidad de Certificación, por lo que es recomendable usar este método de firma con Facturae-PHP. +> #### NOTA +> A partir de OpenSSL v3.0.0, algunos algoritmos de digest como RC4 fueron [marcados como obsoletos](https://www.openssl.org/docs/man3.0/man7/migration_guide.html#Deprecated-low-level-encryption-functions). +> Esto puede suponer un problema para bancos de certificados exportados desde el Gestor de Certificados de Windows. +> Se recomienda validar estos ficheros antes de usarlos en la librería: +> +> ``` +> openssl pkcs12 -in certificado.pfx -info -nokeys -nocerts +> ``` + --- ## Fecha de la firma From afaa15e8495e8edd27d98e0007e202f87ddc8826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 8 Oct 2022 11:43:31 +0200 Subject: [PATCH 083/146] v1.7.3 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 80db0d5..8d132ec 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.1"; + const VERSION = "1.7.3"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 7dd9dbf3b8634716f9057b51361d5e597b3a6e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 11:49:42 +0200 Subject: [PATCH 084/146] Prototipo inicial de FacturaeSigner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creada clase FacturaeSigner para firmar cualquier documento XML de FacturaE - Actualizado XmlTools::injectNamespaces() - Añadido XmlTools::c14n() --- src/Common/FacturaeSigner.php | 279 ++++++++++++++++++++++++++++++ src/Common/XmlTools.php | 57 +++--- src/Face/SoapClient.php | 22 +-- src/FacturaeTraits/UtilsTrait.php | 12 +- 4 files changed, 333 insertions(+), 37 deletions(-) create mode 100644 src/Common/FacturaeSigner.php diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php new file mode 100644 index 0000000..2d216a1 --- /dev/null +++ b/src/Common/FacturaeSigner.php @@ -0,0 +1,279 @@ +signatureId = 'Signature' . $tools->randomId(); + $this->signedInfoId = 'Signature-SignedInfo' . $tools->randomId(); + $this->signedPropertiesId = 'SignedPropertiesID' . $tools->randomId(); + $this->signatureValueId = 'SignatureValue' . $tools->randomId(); + $this->certificateId = 'Certificate' . $tools->randomId(); + $this->referenceId = 'Reference-ID-' . $tools->randomId(); + $this->signatureSignedPropertiesId = $this->signatureId . '-SignedProperties' . $tools->randomId(); + $this->signatureObjectId = $this->signatureId . '-Object' . $tools->randomId(); + } + + + /** + * Set signing time + * @param int|string $time Time of the signature as UNIX timestamp or parseable date + * @return self This instance + */ + public function setSigningTime($time) { + $this->signingTime = is_string($time) ? strtotime($time) : $time; + return $this; + } + + + /** + * Set signing key material + * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store + * @param string|null $privatePath Path to private key PEM file (should be null in case of PKCS#12) + * @param string $passphrase Private key passphrase + * @return self This instance + */ + public function setSigningKey($publicPath, $privatePath=null, $passphrase='') { + $this->keypairReader = new KeyPairReader($publicPath, $privatePath, $passphrase); + return $this; + } + + + /** + * Set timestamp server + * @param string $server Timestamp Authority URL + * @param string|null $user TSA User + * @param string|null $pass TSA Password + * @return self This instance + */ + public function setTimestampServer($server, $user=null, $pass=null) { + $this->tsaEndpoint = $server; + $this->tsaUsername = $user; + $this->tsaPassword = $pass; + return $this; + } + + + /** + * Sign XML document + * @param string $xml Unsigned XML document + * @return string Signed XML document + * @throws RuntimeException if failed to sign document + */ + public function sign($xml) { + $tools = new XmlTools(); + + // Validate signing key material + if ($this->keypairReader === null) { + throw new RuntimeException('Missing signing key material'); + } + $publicChain = $this->keypairReader->getPublicChain(); + if (empty($publicChain)) { + throw new RuntimeException('Invalid signing key material: chain of certificates is empty'); + } + $privateKey = $this->keypairReader->getPrivateKey(); + if (empty($privateKey)) { + throw new RuntimeException('Invalid signing key material: failed to read private key'); + } + + // Extract root element + $openTagPosition = mb_strpos($xml, ' element'); + } + $closeTagPosition = mb_strpos($xml, ''); + if ($closeTagPosition === false) { + throw new RuntimeException('XML document is missing closing tag'); + } + $closeTagPosition += 14; + $xmlRoot = mb_substr($xml, $openTagPosition, $closeTagPosition-$openTagPosition); + + // Inject XMLDSig namespace + $xmlRoot = $tools->injectNamespaces($xmlRoot, [ + 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#' + ]); + + // TODO: get this dynamically from XML root + $xmlns = [ + 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#', + 'xmlns:fe' => 'http://www.facturae.es/Facturae/2014/v3.2.1/Facturae', + 'xmlns:xades' => 'http://uri.etsi.org/01903/v1.3.2#' + ]; + + // Build element + $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; + $certData = openssl_x509_parse($publicChain[0]); + $certIssuer = []; + foreach ($certData['issuer'] as $item=>$value) { + $certIssuer[] = "$item=$value"; + } + $certIssuer = implode(',', array_reverse($certIssuer)); + $xadesSignedProperties = '' . + '' . + '' . date('c', $signingTime) . '' . + '' . + '' . + '' . + '' . + '' . $tools->getCertDigest($publicChain[0]) . '' . + '' . + '' . + '' . $certIssuer . '' . + '' . $certData['serialNumber'] . '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . self::SIGN_POLICY_URL . '' . + '' . self::SIGN_POLICY_NAME . '' . + '' . + '' . + '' . + '' . self::SIGN_POLICY_DIGEST . '' . + '' . + '' . + '' . + '' . + '' . + 'emisor' . + '' . + '' . + '' . + '' . + '' . + 'Factura electrónica' . + '' . + 'urn:oid:1.2.840.10003.5.109.10' . + '' . + 'text/xml' . + '' . + '' . + ''; + + // Build element + $privateData = openssl_pkey_get_details($privateKey); + $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76); + $modulus = str_replace("\r", '', $modulus); + $exponent = base64_encode($privateData['rsa']['e']); + $dsKeyInfo = '' . "\n" . '' . "\n"; + foreach ($publicChain as $pemCertificate) { + $dsKeyInfo .= '' . "\n" . $tools->getCert($pemCertificate) . '' . "\n"; + } + $dsKeyInfo .= '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . $modulus . '' . "\n" . + '' . $exponent . '' . "\n" . + '' . "\n" . + '' . "\n" . + ''; + + // Build element + $dsSignedInfo = '' . "\n" . + '' . + '' . "\n" . + '' . + '' . "\n" . + '' . "\n" . + '' . + '' . "\n" . + '' . + $tools->getDigest($tools->injectNamespaces($xadesSignedProperties, $xmlns)) . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . + '' . "\n" . + '' . + $tools->getDigest($tools->injectNamespaces($dsKeyInfo, $xmlns)) . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . + '' . "\n" . + '' . "\n" . + '' . + '' . "\n" . + '' . $tools->getDigest($tools->c14n($xmlRoot)) . '' . "\n" . + '' . "\n" . + ''; + + // Build element + $dsSignature = '' . "\n" . + $dsSignedInfo . "\n" . + '' . "\n" . + $tools->getSignature($tools->injectNamespaces($dsSignedInfo, $xmlns), $privateKey) . + '' . "\n" . + $dsKeyInfo . "\n" . + '' . + '' . + $xadesSignedProperties . + '' . + '' . + ''; + + // Build new document + $xmlRoot = str_replace('', $dsSignature . '', $xmlRoot); + $xml = mb_substr($xml, 0, $openTagPosition) . $xmlRoot . mb_substr($xml, $closeTagPosition); + + return $xml; + } + + + /** + * Timestamp XML document + * @param string $xml Signed XML document + * @return string Signed and timestamped XML document + * @throws RuntimeException if failed to timestamp document + */ + public function timestamp($xml) { + // TODO: not implemented + return $xml; + } +} diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 3e9770d..894bd18 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -29,32 +29,49 @@ public function randomId() { /** * Inject namespaces - * @param string $xml Input XML - * @param string|string[] $newNs Namespaces - * @return string Canonicalized XML with new namespaces + * @param string $xml Input XML + * @param array $namespaces Namespaces to inject in the form of + * @return string Canonicalized XML with new namespaces */ - public function injectNamespaces($xml, $newNs) { - if (!is_array($newNs)) $newNs = array($newNs); - $xml = explode(">", $xml, 2); - $oldNs = explode(" ", $xml[0]); - $elementName = array_shift($oldNs); - - // Combine and sort namespaces - $xmlns = array(); - $attributes = array(); - foreach (array_merge($oldNs, $newNs) as $name) { - if (strpos($name, 'xmlns:') === 0) { - $xmlns[] = $name; + public function injectNamespaces($xml, $namespaces) { + $xml = explode('>', $xml, 2); + $rawNamespaces = explode(' ', preg_replace('/\s+/', ' ', $xml[0])); + $elementName = array_shift($rawNamespaces); + + // Include missing previous namespaces + foreach ($rawNamespaces as $part) { + list($name, $value) = explode('=', $part, 2); + if (!isset($namespaces[$name])) { + $namespaces[$name] = mb_substr($value, 1, -1); + } + } + ksort($namespaces); + + // Prepare new XML element parts + $xmlns = []; + $attributes = []; + foreach ($namespaces as $name=>$value) { + if (mb_strpos($name, 'xmlns:') === 0) { + $xmlns[] = "$name=\"$value\""; } else { - $attributes[] = $name; + $attributes[] = "$name=\"$value\""; } } - sort($xmlns); - sort($attributes); - $ns = array_merge($xmlns, $attributes); // Generate new XML element - $xml = $elementName . " " . implode(' ', $ns) . ">" . $xml[1]; + $xml = $elementName . " " . implode(' ', array_merge($xmlns, $attributes)) . ">" . $xml[1]; + return $xml; + } + + + /** + * Canonicalize XML document + * @param string $xml Input XML + * @return string Canonicalized XML + */ + public function c14n($xml) { + $xml = str_replace("\r", '', $xml); + // TODO: add missing transformations return $xml; } diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 215f53f..783fe82 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -16,9 +16,9 @@ abstract class SoapClient { /** * SoapClient constructor * - * @param string $publicPath Path to public key in PEM or PKCS#12 file - * @param string $privatePath Path to private key (null for PKCS#12) - * @param string $passphrase Private key passphrase + * @param string $publicPath Path to public key in PEM or PKCS#12 file + * @param string|null $privatePath Path to private key (null for PKCS#12) + * @param string $passphrase Private key passphrase */ public function __construct($publicPath, $privatePath=null, $passphrase="") { $reader = new KeyPairReader($publicPath, $privatePath, $passphrase); @@ -58,14 +58,14 @@ protected function request($body) { $timestampId = "TimestampId-" . $tools->randomId(); $sigId = "SignatureId-" . $tools->randomId(); - // Define namespaces array - $ns = array( - "soapenv" => 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"', - "web" => 'xmlns:web="' . $this->getWebNamespace() . '"', - "ds" => 'xmlns:ds="http://www.w3.org/2000/09/xmldsig#"', - "wsu" => 'xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"', - "wsse" => 'xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"' - ); + // Define namespaces + $ns = [ + 'xmlns:soapenv' => 'http://schemas.xmlsoap.org/soap/envelope/', + 'xmlns:web' => $this->getWebNamespace(), + 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#', + 'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', + 'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd' + ]; // Generate request body $reqBody = '' . $body . ''; diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index f815460..8ff0a90 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -48,14 +48,14 @@ public function pad($val, $field, $precision=null) { /** * Get XML Namespaces - * @return string[] XML Namespaces + * @return array XML Namespaces */ protected function getNamespaces() { - $xmlns = array(); - $xmlns[] = 'xmlns:ds="http://www.w3.org/2000/09/xmldsig#"'; - $xmlns[] = 'xmlns:fe="' . self::$SCHEMA_NS[$this->version] . '"'; - $xmlns[] = 'xmlns:xades="http://uri.etsi.org/01903/v1.3.2#"'; - return $xmlns; + return [ + 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#', + 'xmlns:fe' => self::$SCHEMA_NS[$this->version], + 'xmlns:xades' => 'http://uri.etsi.org/01903/v1.3.2#' + ]; } From 4db8a5d6466a996dd04d66e1056ed03e5fb812b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 11:51:31 +0200 Subject: [PATCH 085/146] Renombrado setSigningTime() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renombrado método Facturae::setSigningTime() - Actualizada documentación --- doc/firma-electronica/firma-electronica.md | 2 +- src/FacturaeTraits/SignableTrait.php | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md index 2a8bee5..f13e76f 100644 --- a/doc/firma-electronica/firma-electronica.md +++ b/doc/firma-electronica/firma-electronica.md @@ -51,7 +51,7 @@ $fac->sign("certificado.pfx", null, "passphrase"); ## Fecha de la firma Por defecto, al firmar una factura se utilizan la fecha y hora actuales como sello de tiempo. Si se quiere indicar otro valor, se debe utilizar el siguiente método: ```php -$fac->setSignTime("2017-01-01T12:34:56+02:00"); +$fac->setSigningTime("2017-01-01T12:34:56+02:00"); ``` > #### NOTA diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index a631d0d..e7548a9 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -27,11 +27,25 @@ trait SignableTrait { private $signatureObjectID; /** - * Set sign time - * @param int|string $time Time of the signature + * Set signing time + * @param int|string $time Time of the signature + * @return self This instance */ - public function setSignTime($time) { + public function setSigningTime($time) { $this->signTime = is_string($time) ? strtotime($time) : $time; + return $this; + } + + + /** + * Set signing time + * + * Same as `Facturae::setSigningTime()` for backwards compatibility + * @param int|string $time Time of the signature + * @return self This instance + */ + public function setSignTime($time) { + return $this->setSigningTime($time); } From 171c2748cce8a192c97a7b10c0af4b55c12b3033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 12:57:41 +0200 Subject: [PATCH 086/146] Implementada C14N de namespaces en FacturaeSigner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner - Añadido método XmlTools::getNamespaces() --- src/Common/FacturaeSigner.php | 17 ++++++++--------- src/Common/XmlTools.php | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 2d216a1..2dcc07a 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -7,6 +7,8 @@ * Class for signing FacturaE XML documents. */ final class FacturaeSigner { + const XMLNS_DS = 'http://www.w3.org/2000/09/xmldsig#'; + const XMLNS_XADES = 'http://uri.etsi.org/01903/v1.3.2#'; const SIGN_POLICY_NAME = 'Política de Firma FacturaE v3.1'; const SIGN_POLICY_URL = 'http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf'; const SIGN_POLICY_DIGEST = 'Ohixl6upD6av8N7pEvDABhEL6hM='; @@ -130,15 +132,12 @@ public function sign($xml) { // Inject XMLDSig namespace $xmlRoot = $tools->injectNamespaces($xmlRoot, [ - 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#' + 'xmlns:ds' => self::XMLNS_DS ]); - // TODO: get this dynamically from XML root - $xmlns = [ - 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#', - 'xmlns:fe' => 'http://www.facturae.es/Facturae/2014/v3.2.1/Facturae', - 'xmlns:xades' => 'http://uri.etsi.org/01903/v1.3.2#' - ]; + // Build list of all namespaces for C14N + $xmlns = $tools->getNamespaces($xmlRoot); + $xmlns['xmlns:xades'] = self::XMLNS_XADES; // Build element $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; @@ -245,7 +244,7 @@ public function sign($xml) { ''; // Build element - $dsSignature = '' . "\n" . + $dsSignature = '' . "\n" . $dsSignedInfo . "\n" . '' . "\n" . $tools->getSignature($tools->injectNamespaces($dsSignedInfo, $xmlns), $privateKey) . @@ -259,7 +258,7 @@ public function sign($xml) { ''; // Build new document - $xmlRoot = str_replace('', $dsSignature . '', $xmlRoot); + $xmlRoot = str_replace('', "$dsSignature", $xmlRoot); $xml = mb_substr($xml, 0, $openTagPosition) . $xmlRoot . mb_substr($xml, $closeTagPosition); return $xml; diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 894bd18..b039453 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -27,6 +27,28 @@ public function randomId() { } + /** + * Get namespaces from root element + * @param string $xml XML document + * @return array Namespaces in the form of + */ + public function getNamespaces($xml) { + $namespaces = []; + + $xml = explode('>', $xml, 2); + $rawNamespaces = explode(' ', preg_replace('/\s+/', ' ', $xml[0])); + array_shift($rawNamespaces); + foreach ($rawNamespaces as $part) { + list($name, $value) = explode('=', $part, 2); + if (mb_strpos($name, 'xmlns:') === 0) { + $namespaces[$name] = mb_substr($value, 1, -1); + } + } + + return $namespaces; + } + + /** * Inject namespaces * @param string $xml Input XML From e6a3835e8999cf1dff4eb48db209087255203e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 13:01:01 +0200 Subject: [PATCH 087/146] Implementado sellado de tiempo en FacturaeSigner - Actualizada clase FacturaeSigner --- src/Common/FacturaeSigner.php | 98 ++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 2dcc07a..a4cc75a 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -1,6 +1,7 @@ referenceId = 'Reference-ID-' . $tools->randomId(); $this->signatureSignedPropertiesId = $this->signatureId . '-SignedProperties' . $tools->randomId(); $this->signatureObjectId = $this->signatureId . '-Object' . $tools->randomId(); + $this->timestampId = 'Timestamp-' . $tools->randomId(); } @@ -272,7 +276,99 @@ public function sign($xml) { * @throws RuntimeException if failed to timestamp document */ public function timestamp($xml) { - // TODO: not implemented + $tools = new XmlTools(); + + // Validate TSA endpoint + if ($this->tsaEndpoint === null) { + throw new RuntimeException('Missing Timestamp Authority URL'); + } + + // Extract root element + $rootOpenTagPosition = mb_strpos($xml, ' element'); + } + $rootCloseTagPosition = mb_strpos($xml, ''); + if ($rootCloseTagPosition === false) { + throw new RuntimeException('Signed XML document is missing closing tag'); + } + $rootCloseTagPosition += 14; + $xmlRoot = mb_substr($xml, $rootOpenTagPosition, $rootCloseTagPosition-$rootOpenTagPosition); + + // Verify element is present + if (mb_strpos($xmlRoot, '') === false) { + throw new RuntimeException('Signed XML document is missing element'); + } + + // Extract element + $signatureOpenTagPosition = mb_strpos($xmlRoot, ' element'); + } + $signatureCloseTagPosition = mb_strpos($xmlRoot, ''); + if ($signatureCloseTagPosition === false) { + throw new RuntimeException('Signed XML document is missing closing tag'); + } + $signatureCloseTagPosition += 20; + $dsSignatureValue = mb_substr($xmlRoot, $signatureOpenTagPosition, $signatureCloseTagPosition-$signatureOpenTagPosition); + + // Canonicalize element + $xmlns = $tools->getNamespaces($xmlRoot); + $xmlns['xmlns:xades'] = self::XMLNS_XADES; + $dsSignatureValue = $tools->injectNamespaces($dsSignatureValue, $xmlns); + + // Build TimeStampQuery in ASN1 using SHA-512 + $tsq = "\x30\x59\x02\x01\x01\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40"; + $tsq .= hash('sha512', $dsSignatureValue, true); + $tsq .= "\x01\x01\xff"; + + // Send query to TSA endpoint + $chOpts = [ + CURLOPT_URL => $this->tsaEndpoint, + CURLOPT_RETURNTRANSFER => 1, + CURLOPT_SSL_VERIFYPEER => 0, + CURLOPT_FOLLOWLOCATION => 1, + CURLOPT_CONNECTTIMEOUT => 0, + CURLOPT_TIMEOUT => 10, // 10 seconds timeout + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => $tsq, + CURLOPT_HTTPHEADER => ['Content-Type: application/timestamp-query'], + CURLOPT_USERAGENT => Facturae::USER_AGENT + ]; + if ($this->tsaUsername !== null && $this->tsaPassword !== null) { + $chOpts[CURLOPT_USERPWD] = $this->tsaUsername . ':' . $this->tsaPassword; + } + $ch = curl_init(); + curl_setopt_array($ch, $chOpts); + $tsr = curl_exec($ch); + if ($tsr === false) { + throw new RuntimeException('Failed to get TSR from server: ' . curl_error($ch)); + } + curl_close($ch); + unset($ch); + + // Validate TimeStampReply + $responseCode = substr($tsr, 6, 3); + if ($responseCode !== "\x02\x01\x00") { // Bytes for INTEGER 0 in ASN1 + throw new RuntimeException('Invalid TSR response code: 0x' . bin2hex($responseCode)); + } + + // Build new element + $timestamp = $tools->toBase64(substr($tsr, 9), true); + $xadesUnsignedProperties = '' . + '' . + '' . + '' . + '' . + '' . "\n" . $timestamp . '' . + '' . + '' . + ''; + + // Build new document + $xmlRoot = str_replace('', "$xadesUnsignedProperties", $xmlRoot); + $xml = mb_substr($xml, 0, $rootOpenTagPosition) . $xmlRoot . mb_substr($xml, $rootCloseTagPosition); + return $xml; } } From 4af458f941bcae1e135263c39b3a38bea3a75037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 13:52:45 +0200 Subject: [PATCH 088/146] Integrado FacturaeSigner con clase Facturae - Actualizados ExportableTrait y SignableTrait - Eliminado Facturae::getNamespaces() - Actualizados test unitarios --- doc/anexos/constantes.md | 1 - src/Common/FacturaeSigner.php | 20 ++ src/Facturae.php | 1 + src/FacturaeTraits/ExportableTrait.php | 7 +- src/FacturaeTraits/SignableTrait.php | 281 +++---------------------- src/FacturaeTraits/UtilsTrait.php | 13 -- tests/InvoiceTest.php | 2 +- tests/SignerTest.php | 62 ++++++ 8 files changed, 116 insertions(+), 271 deletions(-) create mode 100644 tests/SignerTest.php diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 964066e..2266fee 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -14,7 +14,6 @@ permalink: /anexos/constantes.html |`Facturae::SCHEMA_3_2`|Formato de Facturae 3.2| |`Facturae::SCHEMA_3_2_1`|Formato de Facturae 3.2.1| |`Facturae::SCHEMA_3_2_2`|Formato de Facturae 3.2.2| -|`Facturae::SIGN_POLICY_3_1`|Formato de firma 3.1| --- diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index a4cc75a..89b26bd 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -85,6 +85,17 @@ public function setSigningKey($publicPath, $privatePath=null, $passphrase='') { } + /** + * Can sign + * @return boolean Whether instance is ready to sign XML documents + */ + public function canSign() { + return ($this->keypairReader !== null) && + !empty($this->keypairReader->getPublicChain()) && + !empty($this->keypairReader->getPrivateKey()); + } + + /** * Set timestamp server * @param string $server Timestamp Authority URL @@ -100,6 +111,15 @@ public function setTimestampServer($server, $user=null, $pass=null) { } + /** + * Can timestamp + * @return boolean Whether instance is ready to timestamp signed XML documents + */ + public function canTimestamp() { + return ($this->tsaEndpoint !== null); + } + + /** * Sign XML document * @param string $xml Unsigned XML document diff --git a/src/Facturae.php b/src/Facturae.php index 8d132ec..8e877dd 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -16,6 +16,7 @@ class Facturae { const SCHEMA_3_2 = "3.2"; const SCHEMA_3_2_1 = "3.2.1"; const SCHEMA_3_2_2 = "3.2.2"; + /** @deprecated 1.7.4 Not needed anymore (only existing signing policy). */ const SIGN_POLICY_3_1 = array( "name" => "Política de Firma FacturaE v3.1", "url" => "http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf", diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 66ac19a..35ffacb 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -43,8 +43,7 @@ public function export($filePath=null) { foreach ($this->extensions as $ext) $ext->__onBeforeExport(); // Prepare document - $xml = ''; + $xml = ''; $totals = $this->getTotals(); $paymentDetailsXML = $this->getPaymentDetailsXML($totals); @@ -296,8 +295,8 @@ public function export($filePath=null) { $xml .= ''; foreach ($this->extensions as $ext) $xml = $ext->__onBeforeSign($xml); - // Add signature - $xml = $this->injectSignature($xml); + // Add signature and timestamp + $xml = $this->injectSignatureAndTimestamp($xml); foreach ($this->extensions as $ext) $xml = $ext->__onAfterSign($xml); // Prepend content type diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index e7548a9..625da45 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -1,30 +1,27 @@ signer === null) { + $this->signer = new FacturaeSigner(); + } + return $this->signer; + } - private $signatureID; - private $signedInfoID; - private $signedPropertiesID; - private $signatureValueID; - private $certificateID; - private $referenceID; - private $signatureSignedPropertiesID; - private $signatureObjectID; /** * Set signing time @@ -32,7 +29,7 @@ trait SignableTrait { * @return self This instance */ public function setSigningTime($time) { - $this->signTime = is_string($time) ? strtotime($time) : $time; + $this->getSigner()->setSigningTime($time); return $this; } @@ -43,6 +40,7 @@ public function setSigningTime($time) { * Same as `Facturae::setSigningTime()` for backwards compatibility * @param int|string $time Time of the signature * @return self This instance + * @deprecated 1.7.4 Renamed to `Facturae::setSigningTime()`. */ public function setSignTime($time) { return $this->setSigningTime($time); @@ -56,9 +54,7 @@ public function setSignTime($time) { * @param string $pass TSA Password */ public function setTimestampServer($server, $user=null, $pass=null) { - $this->timestampServer = $server; - $this->timestampUser = $user; - $this->timestampPass = $pass; + $this->getSigner()->setTimestampServer($server, $user, $pass); } @@ -67,251 +63,32 @@ public function setTimestampServer($server, $user=null, $pass=null) { * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store * @param string|null $privatePath Path to private key PEM file (should be null in case of PKCS#12) * @param string $passphrase Private key passphrase - * @param array $policy Facturae sign policy * @return boolean Success */ - public function sign($publicPath, $privatePath=null, $passphrase="", $policy=self::SIGN_POLICY_3_1) { - // Generate random IDs - $tools = new XmlTools(); - $this->signatureID = $tools->randomId(); - $this->signedInfoID = $tools->randomId(); - $this->signedPropertiesID = $tools->randomId(); - $this->signatureValueID = $tools->randomId(); - $this->certificateID = $tools->randomId(); - $this->referenceID = $tools->randomId(); - $this->signatureSignedPropertiesID = $tools->randomId(); - $this->signatureObjectID = $tools->randomId(); - - // Load public and private keys - $reader = new KeyPairReader($publicPath, $privatePath, $passphrase); - $this->publicChain = $reader->getPublicChain(); - $this->privateKey = $reader->getPrivateKey(); - $this->signPolicy = $policy; - unset($reader); - - // Return success - return (!empty($this->publicChain) && !empty($this->privateKey)); + public function sign($publicPath, $privatePath=null, $passphrase="") { + $signer = $this->getSigner(); + $signer->setSigningKey($publicPath, $privatePath, $passphrase); + return $signer->canSign(); } /** - * Inject signature + * Inject signature and timestamp (if needed) * @param string $xml Unsigned XML document * @return string Signed XML document */ - protected function injectSignature($xml) { + protected function injectSignatureAndTimestamp($xml) { // Make sure we have all we need to sign the document - if (empty($this->publicChain) || empty($this->privateKey)) return $xml; - $tools = new XmlTools(); - - // Normalize document - $xml = str_replace("\r", "", $xml); - - // Prepare signed properties - $signTime = is_null($this->signTime) ? time() : $this->signTime; - $certData = openssl_x509_parse($this->publicChain[0]); - $certIssuer = []; - foreach ($certData['issuer'] as $item=>$value) { - $certIssuer[] = "$item=$value"; + if ($this->signer === null || $this->signer->canSign() === false) { + return $xml; } - $certIssuer = implode(',', array_reverse($certIssuer)); - - // Generate signed properties - $prop = '' . - '' . - '' . date('c', $signTime) . '' . - '' . - '' . - '' . - '' . - '' . $tools->getCertDigest($this->publicChain[0]) . '' . - '' . - '' . - '' . $certIssuer . '' . - '' . $certData['serialNumber'] . '' . - '' . - '' . - '' . - '' . - '' . - '' . - '' . $this->signPolicy['url'] . '' . - '' . $this->signPolicy['name'] . '' . - '' . - '' . - '' . - '' . $this->signPolicy['digest'] . '' . - '' . - '' . - '' . - '' . - '' . - 'emisor' . - '' . - '' . - '' . - '' . - '' . - 'Factura electrónica' . - '' . - 'urn:oid:1.2.840.10003.5.109.10' . - '' . - 'text/xml' . - '' . - '' . - ''; - // Extract public exponent (e) and modulus (n) - $privateData = openssl_pkey_get_details($this->privateKey); - $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76); - $modulus = str_replace("\r", "", $modulus); - $exponent = base64_encode($privateData['rsa']['e']); - - // Generate KeyInfo - $kInfo = '' . "\n" . - '' . "\n"; - foreach ($this->publicChain as $pemCertificate) { - $kInfo .= '' . "\n" . $tools->getCert($pemCertificate) . '' . "\n"; + // Sign and timestamp document + $xml = $this->signer->sign($xml); + if ($this->signer->canTimestamp()) { + $xml = $this->signer->timestamp($xml); } - $kInfo .= '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . "\n" . $modulus . '' . "\n" . - '' . $exponent . '' . "\n" . - '' . "\n" . - '' . "\n" . - ''; - - // Calculate digests - $xmlns = $this->getNamespaces(); - $propDigest = $tools->getDigest($tools->injectNamespaces($prop, $xmlns)); - $kInfoDigest = $tools->getDigest($tools->injectNamespaces($kInfo, $xmlns)); - $documentDigest = $tools->getDigest($xml); - - // Generate SignedInfo - $sInfo = '' . "\n" . - '' . - '' . "\n" . - '' . - '' . "\n" . - '' . "\n" . - '' . - '' . "\n" . - '' . $propDigest . '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . - '' . "\n" . - '' . $kInfoDigest . '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . - '' . "\n" . - '' . "\n" . - '' . - '' . "\n" . - '' . $documentDigest . '' . "\n" . - '' . "\n" . - ''; - - // Calculate signature - $signaturePayload = $tools->injectNamespaces($sInfo, $xmlns); - $signatureResult = $tools->getSignature($signaturePayload, $this->privateKey); - - // Make signature - $sig = '' . "\n" . - $sInfo . "\n" . - '' . "\n" . - $signatureResult . - '' . "\n" . - $kInfo . "\n" . - '' . - '' . - $prop . - '' . - '' . - ''; - - // Inject signature - $xml = str_replace('', $sig . '', $xml); - - // Inject timestamp - if (!empty($this->timestampServer)) $xml = $this->injectTimestamp($xml); - return $xml; } - - /** - * Inject timestamp - * @param string $signedXml Signed XML document - * @return string Signed and timestamped XML document - */ - private function injectTimestamp($signedXml) { - $tools = new XmlTools(); - - // Prepare data to timestamp - $payload = explode('', $payload, 2)[0]; - $payload = ''; - $payload = $tools->injectNamespaces($payload, $this->getNamespaces()); - - // Create TimeStampQuery in ASN1 using SHA-512 - $tsq = "\x30\x59\x02\x01\x01\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40"; - $tsq .= hash('sha512', $payload, true); - $tsq .= "\x01\x01\xff"; - - // Await TimeStampRequest - $chOpts = array( - CURLOPT_URL => $this->timestampServer, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_BINARYTRANSFER => 1, - CURLOPT_SSL_VERIFYPEER => 0, - CURLOPT_FOLLOWLOCATION => 1, - CURLOPT_CONNECTTIMEOUT => 0, - CURLOPT_TIMEOUT => 10, // 10 seconds timeout - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => $tsq, - CURLOPT_HTTPHEADER => array("Content-Type: application/timestamp-query"), - CURLOPT_USERAGENT => self::USER_AGENT - ); - if (!empty($this->timestampUser) && !empty($this->timestampPass)) { - $chOpts[CURLOPT_USERPWD] = $this->timestampUser . ":" . $this->timestampPass; - } - $ch = curl_init(); - curl_setopt_array($ch, $chOpts); - $tsr = curl_exec($ch); - if ($tsr === false) throw new \Exception('cURL error: ' . curl_error($ch)); - curl_close($ch); - unset($ch); - - // Validate TimeStampRequest - $responseCode = substr($tsr, 6, 3); - if ($responseCode !== "\x02\x01\x00") { // Bytes for INTEGER 0 in ASN1 - throw new \Exception('Invalid TSR response code'); - } - - // Extract TimeStamp from TimeStampRequest and inject into XML document - $tools = new XmlTools(); - $timeStamp = substr($tsr, 9); - $timeStamp = $tools->toBase64($timeStamp, true); - $tsXml = '' . - '' . - '' . - '' . - '' . - '' . "\n" . $timeStamp . '' . - '' . - '' . - ''; - $signedXml = str_replace('', $tsXml . '', $signedXml); - return $signedXml; - } - } diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index 8ff0a90..858435c 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -46,19 +46,6 @@ public function pad($val, $field, $precision=null) { } - /** - * Get XML Namespaces - * @return array XML Namespaces - */ - protected function getNamespaces() { - return [ - 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#', - 'xmlns:fe' => self::$SCHEMA_NS[$this->version], - 'xmlns:xades' => 'http://uri.etsi.org/01903/v1.3.2#' - ]; - } - - /** * Get extension * @param string $name Extension name or class name diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index bc3c670..aa8e32c 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -230,11 +230,11 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // Ya solo queda firmar la factura ... if ($isPfx) { $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); + $fac->setTimestampServer("http://tss.accv.es:8318/tsa"); } else { $fac->sign(self::CERTS_DIR . "/facturae-public.pem", self::CERTS_DIR . "/facturae-private.pem", self::FACTURAE_CERT_PASS); } - $fac->setTimestampServer("http://tss.accv.es:8318/tsa"); // ... exportarlo a un archivo ... $isPfxStr = $isPfx ? "PKCS12" : "X509"; diff --git a/tests/SignerTest.php b/tests/SignerTest.php new file mode 100644 index 0000000..7efbb99 --- /dev/null +++ b/tests/SignerTest.php @@ -0,0 +1,62 @@ +setSigningKey(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $signer->setTimestampServer('http://tss.accv.es:8318/tsa'); + return $signer; + } + + + public function testCannotSignWithoutKey() { + $this->expectException(RuntimeException::class); + $signer = new FacturaeSigner(); + $xml = $this->getBaseInvoice()->export(); + $signer->sign($xml); + } + + + public function testCannotSignInvalidDocuments() { + $this->expectException(RuntimeException::class); + $this->getSigner()->sign(''); + } + + + public function testCanSignValidDocuments() { + $xml = $this->getBaseInvoice()->export(); + $signedXml = $this->getSigner()->sign($xml); + $this->assertStringContainsString('', $signedXml); + } + + + public function testCannotTimestampWithoutTsaDetails() { + $this->expectException(RuntimeException::class); + $signer = new FacturaeSigner(); + $signer->timestamp( + ' + + + ' + ); + } + + + public function testCanTimestampSignedDocuments() { + $signer = $this->getSigner(); + $xml = $this->getBaseInvoice()->export(); + $signedXml = $signer->sign($xml); + $timestampedXml = $signer->timestamp($signedXml); + $this->assertStringContainsString('', $timestampedXml); + } + +} From 52716899b957e3fa4a430a8f39a533c128a77b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 12 Oct 2022 14:06:47 +0200 Subject: [PATCH 089/146] Refactorizado XmlTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convertidos métodos de la clase XmlTools en estáticos - Actualizadas clases --- src/Common/FacturaeSigner.php | 45 +++++++++++------------- src/Common/XmlTools.php | 32 +++++++++--------- src/Face/SoapClient.php | 28 +++++++-------- src/Face/Traits/FaceTrait.php | 5 ++- src/Face/Traits/Faceb2bTrait.php | 8 ++--- src/FacturaeParty.php | 47 ++++++++++++-------------- src/FacturaeTraits/ExportableTrait.php | 29 +++++++--------- 7 files changed, 88 insertions(+), 106 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 89b26bd..7097883 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -48,16 +48,15 @@ final class FacturaeSigner { * Class constuctor */ public function __construct() { - $tools = new XmlTools(); - $this->signatureId = 'Signature' . $tools->randomId(); - $this->signedInfoId = 'Signature-SignedInfo' . $tools->randomId(); - $this->signedPropertiesId = 'SignedPropertiesID' . $tools->randomId(); - $this->signatureValueId = 'SignatureValue' . $tools->randomId(); - $this->certificateId = 'Certificate' . $tools->randomId(); - $this->referenceId = 'Reference-ID-' . $tools->randomId(); - $this->signatureSignedPropertiesId = $this->signatureId . '-SignedProperties' . $tools->randomId(); - $this->signatureObjectId = $this->signatureId . '-Object' . $tools->randomId(); - $this->timestampId = 'Timestamp-' . $tools->randomId(); + $this->signatureId = 'Signature' . XmlTools::randomId(); + $this->signedInfoId = 'Signature-SignedInfo' . XmlTools::randomId(); + $this->signedPropertiesId = 'SignedPropertiesID' . XmlTools::randomId(); + $this->signatureValueId = 'SignatureValue' . XmlTools::randomId(); + $this->certificateId = 'Certificate' . XmlTools::randomId(); + $this->referenceId = 'Reference-ID-' . XmlTools::randomId(); + $this->signatureSignedPropertiesId = $this->signatureId . '-SignedProperties' . XmlTools::randomId(); + $this->signatureObjectId = $this->signatureId . '-Object' . XmlTools::randomId(); + $this->timestampId = 'Timestamp-' . XmlTools::randomId(); } @@ -127,8 +126,6 @@ public function canTimestamp() { * @throws RuntimeException if failed to sign document */ public function sign($xml) { - $tools = new XmlTools(); - // Validate signing key material if ($this->keypairReader === null) { throw new RuntimeException('Missing signing key material'); @@ -155,12 +152,12 @@ public function sign($xml) { $xmlRoot = mb_substr($xml, $openTagPosition, $closeTagPosition-$openTagPosition); // Inject XMLDSig namespace - $xmlRoot = $tools->injectNamespaces($xmlRoot, [ + $xmlRoot = XmlTools::injectNamespaces($xmlRoot, [ 'xmlns:ds' => self::XMLNS_DS ]); // Build list of all namespaces for C14N - $xmlns = $tools->getNamespaces($xmlRoot); + $xmlns = XmlTools::getNamespaces($xmlRoot); $xmlns['xmlns:xades'] = self::XMLNS_XADES; // Build element @@ -178,7 +175,7 @@ public function sign($xml) { '' . '' . '' . - '' . $tools->getCertDigest($publicChain[0]) . '' . + '' . XmlTools::getCertDigest($publicChain[0]) . '' . '' . '' . '' . $certIssuer . '' . @@ -222,7 +219,7 @@ public function sign($xml) { $exponent = base64_encode($privateData['rsa']['e']); $dsKeyInfo = '' . "\n" . '' . "\n"; foreach ($publicChain as $pemCertificate) { - $dsKeyInfo .= '' . "\n" . $tools->getCert($pemCertificate) . '' . "\n"; + $dsKeyInfo .= '' . "\n" . XmlTools::getCert($pemCertificate) . '' . "\n"; } $dsKeyInfo .= '' . "\n" . '' . "\n" . @@ -245,14 +242,14 @@ public function sign($xml) { '' . '' . "\n" . '' . - $tools->getDigest($tools->injectNamespaces($xadesSignedProperties, $xmlns)) . + XmlTools::getDigest(XmlTools::injectNamespaces($xadesSignedProperties, $xmlns)) . '' . "\n" . '' . "\n" . '' . "\n" . '' . '' . "\n" . '' . - $tools->getDigest($tools->injectNamespaces($dsKeyInfo, $xmlns)) . + XmlTools::getDigest(XmlTools::injectNamespaces($dsKeyInfo, $xmlns)) . '' . "\n" . '' . "\n" . '' . "\n" . '' . '' . "\n" . - '' . $tools->getDigest($tools->c14n($xmlRoot)) . '' . "\n" . + '' . XmlTools::getDigest(XmlTools::c14n($xmlRoot)) . '' . "\n" . '' . "\n" . ''; @@ -271,7 +268,7 @@ public function sign($xml) { $dsSignature = '' . "\n" . $dsSignedInfo . "\n" . '' . "\n" . - $tools->getSignature($tools->injectNamespaces($dsSignedInfo, $xmlns), $privateKey) . + XmlTools::getSignature(XmlTools::injectNamespaces($dsSignedInfo, $xmlns), $privateKey) . '' . "\n" . $dsKeyInfo . "\n" . '' . @@ -296,8 +293,6 @@ public function sign($xml) { * @throws RuntimeException if failed to timestamp document */ public function timestamp($xml) { - $tools = new XmlTools(); - // Validate TSA endpoint if ($this->tsaEndpoint === null) { throw new RuntimeException('Missing Timestamp Authority URL'); @@ -333,9 +328,9 @@ public function timestamp($xml) { $dsSignatureValue = mb_substr($xmlRoot, $signatureOpenTagPosition, $signatureCloseTagPosition-$signatureOpenTagPosition); // Canonicalize element - $xmlns = $tools->getNamespaces($xmlRoot); + $xmlns = XmlTools::getNamespaces($xmlRoot); $xmlns['xmlns:xades'] = self::XMLNS_XADES; - $dsSignatureValue = $tools->injectNamespaces($dsSignatureValue, $xmlns); + $dsSignatureValue = XmlTools::injectNamespaces($dsSignatureValue, $xmlns); // Build TimeStampQuery in ASN1 using SHA-512 $tsq = "\x30\x59\x02\x01\x01\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40"; @@ -374,7 +369,7 @@ public function timestamp($xml) { } // Build new element - $timestamp = $tools->toBase64(substr($tsr, 9), true); + $timestamp = XmlTools::toBase64(substr($tsr, 9), true); $xadesUnsignedProperties = '' . '' . '' . diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index b039453..756c644 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -8,7 +8,7 @@ class XmlTools { * @param string $value Input value * @return string Escaped input */ - public function escape($value) { + public static function escape($value) { return htmlspecialchars($value, ENT_XML1, 'UTF-8'); } @@ -21,7 +21,7 @@ public function escape($value) { * * @return int Random number */ - public function randomId() { + public static function randomId() { if (function_exists('random_int')) return random_int(0x10000000, 0x7FFFFFFF); return rand(100000, 999999); } @@ -32,7 +32,7 @@ public function randomId() { * @param string $xml XML document * @return array Namespaces in the form of */ - public function getNamespaces($xml) { + public static function getNamespaces($xml) { $namespaces = []; $xml = explode('>', $xml, 2); @@ -55,7 +55,7 @@ public function getNamespaces($xml) { * @param array $namespaces Namespaces to inject in the form of * @return string Canonicalized XML with new namespaces */ - public function injectNamespaces($xml, $namespaces) { + public static function injectNamespaces($xml, $namespaces) { $xml = explode('>', $xml, 2); $rawNamespaces = explode(' ', preg_replace('/\s+/', ' ', $xml[0])); $elementName = array_shift($rawNamespaces); @@ -91,7 +91,7 @@ public function injectNamespaces($xml, $namespaces) { * @param string $xml Input XML * @return string Canonicalized XML */ - public function c14n($xml) { + public static function c14n($xml) { $xml = str_replace("\r", '', $xml); // TODO: add missing transformations return $xml; @@ -104,9 +104,9 @@ public function c14n($xml) { * @param boolean $pretty Pretty Base64 response * @return string Base64 response */ - public function toBase64($bytes, $pretty=false) { + public static function toBase64($bytes, $pretty=false) { $res = base64_encode($bytes); - return $pretty ? $this->prettify($res) : $res; + return $pretty ? self::prettify($res) : $res; } @@ -115,7 +115,7 @@ public function toBase64($bytes, $pretty=false) { * @param string $input Input string * @return string Multi-line resposne */ - private function prettify($input) { + private static function prettify($input) { return chunk_split($input, 76, "\n"); } @@ -126,8 +126,8 @@ private function prettify($input) { * @param boolean $pretty Pretty Base64 response * @return string Digest */ - public function getDigest($input, $pretty=false) { - return $this->toBase64(hash("sha512", $input, true), $pretty); + public static function getDigest($input, $pretty=false) { + return self::toBase64(hash("sha512", $input, true), $pretty); } @@ -137,11 +137,11 @@ public function getDigest($input, $pretty=false) { * @param boolean $pretty Pretty Base64 response * @return string Base64 Certificate */ - public function getCert($pem, $pretty=true) { + public static function getCert($pem, $pretty=true) { $pem = str_replace("-----BEGIN CERTIFICATE-----", "", $pem); $pem = str_replace("-----END CERTIFICATE-----", "", $pem); $pem = str_replace("\n", "", str_replace("\r", "", $pem)); - if ($pretty) $pem = $this->prettify($pem); + if ($pretty) $pem = self::prettify($pem); return $pem; } @@ -152,9 +152,9 @@ public function getCert($pem, $pretty=true) { * @param boolean $pretty Pretty Base64 response * @return string Base64 Digest */ - public function getCertDigest($publicKey, $pretty=false) { + public static function getCertDigest($publicKey, $pretty=false) { $digest = openssl_x509_fingerprint($publicKey, "sha512", true); - return $this->toBase64($digest, $pretty); + return self::toBase64($digest, $pretty); } @@ -165,9 +165,9 @@ public function getCertDigest($publicKey, $pretty=false) { * @param boolean $pretty Pretty Base64 response * @return string Base64 Signature */ - public function getSignature($payload, $privateKey, $pretty=true) { + public static function getSignature($payload, $privateKey, $pretty=true) { openssl_sign($payload, $signature, $privateKey, OPENSSL_ALGO_SHA512); - return $this->toBase64($signature, $pretty); + return self::toBase64($signature, $pretty); } } diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 783fe82..c62ad10 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -48,15 +48,13 @@ protected abstract function getWebNamespace(); * @return SimpleXMLElement Response */ protected function request($body) { - $tools = new XmlTools(); - // Generate random IDs for this request - $bodyId = "BodyId-" . $tools->randomId(); - $certId = "CertId-" . $tools->randomId(); - $keyId = "KeyId-" . $tools->randomId(); - $strId = "SecTokId-" . $tools->randomId(); - $timestampId = "TimestampId-" . $tools->randomId(); - $sigId = "SignatureId-" . $tools->randomId(); + $bodyId = "BodyId-" . XmlTools::randomId(); + $certId = "CertId-" . XmlTools::randomId(); + $keyId = "KeyId-" . XmlTools::randomId(); + $strId = "SecTokId-" . XmlTools::randomId(); + $timestampId = "TimestampId-" . XmlTools::randomId(); + $sigId = "SignatureId-" . XmlTools::randomId(); // Define namespaces $ns = [ @@ -69,7 +67,7 @@ protected function request($body) { // Generate request body $reqBody = '' . $body . ''; - $bodyDigest = $tools->getDigest($tools->injectNamespaces($reqBody, $ns)); + $bodyDigest = XmlTools::getDigest(XmlTools::injectNamespaces($reqBody, $ns)); // Generate timestamp $timeCreated = time(); @@ -78,8 +76,8 @@ protected function request($body) { '' . date('c', $timeCreated) . '' . '' . date('c', $timeExpires) . '' . ''; - $timestampDigest = $tools->getDigest( - $tools->injectNamespaces($reqTimestamp, $ns) + $timestampDigest = XmlTools::getDigest( + XmlTools::injectNamespaces($reqTimestamp, $ns) ); // Generate request header @@ -89,7 +87,7 @@ protected function request($body) { 'EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" ' . 'wsu:Id="' . $certId . '" ' . 'ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3">' . - $tools->getCert($this->publicKey, false) . + XmlTools::getCert($this->publicKey, false) . ''; // Generate signed info @@ -106,13 +104,13 @@ protected function request($body) { '' . $bodyDigest . '' . '' . ''; - $signedInfoPayload = $tools->injectNamespaces($signedInfo, $ns); + $signedInfoPayload = XmlTools::injectNamespaces($signedInfo, $ns); // Add signature and KeyInfo to header $reqHeader .= '' . $signedInfo . '' . - $tools->getSignature($signedInfoPayload, $this->privateKey, false) . + XmlTools::getSignature($signedInfoPayload, $this->privateKey, false) . ''; $reqHeader .= '' . '' . @@ -130,7 +128,7 @@ protected function request($body) { // Generate final request $req = '' . $reqHeader . $reqBody . ''; - $req = $tools->injectNamespaces($req, $ns); + $req = XmlTools::injectNamespaces($req, $ns); $req = '' . "\n" . $req; // Extract SOAP action from "" diff --git a/src/Face/Traits/FaceTrait.php b/src/Face/Traits/FaceTrait.php index 0abb719..33a3557 100644 --- a/src/Face/Traits/FaceTrait.php +++ b/src/Face/Traits/FaceTrait.php @@ -86,18 +86,17 @@ public function getInvoices($regId) { * @return SimpleXMLElement Response */ public function sendInvoice($email, $invoice, $attachments=array()) { - $tools = new XmlTools(); $req = ''; $req .= '' . $email . ''; $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . + '' . XmlTools::toBase64($invoice->getData()) . '' . '' . $invoice->getFilename() . '' . 'application/xml' . // Mandatory MIME type ''; $req .= ''; foreach ($attachments as $file) { $req .= '' . - '' . $tools->toBase64($file->getData()) . '' . + '' . XmlTools::toBase64($file->getData()) . '' . '' . $file->getFilename() . '' . '' . $file->getMimeType() . '' . ''; diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php index 8a90a7a..a4494d9 100644 --- a/src/Face/Traits/Faceb2bTrait.php +++ b/src/Face/Traits/Faceb2bTrait.php @@ -20,18 +20,17 @@ protected function getWebNamespace() { * @return SimpleXMLElement Response */ public function sendInvoice($invoice, $attachment=null) { - $tools = new XmlTools(); $req = ''; $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . + '' . XmlTools::toBase64($invoice->getData()) . '' . '' . $invoice->getFilename() . '' . 'text/xml' . // Mandatory MIME type ''; if (!is_null($attachment)) { $req .= '' . - '' . $tools->toBase64($attachment->getData()) . '' . + '' . XmlTools::toBase64($attachment->getData()) . '' . '' . $attachment->getFilename() . '' . '' . $attachment->getMimeType() . '' . ''; @@ -191,11 +190,10 @@ public function rejectInvoiceCancellation($regId, $comment) { * @return SimpleXMLElement Response */ public function validateInvoiceSignature($regId, $invoice) { - $tools = new XmlTools(); $req = ''; $req .= '' . $regId . ''; $req .= '' . - '' . $tools->toBase64($invoice->getData()) . '' . + '' . XmlTools::toBase64($invoice->getData()) . '' . '' . $invoice->getFilename() . '' . '' . $invoice->getMimeType() . '' . ''; diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index c8a3feb..a653893 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -64,13 +64,11 @@ public function __construct($properties=array()) { * @return string Entity as Facturae XML */ public function getXML($schema) { - $tools = new XmlTools(); - // Add tax identification $xml = '' . '' . ($this->isLegalEntity ? 'J' : 'F') . '' . 'R' . - '' . $tools->escape($this->taxNumber) . '' . + '' . XmlTools::escape($this->taxNumber) . '' . ''; // Add administrative centres @@ -80,12 +78,12 @@ public function getXML($schema) { $xml .= ''; $xml .= '' . $centre->code . ''; $xml .= '' . $centre->role . ''; - $xml .= '' . $tools->escape($centre->name) . ''; + $xml .= '' . XmlTools::escape($centre->name) . ''; if (!is_null($centre->firstSurname)) { - $xml .= '' . $tools->escape($centre->firstSurname) . ''; + $xml .= '' . XmlTools::escape($centre->firstSurname) . ''; } if (!is_null($centre->lastSurname)) { - $xml .= '' . $tools->escape($centre->lastSurname) . ''; + $xml .= '' . XmlTools::escape($centre->lastSurname) . ''; } // Get centre address, else use fallback @@ -99,23 +97,23 @@ public function getXML($schema) { if ($addressTarget->countryCode === "ESP") { $xml .= '' . - '
' . $tools->escape($addressTarget->address) . '
' . + '
' . XmlTools::escape($addressTarget->address) . '
' . '' . $addressTarget->postCode . '' . - '' . $tools->escape($addressTarget->town) . '' . - '' . $tools->escape($addressTarget->province) . '' . + '' . XmlTools::escape($addressTarget->town) . '' . + '' . XmlTools::escape($addressTarget->province) . '' . '' . $addressTarget->countryCode . '' . '
'; } else { $xml .= '' . - '
' . $tools->escape($addressTarget->address) . '
' . - '' . $addressTarget->postCode . ' ' . $tools->escape($addressTarget->town) . '' . - '' . $tools->escape($addressTarget->province) . '' . + '
' . XmlTools::escape($addressTarget->address) . '
' . + '' . $addressTarget->postCode . ' ' . XmlTools::escape($addressTarget->town) . '' . + '' . XmlTools::escape($addressTarget->province) . '' . '' . $addressTarget->countryCode . '' . '
'; } if (!is_null($centre->description)) { - $xml .= '' . $tools->escape($centre->description) . ''; + $xml .= '' . XmlTools::escape($centre->description) . ''; } $xml .= '
'; } @@ -127,7 +125,7 @@ public function getXML($schema) { // Add data exclusive to `LegalEntity` if ($this->isLegalEntity) { - $xml .= '' . $tools->escape($this->name) . ''; + $xml .= '' . XmlTools::escape($this->name) . ''; $fields = array("book", "registerOfCompaniesLocation", "sheet", "folio", "section", "volume"); @@ -148,25 +146,25 @@ public function getXML($schema) { // Add data exclusive to `Individual` if (!$this->isLegalEntity) { - $xml .= '' . $tools->escape($this->name) . ''; - $xml .= '' . $tools->escape($this->firstSurname) . ''; - $xml .= '' . $tools->escape($this->lastSurname) . ''; + $xml .= '' . XmlTools::escape($this->name) . ''; + $xml .= '' . XmlTools::escape($this->firstSurname) . ''; + $xml .= '' . XmlTools::escape($this->lastSurname) . ''; } // Add address if ($this->countryCode === "ESP") { $xml .= '' . - '
' . $tools->escape($this->address) . '
' . + '
' . XmlTools::escape($this->address) . '
' . '' . $this->postCode . '' . - '' . $tools->escape($this->town) . '' . - '' . $tools->escape($this->province) . '' . + '' . XmlTools::escape($this->town) . '' . + '' . XmlTools::escape($this->province) . '' . '' . $this->countryCode . '' . '
'; } else { $xml .= '' . - '
' . $tools->escape($this->address) . '
' . - '' . $this->postCode . ' ' . $tools->escape($this->town) . '' . - '' . $tools->escape($this->province) . '' . + '
' . XmlTools::escape($this->address) . '
' . + '' . $this->postCode . ' ' . XmlTools::escape($this->town) . '' . + '' . XmlTools::escape($this->province) . '' . '' . $this->countryCode . '' . '
'; } @@ -187,7 +185,6 @@ public function getXML($schema) { * @return string Contact details XML */ private function getContactDetailsXML() { - $tools = new XmlTools(); $contactFields = [ "phone" => "Telephone", "fax" => "TeleFax", @@ -213,7 +210,7 @@ private function getContactDetailsXML() { foreach ($contactFields as $field=>$xmlName) { $value = $this->$field; if (!empty($value)) { - $xml .= "<$xmlName>" . $tools->escape($value) . ""; + $xml .= "<$xmlName>" . XmlTools::escape($value) . ""; } } $xml .= ''; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 35ffacb..879b9df 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -16,14 +16,12 @@ trait ExportableTrait { * @return string Output XML */ private function addOptionalFields($item, $fields) { - $tools = new XmlTools(); - $res = ""; foreach ($fields as $key=>$name) { if (is_int($key)) $key = $name; // Allow $item to have a different property name if (!empty($item[$key])) { $xmlTag = ucfirst($name); - $res .= "<$xmlTag>" . $tools->escape($item[$key]) . ""; + $res .= "<$xmlTag>" . XmlTools::escape($item[$key]) . ""; } } return $res; @@ -37,8 +35,6 @@ private function addOptionalFields($item, $fields) { * @return string|int XML data|Written file bytes */ public function export($filePath=null) { - $tools = new XmlTools(); - // Notify extensions foreach ($this->extensions as $ext) $ext->__onBeforeExport(); @@ -75,7 +71,7 @@ public function export($filePath=null) { $xml .= $paymentDetailsXML; if (!is_null($this->header['assignmentClauses'])) { $xml .= '' . - $tools->escape($this->header['assignmentClauses']) . + XmlTools::escape($this->header['assignmentClauses']) . ''; } $xml .= ''; @@ -160,7 +156,7 @@ public function export($filePath=null) { $xml .= '<' . $generalGroups[$g][0] . '>'; foreach ($totals[$groupTag] as $elem) { $xml .= "<$xmlTag>"; - $xml .= "<{$xmlTag}Reason>" . $tools->escape($elem['reason']) . ""; + $xml .= "<{$xmlTag}Reason>" . XmlTools::escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { $xml .= "<{$xmlTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } @@ -196,7 +192,7 @@ public function export($filePath=null) { ]); // Add required fields - $xml .= '' . $tools->escape($item['name']) . '' . + $xml .= '' . XmlTools::escape($item['name']) . '' . '' . $this->pad($item['quantity'], 'Item/Quantity') . '' . '' . $item['unitOfMeasure'] . '' . '' . $this->pad($item['unitPriceWithoutTax'], 'Item/UnitPriceWithoutTax') . '' . @@ -213,7 +209,7 @@ public function export($filePath=null) { $xml .= '<' . $itemGroups[$g][0] . '>'; foreach ($item[$group] as $elem) { $xml .= "<$groupTag>"; - $xml .= "<{$groupTag}Reason>" . $tools->escape($elem['reason']) . ""; + $xml .= "<{$groupTag}Reason>" . XmlTools::escape($elem['reason']) . ""; if (!is_null($elem['rate'])) { $xml .= "<{$groupTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . ""; } @@ -260,8 +256,8 @@ public function export($filePath=null) { // Add line period dates if (!empty($item['periodStart']) && !empty($item['periodEnd'])) { $xml .= ''; - $xml .= '' . $tools->escape($item['periodStart']) . ''; - $xml .= '' . $tools->escape($item['periodEnd']) . ''; + $xml .= '' . XmlTools::escape($item['periodStart']) . ''; + $xml .= '' . XmlTools::escape($item['periodEnd']) . ''; $xml .= ''; } @@ -283,7 +279,7 @@ public function export($filePath=null) { if (count($this->legalLiterals) > 0) { $xml .= ''; foreach ($this->legalLiterals as $reference) { - $xml .= '' . $tools->escape($reference) . ''; + $xml .= '' . XmlTools::escape($reference) . ''; } $xml .= ''; } @@ -362,9 +358,8 @@ private function getAdditionalDataXML() { if (!$hasData) return ""; // Generate initial XML block - $tools = new XmlTools(); $xml = ''; - if (!empty($relInvoice)) $xml .= '' . $tools->escape($relInvoice) . ''; + if (!empty($relInvoice)) $xml .= '' . XmlTools::escape($relInvoice) . ''; // Add attachments if (!empty($this->attachments)) { @@ -374,9 +369,9 @@ private function getAdditionalDataXML() { $type = end($type); $xml .= ''; $xml .= 'NONE'; - $xml .= '' . $tools->escape($type) . ''; + $xml .= '' . XmlTools::escape($type) . ''; $xml .= 'BASE64'; - $xml .= '' . $tools->escape($att['description']) . ''; + $xml .= '' . XmlTools::escape($att['description']) . ''; $xml .= '' . base64_encode($att['file']->getData()) . ''; $xml .= ''; } @@ -385,7 +380,7 @@ private function getAdditionalDataXML() { // Add additional information if (!empty($additionalInfo)) { - $xml .= '' . $tools->escape($additionalInfo) . ''; + $xml .= '' . XmlTools::escape($additionalInfo) . ''; } // Add extensions data From 6f16c29b55fc3dc6421c186e3adea8d203387813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 15 Oct 2022 19:01:56 +0200 Subject: [PATCH 090/146] Mejorado XmlTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase XmlTools - Añadidos tests unitarios --- src/Common/XmlTools.php | 38 ++++++++++++++++++-------------- tests/XmlToolsTest.php | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 tests/XmlToolsTest.php diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 756c644..9e70cfd 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -35,14 +35,15 @@ public static function randomId() { public static function getNamespaces($xml) { $namespaces = []; + // Extract element opening tag $xml = explode('>', $xml, 2); - $rawNamespaces = explode(' ', preg_replace('/\s+/', ' ', $xml[0])); - array_shift($rawNamespaces); - foreach ($rawNamespaces as $part) { - list($name, $value) = explode('=', $part, 2); - if (mb_strpos($name, 'xmlns:') === 0) { - $namespaces[$name] = mb_substr($value, 1, -1); - } + $xml = $xml[0]; + + // Extract namespaces + $matches = []; + preg_match_all('/\s(xmlns:[0-9a-z]+)=["\'](.+?)["\']/i', $xml, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $namespaces[$match[1]] = $match[2]; } return $namespaces; @@ -57,14 +58,16 @@ public static function getNamespaces($xml) { */ public static function injectNamespaces($xml, $namespaces) { $xml = explode('>', $xml, 2); - $rawNamespaces = explode(' ', preg_replace('/\s+/', ' ', $xml[0])); - $elementName = array_shift($rawNamespaces); - - // Include missing previous namespaces - foreach ($rawNamespaces as $part) { - list($name, $value) = explode('=', $part, 2); - if (!isset($namespaces[$name])) { - $namespaces[$name] = mb_substr($value, 1, -1); + + // Get element name (in the form of "/', function($match) { + return self::escape($match[1]); + }, $xml); + $xml = preg_replace('/<([0-9a-z:]+?) ?\/>/i', '<$1>', $xml); return $xml; } diff --git a/tests/XmlToolsTest.php b/tests/XmlToolsTest.php new file mode 100644 index 0000000..1235c00 --- /dev/null +++ b/tests/XmlToolsTest.php @@ -0,0 +1,48 @@ +"); + $this->assertEquals([ + 'xmlns:a' => 'abc', + 'xmlns:b' => 'xyz', + 'xmlns:c' => 'o o o', + ], $xmlns); + + $xmlns = XmlTools::getNamespaces(''); + $this->assertEquals([ + 'xmlns:a' => 'abc', + 'xmlns:b' => 'xyz' + ], $xmlns); + } + + + public function testCanInjectNamespaces() { + $xml = XmlTools::injectNamespaces('Hey', ['xmlns:abc' => 'abc']); + $this->assertEquals('Hey', $xml); + + $xml = XmlTools::injectNamespaces("", [ + 'test' => 'A test value', + 'xmlns:b' => 'XXXX', + 'xmlns:zzz' => 'Last namespace' + ]); + $this->assertEquals( + '', + $xml + ); + } + + + public function testCanCanonicalizeXml() { + $c14n = XmlTools::c14n(']]>'); + $this->assertEquals('äëïöüThis is a <test>', $c14n); + + $c14n = XmlTools::c14n(''); + $this->assertEquals('', $c14n); + } + +} From 772286d7cbfa98050fbfe9312eb9abfbdcdda529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 16 Oct 2022 10:55:41 +0200 Subject: [PATCH 091/146] =?UTF-8?q?A=C3=B1adido=20m=C3=A9todo=20para=20reg?= =?UTF-8?q?enerar=20IDs=20de=20firma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner - Añadido test unitario --- src/Common/FacturaeSigner.php | 10 ++++++++++ tests/SignerTest.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 7097883..755d0f3 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -48,6 +48,15 @@ final class FacturaeSigner { * Class constuctor */ public function __construct() { + $this->regenerateIds(); + } + + + /** + * Regenerate random element IDs + * @return self This instance + */ + public function regenerateIds() { $this->signatureId = 'Signature' . XmlTools::randomId(); $this->signedInfoId = 'Signature-SignedInfo' . XmlTools::randomId(); $this->signedPropertiesId = 'SignedPropertiesID' . XmlTools::randomId(); @@ -57,6 +66,7 @@ public function __construct() { $this->signatureSignedPropertiesId = $this->signatureId . '-SignedProperties' . XmlTools::randomId(); $this->signatureObjectId = $this->signatureId . '-Object' . XmlTools::randomId(); $this->timestampId = 'Timestamp-' . XmlTools::randomId(); + return $this; } diff --git a/tests/SignerTest.php b/tests/SignerTest.php index 7efbb99..005defa 100644 --- a/tests/SignerTest.php +++ b/tests/SignerTest.php @@ -18,6 +18,19 @@ private function getSigner() { } + public function testCanRegenerateIds() { + $signer = new FacturaeSigner(); + + $oldSignatureId = $signer->signatureId; + $signer->regenerateIds(); + $this->assertNotEquals($oldSignatureId, $signer->signatureId); + + $oldSignatureId = $signer->signatureId; + $signer->regenerateIds(); + $this->assertNotEquals($oldSignatureId, $signer->signatureId); + } + + public function testCannotSignWithoutKey() { $this->expectException(RuntimeException::class); $signer = new FacturaeSigner(); From e354815d2cc0602aac0c95055d98e330e0596f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 16 Oct 2022 10:58:36 +0200 Subject: [PATCH 092/146] =?UTF-8?q?A=C3=B1adida=20documentaci=C3=B3n=20de?= =?UTF-8?q?=20FacturaeSigner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado README.md - Añadida sección de firma avanzada --- README.md | 11 ++++ doc/firma-electronica/firma-avanzada.md | 82 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 doc/firma-electronica/firma-avanzada.md diff --git a/README.md b/README.md index 7427afd..850bcbb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,17 @@ $fac->sign("certificado.pfx", null, "passphrase"); $fac->export("mi-factura.xsig"); ``` +También permite firmar facturas que hayan sido generadas con otro programa: + +```php +$signer = new FacturaeSigner(); +$signer->setSigningKey("certificado.pfx", null, "passphrase"); + +$xml = file_get_contents(__DIR__ . "/factura.xml"); +$signedXml = $signer->sign($xml); +file_put_contents(__DIR__ . "/factura.xsig", $signedXml); +``` + ## Requisitos - PHP 5.6 o superior - OpenSSL (solo para firmar facturas) diff --git a/doc/firma-electronica/firma-avanzada.md b/doc/firma-electronica/firma-avanzada.md new file mode 100644 index 0000000..58092c4 --- /dev/null +++ b/doc/firma-electronica/firma-avanzada.md @@ -0,0 +1,82 @@ +--- +title: Firma avanzada +parent: Firma electrónica +nav_order: 2 +permalink: /firma-electronica/firma-avanzada.html +--- + +# Firma avanzada +La librería permite firmar documentos XML de FacturaE que hayan sido generados con otros programas a través de la clase `FacturaeSigner`. Esta misma clase es usada a nivel interno para firmar las facturas creadas con Facturae-PHP. + +```php +use josemmo\Facturae\Common\FacturaeSigner; +use RuntimeException; + +// Creación y configuración de la instancia +$signer = new FacturaeSigner(); +$signer->setSigningKey("certificado.pfx", null, "passphrase"); +$signer->setTimestampServer("https://www.safestamper.com/tsa", "usuario", "contraseña"); + +// Firma electrónica +$xml = file_get_contents(__DIR__ . "/factura.xml"); +try { + $signedXml = $signer->sign($xml); +} catch (RuntimeException $e) { + // Fallo al firmar +} + +// Sellado de tiempo +try { + $timestampedXml = $signer->timestamp($signedXml); +} catch (RuntimeException $e) { + // Fallo al añadir sello de tiempo +} +file_put_contents(__DIR__ . "/factura.xsig", $timestampedXml); +``` + +`FacturaeSigner` es capaz de firmar cualquier documento XML válido que cumpla con la especificación de FacturaE, siempre y cuando: + +- El elemento raíz del documento sea `` +- El namespace de FacturaE sea `xmlns:fe` +- El namespace de XMLDSig no aparezca (recomendable) o sea `xmlns:ds` +- El namespace de XAdES no aparezca (recomendable) o sea `xmlns:xades` + +La inmensa mayoría de programas que generan documentos de FacturaE cumplen con estos requisitos. + +--- + +## Fecha de la firma +Por defecto, al firmar una factura se utilizan la fecha y hora actuales como sello de tiempo. Si se quiere indicar otro valor, se debe utilizar el siguiente método: +```php +$signer->setSigningTime("2017-01-01T12:34:56+02:00"); +``` + +> #### NOTA +> Cambiar manualmente la fecha de la firma puede entrar en conflicto con el sellado de tiempo. + +--- + +## Identificadores de elementos XML +Al firmar un documento XML, durante la firma se añaden una serie de identificadores a determinados nodos en forma de atributos (por ejemplo, ``). +Estos identificadores son necesarios para validar la firma e integridad del documento. + +Por defecto, sus valores se generan de forma aleatoria en el momento de **instanciación** de la clase `FacturaeSigner`, por que lo que si se firman varias facturas con la misma instancia sus valores no cambian. +Se recomienda llamar al método `$signer->regenerateIds()` si se firman varios documentos: + +```php +$firstXml = file_get_contents(__DIR__ . "/factura_1.xml"); +$firstSignedXml = $signer->sign($firstXml); + +$signer->regenerateIds(); + +$secondXml = file_get_contents(__DIR__ . "/factura_2.xml"); +$secondSignedXml = $signer->sign($secondXml); +``` + +También es posible establecer valores deterministas a todos los IDs: + +```php +$signer->signatureId = "My-Custom-SignatureId"; +$signer->certificateId = "My-Custom-CertificateId"; +$signedXml = $signer->sign($xml); +``` From 67fdbbdaef194529cef966fca852afce6d9334be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 17 Oct 2022 20:52:18 +0200 Subject: [PATCH 093/146] Mejorada carga de certificados y claves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplazado KeyPairReader por KeyPairReaderTrait - Actualizado SoapClient - Actualizado FacturaeSigner - Actualizado SignableTrait - Añadidos tests unitarios > Closes #105 --- src/Common/FacturaeSigner.php | 40 +++------- src/Common/KeyPairReader.php | 95 ----------------------- src/Common/KeyPairReaderTrait.php | 109 +++++++++++++++++++++++++++ src/Face/SoapClient.php | 26 +++---- src/FacturaeTraits/SignableTrait.php | 17 +++-- tests/SignerTest.php | 17 ++++- 6 files changed, 159 insertions(+), 145 deletions(-) delete mode 100644 src/Common/KeyPairReader.php create mode 100644 src/Common/KeyPairReaderTrait.php diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 755d0f3..6cd8d66 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -14,8 +14,8 @@ final class FacturaeSigner { const SIGN_POLICY_URL = 'http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf'; const SIGN_POLICY_DIGEST = 'Ohixl6upD6av8N7pEvDABhEL6hM='; - /** @var KeyPairReader|null */ - private $keypairReader = null; + use KeyPairReaderTrait; + /** @var int|null */ private $signingTime = null; /** @var string|null */ @@ -81,27 +81,12 @@ public function setSigningTime($time) { } - /** - * Set signing key material - * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store - * @param string|null $privatePath Path to private key PEM file (should be null in case of PKCS#12) - * @param string $passphrase Private key passphrase - * @return self This instance - */ - public function setSigningKey($publicPath, $privatePath=null, $passphrase='') { - $this->keypairReader = new KeyPairReader($publicPath, $privatePath, $passphrase); - return $this; - } - - /** * Can sign * @return boolean Whether instance is ready to sign XML documents */ public function canSign() { - return ($this->keypairReader !== null) && - !empty($this->keypairReader->getPublicChain()) && - !empty($this->keypairReader->getPrivateKey()); + return !empty($this->publicChain) && !empty($this->privateKey); } @@ -137,15 +122,10 @@ public function canTimestamp() { */ public function sign($xml) { // Validate signing key material - if ($this->keypairReader === null) { - throw new RuntimeException('Missing signing key material'); - } - $publicChain = $this->keypairReader->getPublicChain(); - if (empty($publicChain)) { + if (empty($this->publicChain)) { throw new RuntimeException('Invalid signing key material: chain of certificates is empty'); } - $privateKey = $this->keypairReader->getPrivateKey(); - if (empty($privateKey)) { + if (empty($this->privateKey)) { throw new RuntimeException('Invalid signing key material: failed to read private key'); } @@ -172,7 +152,7 @@ public function sign($xml) { // Build element $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; - $certData = openssl_x509_parse($publicChain[0]); + $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; foreach ($certData['issuer'] as $item=>$value) { $certIssuer[] = "$item=$value"; @@ -185,7 +165,7 @@ public function sign($xml) { '' . '' . '' . - '' . XmlTools::getCertDigest($publicChain[0]) . '' . + '' . XmlTools::getCertDigest($this->publicChain[0]) . '' . '' . '' . '' . $certIssuer . '' . @@ -223,12 +203,12 @@ public function sign($xml) { ''; // Build element - $privateData = openssl_pkey_get_details($privateKey); + $privateData = openssl_pkey_get_details($this->privateKey); $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76); $modulus = str_replace("\r", '', $modulus); $exponent = base64_encode($privateData['rsa']['e']); $dsKeyInfo = '' . "\n" . '' . "\n"; - foreach ($publicChain as $pemCertificate) { + foreach ($this->publicChain as $pemCertificate) { $dsKeyInfo .= '' . "\n" . XmlTools::getCert($pemCertificate) . '' . "\n"; } $dsKeyInfo .= '' . "\n" . @@ -278,7 +258,7 @@ public function sign($xml) { $dsSignature = '' . "\n" . $dsSignedInfo . "\n" . '' . "\n" . - XmlTools::getSignature(XmlTools::injectNamespaces($dsSignedInfo, $xmlns), $privateKey) . + XmlTools::getSignature(XmlTools::injectNamespaces($dsSignedInfo, $xmlns), $this->privateKey) . '' . "\n" . $dsKeyInfo . "\n" . '' . diff --git a/src/Common/KeyPairReader.php b/src/Common/KeyPairReader.php deleted file mode 100644 index c6c551c..0000000 --- a/src/Common/KeyPairReader.php +++ /dev/null @@ -1,95 +0,0 @@ -publicChain; - } - - - /** - * Get public key - * @return string|null Certificate for the Public Key in PEM format - */ - public function getPublicKey() { - return empty($this->publicChain) ? null : $this->publicChain[0]; - } - - - /** - * Get private key - * @return \OpenSSLAsymmetricKey|resource|null Decrypted Private Key - */ - public function getPrivateKey() { - return $this->privateKey; - } - - - /** - * KeyPairReader constructor - * - * @param string $publicPath Path to public key in PEM or PKCS#12 file - * @param string|null $privatePath Path to private key (null for PKCS#12) - * @param string $passphrase Private key passphrase - */ - public function __construct($publicPath, $privatePath=null, $passphrase="") { - if (is_null($privatePath)) { - $this->readPkcs12($publicPath, $passphrase); - } else { - $this->readX509($publicPath, $privatePath, $passphrase); - } - } - - - /** - * Read a X.509 certificate and PEM encoded private key - * - * @param string $publicPath Path to public key PEM file - * @param string $privatePath Path to private key PEM file - * @param string $passphrase Private key passphrase - */ - private function readX509($publicPath, $privatePath, $passphrase) { - if (!is_file($publicPath) || !is_file($privatePath)) return; - - // Validate and normalize public key - $publicKey = openssl_x509_read(file_get_contents($publicPath)); - if (empty($publicKey)) return; - openssl_x509_export($publicKey, $publicKeyPem); - $this->publicChain = array($publicKeyPem); - - // Decrypt private key - $this->privateKey = openssl_pkey_get_private(file_get_contents($privatePath), $passphrase); - } - - - /** - * Read a PKCS#12 Certificate Store - * - * @param string $certPath The certificate store file name - * @param string $passphrase Password for unlocking the PKCS#12 file - */ - private function readPkcs12($certPath, $passphrase) { - if (!is_file($certPath)) return; - if (openssl_pkcs12_read(file_get_contents($certPath), $store, $passphrase)) { - $this->publicChain = array($store['cert']); - if (!empty($store['extracerts'])) { - $this->publicChain = array_merge($this->publicChain, $store['extracerts']); - } - $this->privateKey = openssl_pkey_get_private($store['pkey']); - unset($store); - } - } - -} diff --git a/src/Common/KeyPairReaderTrait.php b/src/Common/KeyPairReaderTrait.php new file mode 100644 index 0000000..7041b6f --- /dev/null +++ b/src/Common/KeyPairReaderTrait.php @@ -0,0 +1,109 @@ +publicChain[] = $normalizedCertificate; + return true; + } + + + /** + * Set private key + * + * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string $privateKey OpenSSL instance, PEM string or filepath + * @param string $passphrase Passphrase to decrypt (optional) + * @return boolean Success result + */ + public function setPrivateKey($privateKey, $passphrase='') { + // Read file from path + if (is_string($privateKey) && strpos($privateKey, ' PRIVATE KEY-----') === false) { + $privateKey = file_get_contents($privateKey); + } + + // Validate and extract private key + if (empty($privateKey)) { + return false; + } + $privateKey = openssl_pkey_get_private($privateKey, $passphrase); + if ($privateKey === false) { + return false; + } + + // Set private key + $this->privateKey = $privateKey; + return true; + } + + + /** + * Load public chain and private key from PKCS#12 Certificate Store + * + * @param string $certificateStore PKCS#12 bytes or filepath + * @param string $passphrase Password for unlocking the PKCS#12 file + * @return boolean Success result + */ + public function loadPkcs12($certificateStore, $passphrase) { + // Read file from path + // (look for "1.2.840.113549.1.7.1" ASN.1 object identifier) + if (strpos($certificateStore, "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x07\x01") === false) { + $certificateStore = file_get_contents($certificateStore); + } + + // Validate and parse certificate store + if (empty($certificateStore)) { + return false; + } + if (!openssl_pkcs12_read($certificateStore, $parsed, $passphrase)) { + return false; + } + + // Set public chain and private key + $this->publicChain = []; + $this->publicChain[] = $parsed['cert']; + if (!empty($parsed['extracerts'])) { + $this->publicChain = array_merge($this->publicChain, $parsed['extracerts']); + } + $this->privateKey = openssl_pkey_get_private($parsed['pkey']); + return true; + } + +} diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index c62ad10..7790a3d 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -2,29 +2,29 @@ namespace josemmo\Facturae\Face; use josemmo\Facturae\Facturae; -use josemmo\Facturae\Common\KeyPairReader; +use josemmo\Facturae\Common\KeyPairReaderTrait; use josemmo\Facturae\Common\XmlTools; abstract class SoapClient { const REQUEST_EXPIRATION = 60; // In seconds - private $publicKey; - private $privateKey; - + use KeyPairReaderTrait; /** * SoapClient constructor * - * @param string $publicPath Path to public key in PEM or PKCS#12 file - * @param string|null $privatePath Path to private key (null for PKCS#12) - * @param string $passphrase Private key passphrase + * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string $storeOrCertificate Certificate or PKCS#12 store + * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|null $privateKey Private key (`null` for PKCS#12) + * @param string $passphrase Store or private key passphrase */ - public function __construct($publicPath, $privatePath=null, $passphrase="") { - $reader = new KeyPairReader($publicPath, $privatePath, $passphrase); - $this->publicKey = $reader->getPublicKey(); - $this->privateKey = $reader->getPrivateKey(); - unset($reader); + public function __construct($storeOrCertificate, $privateKey=null, $passphrase='') { + if ($privateKey === null) { + $this->loadPkcs12($storeOrCertificate, $passphrase); + } else { + $this->addCertificate($storeOrCertificate); + $this->setPrivateKey($privateKey, $passphrase); + } } @@ -87,7 +87,7 @@ protected function request($body) { 'EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" ' . 'wsu:Id="' . $certId . '" ' . 'ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3">' . - XmlTools::getCert($this->publicKey, false) . + XmlTools::getCert($this->publicChain[0], false) . ''; // Generate signed info diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 625da45..65b1791 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -60,14 +60,19 @@ public function setTimestampServer($server, $user=null, $pass=null) { /** * Sign - * @param string $publicPath Path to public key PEM file or PKCS#12 certificate store - * @param string|null $privatePath Path to private key PEM file (should be null in case of PKCS#12) - * @param string $passphrase Private key passphrase - * @return boolean Success + * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string $storeOrCertificate Certificate or PKCS#12 store + * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|null $privateKey Private key (`null` for PKCS#12) + * @param string $passphrase Store or private key passphrase + * @return boolean Success */ - public function sign($publicPath, $privatePath=null, $passphrase="") { + public function sign($storeOrCertificate, $privateKey=null, $passphrase='') { $signer = $this->getSigner(); - $signer->setSigningKey($publicPath, $privatePath, $passphrase); + if ($privateKey === null) { + $signer->loadPkcs12($storeOrCertificate, $passphrase); + } else { + $signer->addCertificate($storeOrCertificate); + $signer->setPrivateKey($privateKey, $passphrase); + } return $signer->canSign(); } diff --git a/tests/SignerTest.php b/tests/SignerTest.php index 005defa..327e12e 100644 --- a/tests/SignerTest.php +++ b/tests/SignerTest.php @@ -12,12 +12,27 @@ final class SignerTest extends AbstractTest { */ private function getSigner() { $signer = new FacturaeSigner(); - $signer->setSigningKey(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $signer->loadPkcs12(self::CERTS_DIR . '/facturae.p12', self::FACTURAE_CERT_PASS); $signer->setTimestampServer('http://tss.accv.es:8318/tsa'); return $signer; } + public function testCanLoadPemStrings() { + $signer = new FacturaeSigner(); + $signer->addCertificate(file_get_contents(self::CERTS_DIR . '/facturae-public.pem')); + $signer->setPrivateKey(file_get_contents(self::CERTS_DIR . '/facturae-private.pem'), self::FACTURAE_CERT_PASS); + $this->assertTrue($signer->canSign()); + } + + + public function testCanLoadStoreBytes() { + $signer = new FacturaeSigner(); + $signer->loadPkcs12(file_get_contents(self::CERTS_DIR . '/facturae.p12'), self::FACTURAE_CERT_PASS); + $this->assertTrue($signer->canSign()); + } + + public function testCanRegenerateIds() { $signer = new FacturaeSigner(); From 56d39d918c02d41167693dea9c6468da000225ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 17 Oct 2022 20:55:38 +0200 Subject: [PATCH 094/146] =?UTF-8?q?Actualizada=20documentaci=C3=B3n=20de?= =?UTF-8?q?=20firma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada documentación - Actualizado README.md > Related to #105 --- README.md | 2 +- doc/envio-y-recepcion/face.md | 3 +++ doc/envio-y-recepcion/faceb2b.md | 3 +++ doc/firma-electronica/firma-avanzada.md | 2 +- doc/firma-electronica/firma-electronica.md | 15 ++++++++++++++- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 850bcbb..d6bb6ca 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ También permite firmar facturas que hayan sido generadas con otro programa: ```php $signer = new FacturaeSigner(); -$signer->setSigningKey("certificado.pfx", null, "passphrase"); +$signer->loadPkcs12("certificado.pfx", "passphrase"); $xml = file_get_contents(__DIR__ . "/factura.xml"); $signedXml = $signer->sign($xml); diff --git a/doc/envio-y-recepcion/face.md b/doc/envio-y-recepcion/face.md index 47c0027..d4876c1 100644 --- a/doc/envio-y-recepcion/face.md +++ b/doc/envio-y-recepcion/face.md @@ -29,6 +29,9 @@ Al igual que al firmar una factura electrónica, puedes usar un solo archivo `.p $face = new FaceClient("certificado.pfx", null, "passphrase"); ``` +> #### NOTA +> Para más información sobre qué otros valores pueden tomar los parámetros del constructor, consulta la documentación de [firma electrónica](../firma-electronica/). + Por defecto, `FaceClient` se comunica con el entorno de producción de FACe. Para usar el entorno de pruebas (*staging*) puedes utilizar el siguiente método: ```php $face->setProduction(false); diff --git a/doc/envio-y-recepcion/faceb2b.md b/doc/envio-y-recepcion/faceb2b.md index 63c84ab..d111f5b 100644 --- a/doc/envio-y-recepcion/faceb2b.md +++ b/doc/envio-y-recepcion/faceb2b.md @@ -32,6 +32,9 @@ Al igual que al firmar una factura electrónica, puedes usar un solo archivo `.p $face = new Faceb2bClient("certificado.pfx", null, "passphrase"); ``` +> #### NOTA +> Para más información sobre qué otros valores pueden tomar los parámetros del constructor, consulta la documentación de [firma electrónica](../firma-electronica/). + Por defecto, `Faceb2bClient` se comunica con el entorno de producción de FACeB2B. Para usar el entorno de pruebas (*staging*) puedes utilizar el siguiente método: ```php $face->setProduction(false); diff --git a/doc/firma-electronica/firma-avanzada.md b/doc/firma-electronica/firma-avanzada.md index 58092c4..df9b3bb 100644 --- a/doc/firma-electronica/firma-avanzada.md +++ b/doc/firma-electronica/firma-avanzada.md @@ -14,7 +14,7 @@ use RuntimeException; // Creación y configuración de la instancia $signer = new FacturaeSigner(); -$signer->setSigningKey("certificado.pfx", null, "passphrase"); +$signer->loadPkcs12("certificado.pfx", "passphrase"); $signer->setTimestampServer("https://www.safestamper.com/tsa", "usuario", "contraseña"); // Firma electrónica diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md index f13e76f..6cf303a 100644 --- a/doc/firma-electronica/firma-electronica.md +++ b/doc/firma-electronica/firma-electronica.md @@ -10,11 +10,18 @@ Aunque es posible exportar las facturas sin firmarlas, es un paso obligatorio pa Para firmar facturas se necesita un certificado electrónico (generalmente expedido por la FNMT) del que extraer su clave pública y su clave privada. ## Firmado con clave pública y privada X.509 -Si se tienen las clave pública y privada en archivos independientes se debe utilizar este método con los siguientes argumentos: +Si se tiene la clave pública (un certificado) y la clave privada en archivos independientes, se debe utilizar este método con los siguientes argumentos: ```php $fac->sign("clave_publica.pem", "clave_privada.pem", "passphrase"); ``` +También se pueden pasar como parámetros los bytes de ambos ficheros en vez de sus rutas, o instancias de `OpenSSLCertificate` y `OpenSSLAsymmetricKey`, respectivamente: +```php +$publicKey = openssl_x509_read("clave_publica.pem"); +$encryptedPrivateKey = file_get_contents("clave_privada.pem"); +$fac->sign($publicKey, $encryptedPrivateKey, "passphrase"); +``` + > #### NOTA > Los siguientes comandos permiten extraer el certificado (clave pública) y la clave privada de un archivo PFX: > @@ -31,6 +38,12 @@ Desde la versión 1.0.5 de Facturae-PHP ya es posible cargar un banco de certifi $fac->sign("certificado.pfx", null, "passphrase"); ``` +También se pueden pasar como parámetro los bytes del banco PKCS#12: +```php +$encryptedStore = file_get_contents("certificado.pfx"); +$fac->sign($encryptedStore, null, "passphrase"); +``` + > #### NOTA > Al utilizar un banco PKCS#12, Facturae-PHP incluirá la cadena completa de certificados en la factura al firmarla. > From 915dbb08826ba2710f438d7d584f48ce5fafc591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 31 Oct 2022 11:30:06 +0100 Subject: [PATCH 095/146] =?UTF-8?q?A=C3=B1adidos=20suplidos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modificados ExportableTrait y PropertiesTrait - Añadida clase ReimbursableExpense - Modificada clase FacturaeParty - Actualizados test unitarios - Actualizada documentación > Closes #103 --- doc/propiedades/suplidos.md | 22 +++++++++++ src/FacturaeParty.php | 14 +++++++ src/FacturaeTraits/ExportableTrait.php | 46 +++++++++++++++++++++-- src/FacturaeTraits/PropertiesTrait.php | 48 +++++++++++++++++++++++- src/ReimbursableExpense.php | 51 ++++++++++++++++++++++++++ tests/InvoiceTest.php | 11 ++++++ 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 doc/propiedades/suplidos.md create mode 100644 src/ReimbursableExpense.php diff --git a/doc/propiedades/suplidos.md b/doc/propiedades/suplidos.md new file mode 100644 index 0000000..f4eb973 --- /dev/null +++ b/doc/propiedades/suplidos.md @@ -0,0 +1,22 @@ +--- +title: Suplidos +parent: Propiedades de una factura +nav_order: 8 +permalink: /propiedades/suplidos.html +--- + +# Suplidos +La especificación de FacturaE permite añadir gastos a cuenta de terceros a una factura (suplidos). +Para ello, se debe hacer uso de la clase `ReimbursableExpense`: +```php +$fac->addReimbursableExpense(new ReimbursableExpense([ + "seller" => new FacturaeParty(["taxNumber" => "00000000A"]), + "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]), + "issueDate" => "2017-11-27", + "invoiceNumber" => "EX-19912", + "invoiceSeriesCode" => "156A", + "amount" => 100.00 +])); +``` + +Todos las propiedades de un suplido son opcionales excepto el importe (`amount`). diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index a653893..f318e6d 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -12,6 +12,7 @@ class FacturaeParty { public $isLegalEntity = true; // By default is a company and not a person + public $isEuropeanUnionResident = true; // By default resides in the EU public $taxNumber = null; public $name = null; @@ -218,5 +219,18 @@ private function getContactDetailsXML() { return $xml; } + + /** + * Get item XML for reimbursable expense node + * + * @return string Reimbursable expense XML + */ + public function getReimbursableExpenseXML() { + $xml = '' . ($this->isLegalEntity ? 'J' : 'F') . ''; + $xml .= '' . ($this->isEuropeanUnionResident ? 'R' : 'E') . ''; + $xml .= '' . XmlTools::escape($this->taxNumber) . ''; + return $xml; + } + } diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 879b9df..df9f25e 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -3,6 +3,7 @@ use josemmo\Facturae\Common\XmlTools; use josemmo\Facturae\FacturaePayment; +use josemmo\Facturae\ReimbursableExpense; /** * Allows a Facturae instance to be exported to XML. @@ -56,10 +57,10 @@ public function export($filePath=null) { '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . '' . '' . - '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . + '' . $this->pad($totals['totalOutstandingAmount'], 'InvoiceTotal') . '' . '' . '' . - '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . + '' . $this->pad($totals['totalExecutableAmount'], 'InvoiceTotal') . '' . '' . '' . $this->currency . '' . ''; @@ -166,14 +167,51 @@ public function export($filePath=null) { $xml .= ''; } + // Add some total amounts $xml .= '' . $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts') . ''; $xml .= '' . $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges') . ''; $xml .= '' . $this->pad($totals['grossAmountBeforeTaxes'], 'TotalGrossAmountBeforeTaxes') . ''; $xml .= '' . $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs') . ''; $xml .= '' . $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld') . ''; $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; - $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; - $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . ''; + + // Add reimbursable expenses + if (!empty($this->reimbursableExpenses)) { + $xml .= ''; + foreach ($this->reimbursableExpenses as $expense) { /** @var ReimbursableExpense $expense */ + $xml .= ''; + if ($expense->seller !== null) { + $xml .= ''; + $xml .= $expense->seller->getReimbursableExpenseXML(); + $xml .= ''; + } + if ($expense->buyer !== null) { + $xml .= ''; + $xml .= $expense->buyer->getReimbursableExpenseXML(); + $xml .= ''; + } + if ($expense->issueDate !== null) { + $issueDate = is_string($expense->issueDate) ? strtotime($expense->issueDate) : $expense->issueDate; + $xml .= '' . date('Y-m-d', $issueDate) . ''; + } + if ($expense->invoiceNumber !== null) { + $xml .= '' . XmlTools::escape($expense->invoiceNumber) . ''; + } + if ($expense->invoiceSeriesCode !== null) { + $xml .= '' . XmlTools::escape($expense->invoiceSeriesCode) . ''; + } + $xml .= '' . $this->pad($expense->amount, 'ReimbursableExpense/Amount') . ''; + $xml .= ''; + } + $xml .= ''; + } + + // Add more total amounts + $xml .= '' . $this->pad($totals['totalOutstandingAmount'], 'TotalOutstandingAmount') . ''; + $xml .= '' . $this->pad($totals['totalExecutableAmount'], 'TotalExecutableAmount') . ''; + if (!empty($this->reimbursableExpenses)) { + $xml .= '' . $this->pad($totals['totalReimbursableExpenses'], 'TotalReimbursableExpenses') . ''; + } $xml .= ''; // Add invoice items diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 02c3347..d32a48c 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -4,6 +4,7 @@ use josemmo\Facturae\FacturaeFile; use josemmo\Facturae\FacturaeItem; use josemmo\Facturae\FacturaePayment; +use josemmo\Facturae\ReimbursableExpense; /** * Implements all attributes and methods needed to make Facturae instantiable. @@ -34,6 +35,8 @@ trait PropertiesTrait { "seller" => null, "buyer" => null ); + /** @var ReimbursableExpense[] */ + protected $reimbursableExpenses = array(); protected $items = array(); protected $legalLiterals = array(); protected $discounts = array(); @@ -651,6 +654,36 @@ public function clearItems() { } + /** + * Add reimbursable expense + * @param ReimbursableExpense $item Reimbursable expense + * @return Facturae Invoice instance + */ + public function addReimbursableExpense($item) { + $this->reimbursableExpenses[] = $item; + return $this; + } + + + /** + * Get reimbursable expenses + * @return ReimbursableExpense[] Reimbursable expenses + */ + public function getReimbursableExpenses() { + return $this->reimbursableExpenses; + } + + + /** + * Clear reimbursable expenses + * @return Facturae Invoice instance + */ + public function clearReimbursableExpenses() { + $this->reimbursableExpenses = array(); + return $this; + } + + /** * Get totals * @return array Invoice totals @@ -667,7 +700,10 @@ public function getTotals() { "totalGeneralDiscounts" => 0, "totalGeneralCharges" => 0, "totalTaxesOutputs" => 0, - "totalTaxesWithheld" => 0 + "totalTaxesWithheld" => 0, + "totalReimbursableExpenses" => 0, + "totalOutstandingAmount" => 0, + "totalExecutableAmount" => 0 ); // Precalculate total global amount (needed for general discounts and charges) @@ -742,11 +778,19 @@ public function getTotals() { } } + // Get total reimbursable expenses amount + if (!empty($this->reimbursableExpenses)) { + foreach ($this->reimbursableExpenses as $expense) { + $totals['totalReimbursableExpenses'] += $expense->amount; + } + } + // Pre-round some total values (needed to create a sum-reasonable invoice total) $totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs'); $totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld'); $totals['totalGeneralDiscounts'] = $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts'); $totals['totalGeneralCharges'] = $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges'); + $totals['totalReimbursableExpenses'] = $this->pad($totals['totalReimbursableExpenses'], 'TotalReimbursableExpenses'); $totals['grossAmount'] = $this->pad($totals['grossAmount'], 'TotalGrossAmount'); // Fill missing values @@ -755,6 +799,8 @@ public function getTotals() { 'TotalGrossAmountBeforeTaxes' ); $totals['invoiceAmount'] = $totals['grossAmountBeforeTaxes'] + $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld']; + $totals['totalOutstandingAmount'] = $totals['invoiceAmount']; + $totals['totalExecutableAmount'] = $totals['invoiceAmount'] + $totals['totalReimbursableExpenses']; return $totals; } diff --git a/src/ReimbursableExpense.php b/src/ReimbursableExpense.php new file mode 100644 index 0000000..353019a --- /dev/null +++ b/src/ReimbursableExpense.php @@ -0,0 +1,51 @@ +$value) { + $this->{$key} = $value; + } + } +} diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index aa8e32c..d563048 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -7,6 +7,7 @@ use josemmo\Facturae\FacturaeParty; use josemmo\Facturae\FacturaePayment; use josemmo\Facturae\FacturaeCentre; +use josemmo\Facturae\ReimbursableExpense; final class InvoiceTest extends AbstractTest { @@ -184,6 +185,16 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $fac->addDiscount('A mitad de precio', 50); $fac->addCharge('Recargo del 50%', 50); + // Añadimos un suplido + $fac->addReimbursableExpense(new ReimbursableExpense([ + "seller" => new FacturaeParty(["taxNumber" => "00000000A"]), + "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]), + "issueDate" => "2017-11-27", + "invoiceNumber" => "EX-19912", + "invoiceSeriesCode" => "156A", + "amount" => 99.9991172 + ])); + // Establecemos un un cesionario (solo en algunos casos) if ($isPfx) { $fac->setAssignee(new FacturaeParty([ From a496651b766fd2c59dc62288faec764237f4dc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 19 Nov 2022 12:12:49 +0100 Subject: [PATCH 096/146] v1.7.4 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 8e877dd..6db8631 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.3"; + const VERSION = "1.7.4"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From c28029e3f630c37d4eb1369489502a7ce3244294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 8 Dec 2022 21:03:56 +0100 Subject: [PATCH 097/146] =?UTF-8?q?A=C3=B1adido=20PHP=208.3=20a=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado workflow de tests --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b3e2e4..bdda11c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,17 +13,17 @@ jobs: continue-on-error: ${{ matrix.experimental || false }} strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] include: - - php-version: '8.1' + - php-version: '8.2' test-ws: true send-coverage: true - - php-version: '8.2' + - php-version: '8.3' experimental: true steps: # Download code from repository - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Setup PHP and Composer - name: Setup PHP From e35c586b053e038e627268dbc2f30876ecf1bd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 9 Dec 2022 10:24:18 +0100 Subject: [PATCH 098/146] Simplificados logs de tests - Actualizado AbstractTest::validateInvoiceXML() para reducir salida en caso de error --- tests/AbstractTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 02b4b1c..570054f 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -95,13 +95,13 @@ protected function validateInvoiceXML($path, $validateSignature=false) { unset($ch); // Validate results - $this->assertNotEmpty($res); - $this->assertStringContainsString('euro_ok.png', $res, 'Invalid XML Format'); + $this->assertNotEmpty($res, 'Invalid Validator Response'); + $this->assertNotEmpty(strpos($res, 'euro_ok.png'), 'Invalid XML Format'); if ($validateSignature) { - $this->assertStringContainsString('>Nivel de Firma Válido<', $res, 'Invalid Signature'); + $this->assertNotEmpty(strpos($res, '>Nivel de Firma Válido<'), 'Invalid Signature'); } if (strpos($res, '>Sellos de Tiempo<') !== false) { - $this->assertStringContainsString('>XAdES_T<', $res, 'Invalid Timestamp'); + $this->assertNotEmpty(strpos($res, '>XAdES_T<'), 'Invalid Timestamp'); } } From da9f07017cff66fcd3350774f8e381ee3db3a3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 9 Dec 2022 10:26:12 +0100 Subject: [PATCH 099/146] Actualizados certificados de tests - Re-exportado banco de certificados para usar SHA-256 como algoritmo de digest > Relacionado con #101 --- tests/certs/facturae.p12 | Bin 7084 -> 6947 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/certs/facturae.p12 b/tests/certs/facturae.p12 index b60d4f08c8ddf207d7ed2455872c465c408282c3..5867c103b5fc3321cf4ec46f188b2e19fd0f1f2a 100644 GIT binary patch literal 6947 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 literal 7084 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 BVOIbE From f5c79b7a5b4a2e8b5901edd73fca8ef72c82acb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 31 Dec 2022 13:36:18 +0100 Subject: [PATCH 100/146] =?UTF-8?q?A=C3=B1adido=20tipo=20de=20documento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidas constantes a clase Facturae - Añadidos getter y setter en PropertiesTrait - Actualizado ExportableTrait::export() - Actualizados tests unitarios - Actualizada documentación > Closes #113 --- doc/anexos/constantes.md | 9 +++++++++ src/Facturae.php | 3 +++ src/FacturaeTraits/ExportableTrait.php | 2 +- src/FacturaeTraits/PropertiesTrait.php | 21 +++++++++++++++++++++ tests/MethodsTest.php | 5 +++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 2266fee..3d9046a 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -17,6 +17,15 @@ permalink: /anexos/constantes.html --- +## Tipos de documento + +|Constante|Descripción| +|--------:|:----------| +|`Facturae::INVOICE_FULL`|Factura completa| +|`Facturae::INVOICE_SIMPLIFIED`|Factura simplificada| + +--- + ## Modos de precisión |Constante|Descripción| diff --git a/src/Facturae.php b/src/Facturae.php index 6db8631..f92e9ac 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -23,6 +23,9 @@ class Facturae { "digest" => "Ohixl6upD6av8N7pEvDABhEL6hM=" ); + const INVOICE_FULL = "FC"; + const INVOICE_SIMPLIFIED = "FA"; + const PRECISION_LINE = 1; const PRECISION_INVOICE = 2; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index df9f25e..6de2d1f 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -92,7 +92,7 @@ public function export($filePath=null) { $xml .= '' . '' . $this->header['number'] . '' . '' . $this->header['serie'] . '' . - 'FC' . + '' . $this->header['type'] . '' . 'OO' . ''; $xml .= ''; diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index d32a48c..3b11387 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -17,6 +17,7 @@ trait PropertiesTrait { protected $version = null; protected $precision = self::PRECISION_LINE; protected $header = array( + "type" => self::INVOICE_FULL, "serie" => null, "number" => null, "issueDate" => null, @@ -175,6 +176,26 @@ public function getBuyer() { } + /** + * Set document type + * @param string $type Document type + * @return Facturae Invoice instance + */ + public function setType($type) { + $this->header['type'] = $type; + return $this; + } + + + /** + * Get document type + * @return string Document type + */ + public function getType() { + return $this->header['type']; + } + + /** * Set invoice number * @param string $serie Serie code of the invoice diff --git a/tests/MethodsTest.php b/tests/MethodsTest.php index 9bf9a61..01bab42 100644 --- a/tests/MethodsTest.php +++ b/tests/MethodsTest.php @@ -17,6 +17,11 @@ public function testMethods() { $fac = new Facturae($schema); $this->assertEquals($schema, $fac->getSchemaVersion()); + // Document type + $this->assertEquals(Facturae::INVOICE_FULL, $fac->getType()); + $fac->setType(Facturae::INVOICE_SIMPLIFIED); + $this->assertEquals(Facturae::INVOICE_SIMPLIFIED, $fac->getType()); + // Parties $seller = new FacturaeParty(['name'=>'Seller']); $buyer = new FacturaeParty(['name'=>'Buyer']); From 5e703e775f1cf5558575cc100916c03b5e0ec86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 4 Jan 2023 17:33:44 +0100 Subject: [PATCH 101/146] Implementada factura rectificativa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creada clase CorrectiveDetails - Actualizados PropertiesTrait y ExportableTrait - Actualizados tests unitarios - Actualizada documentación > Closes #107 --- doc/anexos/constantes.md | 11 ++ doc/propiedades/rectificativas.md | 56 +++++++++ src/CorrectiveDetails.php | 157 +++++++++++++++++++++++++ src/FacturaeTraits/ExportableTrait.php | 39 +++++- src/FacturaeTraits/PropertiesTrait.php | 23 ++++ tests/InvoiceTest.php | 13 ++ 6 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 doc/propiedades/rectificativas.md create mode 100644 src/CorrectiveDetails.php diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 3d9046a..2288f34 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -35,6 +35,17 @@ permalink: /anexos/constantes.html --- +## Modos de rectificación + +|Constante|Descripción| +|--------:|:----------| +|`CorrectiveDetails::METHOD_FULL`|Rectificación íntegra| +|`CorrectiveDetails::METHOD_DIFFERENCES`|Rectificación por diferencias| +|`CorrectiveDetails::METHOD_VOLUME_DISCOUNT`|Rectificación por descuento por volumen de operaciones durante un periodo| +|`CorrectiveDetails::METHOD_AEAT_AUTHORIZED`|Autorizadas por la Agencia Tributaria| + +--- + ## Formas de pago |Constante|Descripción| diff --git a/doc/propiedades/rectificativas.md b/doc/propiedades/rectificativas.md new file mode 100644 index 0000000..8811a0e --- /dev/null +++ b/doc/propiedades/rectificativas.md @@ -0,0 +1,56 @@ +--- +title: Rectificativas +parent: Propiedades de una factura +nav_order: 9 +permalink: /propiedades/rectificativas.html +--- + +# Facturas rectificativas +Por defecto, todos los documentos generados con la librería son facturas originales. Para generar una factura original +**rectificativa** se deben añadir una serie de propiedades adicionales a través del método `$fac->setCorrective()`: +```php +$fac->setCorrective(new CorrectiveDetails([ + // Serie y número de la factura a rectificar + "invoiceSeriesCode" => "EMP201712", + "invoiceNumber" => "0002", + + // Código del motivo de la rectificación según: + // - RD 1496/2003 (del "01" al 16") + // - Art. 80 Ley 37/92 (del "80" al "85") + "reason" => "01", + + // Periodo de tributación de la factura a rectificar + "taxPeriodStart" => "2017-10-01", + "taxPeriodEnd" => "2017-10-31", + + // Modo del criterio empleado para la rectificación + "correctionMethod" => CorrectiveDetails::METHOD_FULL +])); +``` + +Las razones (valores de `reason`) admitidas en la especificación de FacturaE son: + +- `01`: Número de la factura +- `02`: Serie de la factura +- `03`: Fecha expedición +- `04`: Nombre y apellidos/Razón Social-Emisor +- `05`: Nombre y apellidos/Razón Social-Receptor +- `06`: Identificación fiscal Emisor/obligado +- `07`: Identificación fiscal Receptor +- `08`: Domicilio Emisor/Obligado +- `09`: Domicilio Receptor +- `10`: Detalle Operación +- `11`: Porcentaje impositivo a aplicar +- `12`: Cuota tributaria a aplicar +- `13`: Fecha/Periodo a aplicar +- `14`: Clase de factura +- `15`: Literales legales +- `16`: Base imponible +- `80`: Cálculo de cuotas repercutidas +- `81`: Cálculo de cuotas retenidas +- `82`: Base imponible modificada por devolución de envases / embalajes +- `83`: Base imponible modificada por descuentos y bonificaciones +- `84`: Base imponible modificada por resolución firme, judicial o administrativa +- `85`: Base imponible modificada cuotas repercutidas no satisfechas. Auto de declaración de concurso + +Los distintos modos de rectificación (valores de `correctionMethod`) se definen en las [constantes del anexo](../anexos/constantes.html#modos-de-rectificación). diff --git a/src/CorrectiveDetails.php b/src/CorrectiveDetails.php new file mode 100644 index 0000000..3a38529 --- /dev/null +++ b/src/CorrectiveDetails.php @@ -0,0 +1,157 @@ +$value) { + $this->{$key} = $value; + } + } + + /** + * Get reason description + * @return string Reason description + */ + public function getReasonDescription() { + // Use custom value if available + if ($this->reasonDescription !== null) { + return $this->reasonDescription; + } + + // Fallback to default value per specification + switch ($this->reason) { + case "01": + return "Número de la factura"; + case "02": + return "Serie de la factura"; + case "03": + return "Fecha expedición"; + case "04": + return "Nombre y apellidos/Razón Social-Emisor"; + case "05": + return "Nombre y apellidos/Razón Social-Receptor"; + case "06": + return "Identificación fiscal Emisor/obligado"; + case "07": + return "Identificación fiscal Receptor"; + case "08": + return "Domicilio Emisor/Obligado"; + case "09": + return "Domicilio Receptor"; + case "10": + return "Detalle Operación"; + case "11": + return "Porcentaje impositivo a aplicar"; + case "12": + return "Cuota tributaria a aplicar"; + case "13": + return "Fecha/Periodo a aplicar"; + case "14": + return "Clase de factura"; + case "15": + return "Literales legales"; + case "16": + return "Base imponible"; + case "80": + return "Cálculo de cuotas repercutidas"; + case "81": + return "Cálculo de cuotas retenidas"; + case "82": + return "Base imponible modificada por devolución de envases / embalajes"; + case "83": + return "Base imponible modificada por descuentos y bonificaciones"; + case "84": + return "Base imponible modificada por resolución firme, judicial o administrativa"; + case "85": + return "Base imponible modificada cuotas repercutidas no satisfechas. Auto de declaración de concurso"; + } + return ""; + } + + /** + * Get correction method description + * @return string Correction method description + */ + public function getCorrectionMethodDescription() { + // Use custom value if available + if ($this->correctionMethodDescription !== null) { + return $this->correctionMethodDescription; + } + + // Fallback to default value per specification + switch ($this->correctionMethod) { + case self::METHOD_FULL: + return "Rectificación íntegra"; + case self::METHOD_DIFFERENCES: + return "Rectificación por diferencias"; + case self::METHOD_VOLUME_DISCOUNT: + return "Rectificación por descuento por volumen de operaciones durante un periodo"; + case self::METHOD_AEAT_AUTHORIZED: + return "Autorizadas por la Agencia Tributaria"; + } + return ""; + } +} diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 6de2d1f..e0d9dc2 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -2,6 +2,7 @@ namespace josemmo\Facturae\FacturaeTraits; use josemmo\Facturae\Common\XmlTools; +use josemmo\Facturae\CorrectiveDetails; use josemmo\Facturae\FacturaePayment; use josemmo\Facturae\ReimbursableExpense; @@ -42,6 +43,8 @@ public function export($filePath=null) { // Prepare document $xml = ''; $totals = $this->getTotals(); + /** @var CorrectiveDetails|null */ + $corrective = $this->getCorrective(); $paymentDetailsXML = $this->getPaymentDetailsXML($totals); // Add header @@ -89,12 +92,36 @@ public function export($filePath=null) { // Add invoice data $xml .= ''; - $xml .= '' . - '' . $this->header['number'] . '' . - '' . $this->header['serie'] . '' . - '' . $this->header['type'] . '' . - 'OO' . - ''; + $xml .= ''; + $xml .= '' . XmlTools::escape($this->header['number']) . ''; + $xml .= '' . XmlTools::escape($this->header['serie']) . ''; + $xml .= '' . $this->header['type'] . ''; + $xml .= '' . ($corrective === null ? 'OO' : 'OR') . ''; + if ($corrective !== null) { + $xml .= ''; + if ($corrective->invoiceNumber !== null) { + $xml .= '' . XmlTools::escape($corrective->invoiceNumber) . ''; + } + if ($corrective->invoiceSeriesCode !== null) { + $xml .= '' . XmlTools::escape($corrective->invoiceSeriesCode) . ''; + } + $xml .= '' . $corrective->reason . ''; + $xml .= '' . XmlTools::escape($corrective->getReasonDescription()) . ''; + if ($corrective->taxPeriodStart !== null && $corrective->taxPeriodEnd !== null) { + $start = is_string($corrective->taxPeriodStart) ? strtotime($corrective->taxPeriodStart) : $corrective->taxPeriodStart; + $end = is_string($corrective->taxPeriodEnd) ? strtotime($corrective->taxPeriodEnd) : $corrective->taxPeriodEnd; + $xml .= '' . + '' . date('Y-m-d', $start) . '' . + '' . date('Y-m-d', $end) . '' . + ''; + } + $xml .= '' . $corrective->correctionMethod . ''; + $xml .= '' . + XmlTools::escape($corrective->getCorrectionMethodDescription()) . + ''; + $xml .= ''; + } + $xml .= ''; $xml .= ''; $xml .= '' . date('Y-m-d', $this->header['issueDate']) . ''; if (!is_null($this->header['startDate'])) { diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 3b11387..6906a99 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -1,6 +1,7 @@ null, "buyer" => null ); + /** @var CorrectiveDetails|null */ + protected $corrective = null; /** @var ReimbursableExpense[] */ protected $reimbursableExpenses = array(); protected $items = array(); @@ -176,6 +179,26 @@ public function getBuyer() { } + /** + * Set corrective details + * @param CorrectiveDetails|null $corrective Corrective details + * @return Facturae Invoice instance + */ + public function setCorrective($corrective) { + $this->corrective = $corrective; + return $this; + } + + + /** + * Get corrective details + * @return CorrectiveDetails|null Corrective details + */ + public function getCorrective() { + return $this->corrective; + } + + /** * Set document type * @param string $type Document type diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index d563048..2af3fa5 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -1,6 +1,7 @@ setCorrective(new CorrectiveDetails([ + "invoiceSeriesCode" => "EMP201712", + "invoiceNumber" => "0002", + "reason" => "03", + "taxPeriodStart" => "2017-10-01", + "taxPeriodEnd" => "2017-10-31", + "correctionMethod" => CorrectiveDetails::METHOD_DIFFERENCES + ])); + } + // Añadimos los productos a incluir en la factura // En este caso, probaremos con tres lámpara por // precio unitario de 20,14€, IVA al 21% YA INCLUÍDO From cc6dfa69544caf47a5f11b7dc6139d9f890c1c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 21 Jan 2023 11:53:50 +0100 Subject: [PATCH 102/146] v1.7.5 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index f92e9ac..52bc37d 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.4"; + const VERSION = "1.7.5"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From c7183ea21c68ec66394287d6f47845634004e51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 26 Jan 2023 19:03:30 +0100 Subject: [PATCH 103/146] Actualizado ejemplo sin Composer - Modificado sin-composer.md > Closes #115 --- doc/ejemplos/sin-composer.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/ejemplos/sin-composer.md b/doc/ejemplos/sin-composer.md index 26fb0b3..15f7680 100644 --- a/doc/ejemplos/sin-composer.md +++ b/doc/ejemplos/sin-composer.md @@ -9,7 +9,8 @@ permalink: /ejemplos/sin-composer.html Este ejemplo muestra cómo usar `Facturae-PHP` sin tener configurado un entorno de Composer, solo descargando el código fuente de la librería. ```php -require_once 'ruta/hacia/Facturae-PHP/src/Common/KeyPairReader.php'; +require_once 'ruta/hacia/Facturae-PHP/src/Common/FacturaeSigner.php'; +require_once 'ruta/hacia/Facturae-PHP/src/Common/KeyPairReaderTrait.php'; require_once 'ruta/hacia/Facturae-PHP/src/Common/XmlTools.php'; require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/PropertiesTrait.php'; require_once 'ruta/hacia/Facturae-PHP/src/FacturaeTraits/UtilsTrait.php'; From 3b17eab67f0015065b077c50169edae1ff6a8f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 19 Feb 2023 13:16:14 +0100 Subject: [PATCH 104/146] =?UTF-8?q?A=C3=B1adido=20soporte=20para=20tercero?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeParty - Actualizados PropertiesTrait y ExportableTrait - Actualizados tests unitarios > Closes #118 --- doc/entidades/terceros.md | 24 ++++++++++++++ src/FacturaeParty.php | 8 ++--- src/FacturaeTraits/ExportableTrait.php | 45 ++++++++++++++------------ src/FacturaeTraits/PropertiesTrait.php | 21 ++++++++++++ tests/InvoiceTest.php | 12 ++++++- 5 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 doc/entidades/terceros.md diff --git a/doc/entidades/terceros.md b/doc/entidades/terceros.md new file mode 100644 index 0000000..3dd2285 --- /dev/null +++ b/doc/entidades/terceros.md @@ -0,0 +1,24 @@ +--- +title: Terceros +parent: Entidades +nav_order: 4 +permalink: /entidades/terceros.html +--- + +# Terceros +Un tercero o *Third-Party* es la entidad que genera y firma una factura cuando esta no coincide con el emisor. +Por ejemplo, en el caso de una gestoría que trabaja con varios clientes y emite las facturas en su nombre. + +En el caso de Facturae-PHP, pueden especificarse los datos de un tercero de la siguiente forma: +```php +$fac->setThirdParty(new FacturaeParty([ + "taxNumber" => "B99999999", + "name" => "Gestoría de Ejemplo, S.L.", + "address" => "C/ de la Gestoría, 24", + "postCode" => "23456", + "town" => "Madrid", + "province" => "Madrid", + "phone" => "915555555", + "email" => "noexiste@gestoria.com" +])); +``` diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index f318e6d..d0eff4e 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -61,10 +61,10 @@ public function __construct($properties=array()) { /** * Get XML * - * @param string $schema Facturae schema version - * @return string Entity as Facturae XML + * @param boolean $includeAdministrativeCentres Whether to include administrative centers or not + * @return string Entity as Facturae XML */ - public function getXML($schema) { + public function getXML($includeAdministrativeCentres) { // Add tax identification $xml = '' . '' . ($this->isLegalEntity ? 'J' : 'F') . '' . @@ -73,7 +73,7 @@ public function getXML($schema) { ''; // Add administrative centres - if (count($this->centres) > 0) { + if ($includeAdministrativeCentres && count($this->centres) > 0) { $xml .= ''; foreach ($this->centres as $centre) { $xml .= ''; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index e0d9dc2..11cc0d3 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -49,29 +49,32 @@ public function export($filePath=null) { // Add header $batchIdentifier = $this->parties['seller']->taxNumber . $this->header['number'] . $this->header['serie']; - $xml .= '' . - '' . $this->version .'' . - 'I' . - 'EM' . - '' . - '' . $batchIdentifier . '' . - '1' . - '' . - '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . - '' . - '' . - '' . $this->pad($totals['totalOutstandingAmount'], 'InvoiceTotal') . '' . - '' . - '' . - '' . $this->pad($totals['totalExecutableAmount'], 'InvoiceTotal') . '' . - '' . - '' . $this->currency . '' . - ''; + $xml .= ''; + $xml .= '' . $this->version .''; + $xml .= 'I'; + $xml .= 'EM'; + if (!is_null($this->parties['thirdParty'])) { + $xml .= '' . $this->parties['thirdParty']->getXML(false) . ''; + } + $xml .= '' . + '' . $batchIdentifier . '' . + '1' . + '' . + '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' . + '' . + '' . + '' . $this->pad($totals['totalOutstandingAmount'], 'InvoiceTotal') . '' . + '' . + '' . + '' . $this->pad($totals['totalExecutableAmount'], 'InvoiceTotal') . '' . + '' . + '' . $this->currency . '' . + ''; // Add factoring assignment data if (!is_null($this->parties['assignee'])) { $xml .= ''; - $xml .= '' . $this->parties['assignee']->getXML($this->version) . ''; + $xml .= '' . $this->parties['assignee']->getXML(false) . ''; $xml .= $paymentDetailsXML; if (!is_null($this->header['assignmentClauses'])) { $xml .= '' . @@ -86,8 +89,8 @@ public function export($filePath=null) { // Add parties $xml .= '' . - '' . $this->parties['seller']->getXML($this->version) . '' . - '' . $this->parties['buyer']->getXML($this->version) . '' . + '' . $this->parties['seller']->getXML(true) . '' . + '' . $this->parties['buyer']->getXML(true) . '' . ''; // Add invoice data diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 6906a99..8714267 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -33,6 +33,7 @@ trait PropertiesTrait { "additionalInformation" => null ); protected $parties = array( + "thirdParty" => null, "assignee" => null, "seller" => null, "buyer" => null @@ -99,6 +100,26 @@ public function setPrecision($precision) { } + /** + * Set third party + * @param FacturaeParty $assignee Third party information + * @return Facturae Invoice instance + */ + public function setThirdParty($thirdParty) { + $this->parties['thirdParty'] = $thirdParty; + return $this; + } + + + /** + * Get third party + * @return FacturaeParty|null Third party information + */ + public function getThirdParty() { + return $this->parties['thirdParty']; + } + + /** * Set assignee * @param FacturaeParty $assignee Assignee information diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 2af3fa5..0c9b2e7 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -208,8 +208,18 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "amount" => 99.9991172 ])); - // Establecemos un un cesionario (solo en algunos casos) + // Establecemos un tercero y un cesionario (solo en algunos casos) if ($isPfx) { + $fac->setThirdParty(new FacturaeParty([ + "taxNumber" => "B99999999", + "name" => "Gestoría de Ejemplo, S.L.", + "address" => "C/ de la Gestoría, 24", + "postCode" => "23456", + "town" => "Madrid", + "province" => "Madrid", + "phone" => "915555555", + "email" => "noexiste@gestoria.com" + ])); $fac->setAssignee(new FacturaeParty([ "taxNumber" => "B00000000", "name" => "Cesionario S.L.", From 420a61b16fd69c7ff31c740add7094807926e4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 19 Feb 2023 13:20:39 +0100 Subject: [PATCH 105/146] =?UTF-8?q?A=C3=B1adidos=20tipos=20de=20emisor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidas constantes de tipos de emisor - Actualizados PropertiesTrait y ExportableTrait - Actualizados tests unitarios - Actualizada documentación > Related to #118 --- doc/anexos/constantes.md | 10 ++++++++++ doc/entidades/terceros.md | 2 ++ src/Facturae.php | 4 ++++ src/FacturaeTraits/ExportableTrait.php | 2 +- src/FacturaeTraits/PropertiesTrait.php | 22 ++++++++++++++++++++++ tests/InvoiceTest.php | 1 + 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 2288f34..840ff36 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -26,6 +26,16 @@ permalink: /anexos/constantes.html --- +## Tipos de emisor + +|Constante|Descripción| +|--------:|:----------| +|`Facturae::ISSUER_SELLER`|Proveedor (emisor)| +|`Facturae::ISSUER_BUYER`|Destinatario (receptor)| +|`Facturae::ISSUER_THIRD_PARTY`|Tercero| + +--- + ## Modos de precisión |Constante|Descripción| diff --git a/doc/entidades/terceros.md b/doc/entidades/terceros.md index 3dd2285..7f94209 100644 --- a/doc/entidades/terceros.md +++ b/doc/entidades/terceros.md @@ -22,3 +22,5 @@ $fac->setThirdParty(new FacturaeParty([ "email" => "noexiste@gestoria.com" ])); ``` + +El tipo de emisor de una factura cambiará automáticamente a `Facturae::ISSUER_THIRD_PARTY` al establecer los datos de un tercero. diff --git a/src/Facturae.php b/src/Facturae.php index 52bc37d..a9c533b 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -26,6 +26,10 @@ class Facturae { const INVOICE_FULL = "FC"; const INVOICE_SIMPLIFIED = "FA"; + const ISSUER_SELLER = "EM"; + const ISSUER_BUYER = "RE"; + const ISSUER_THIRD_PARTY = "TE"; + const PRECISION_LINE = 1; const PRECISION_INVOICE = 2; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 11cc0d3..eef159b 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -52,7 +52,7 @@ public function export($filePath=null) { $xml .= ''; $xml .= '' . $this->version .''; $xml .= 'I'; - $xml .= 'EM'; + $xml .= '' . $this->header['issuerType'] . ''; if (!is_null($this->parties['thirdParty'])) { $xml .= '' . $this->parties['thirdParty']->getXML(false) . ''; } diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 8714267..d1f566b 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -19,6 +19,7 @@ trait PropertiesTrait { protected $precision = self::PRECISION_LINE; protected $header = array( "type" => self::INVOICE_FULL, + "issuerType" => self::ISSUER_SELLER, "serie" => null, "number" => null, "issueDate" => null, @@ -107,6 +108,7 @@ public function setPrecision($precision) { */ public function setThirdParty($thirdParty) { $this->parties['thirdParty'] = $thirdParty; + $this->setIssuerType(self::ISSUER_THIRD_PARTY); return $this; } @@ -240,6 +242,26 @@ public function getType() { } + /** + * Set issuer type + * @param string $issuerType Issuer type + * @return Facturae Invoice instance + */ + public function setIssuerType($issuerType) { + $this->header['issuerType'] = $issuerType; + return $this; + } + + + /** + * Get issuer type + * @return string Issuer type + */ + public function getIssuerType() { + return $this->header['issuerType']; + } + + /** * Set invoice number * @param string $serie Serie code of the invoice diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 0c9b2e7..530f613 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -220,6 +220,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "phone" => "915555555", "email" => "noexiste@gestoria.com" ])); + $this->assertEquals(Facturae::ISSUER_THIRD_PARTY, $fac->getIssuerType()); $fac->setAssignee(new FacturaeParty([ "taxNumber" => "B00000000", "name" => "Cesionario S.L.", From 28fa42dbacf8bcac434a6d873e1477198dbdf57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 17 Mar 2023 19:15:00 +0100 Subject: [PATCH 106/146] Arreglado test de extensiones - Actualizado ExtensionsTest para usar cookies (necesario para descargar XSD) --- tests/ExtensionsTest.php | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/ExtensionsTest.php b/tests/ExtensionsTest.php index 009942c..ed228e5 100644 --- a/tests/ExtensionsTest.php +++ b/tests/ExtensionsTest.php @@ -69,14 +69,37 @@ public function testExtensions() { $extXml = explode('', $extXml[1])[0]; // Validamos la parte de FACeB2B + $schemaPath = $this->getSchema(); $faceXml = new \DOMDocument(); $faceXml->loadXML($extXml); - $isValidXml = $faceXml->schemaValidate(self::FB2B_XSD_PATH); + $isValidXml = $faceXml->schemaValidate($schemaPath); $this->assertTrue($isValidXml); + unlink($schemaPath); // Validamos la ejecución de DisclaimerExtension $disclaimerPos = strpos($rawXml, '' . $disclaimer->getDisclaimer() . ''); $this->assertTrue($disclaimerPos !== false); } + /** + * Get path to FaceB2B schema file + * @return string Path to schema file + */ + private function getSchema() { + // Get XSD contents + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, self::FB2B_XSD_PATH); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_COOKIEFILE, ''); + $res = curl_exec($ch); + curl_close($ch); + unset($ch); + + // Save to disk + $path = self::OUTPUT_DIR . "/faceb2b.xsd"; + file_put_contents($path, $res); + + return $path; + } } From 94affacbd7f77c4860b44b48f234b631b97fe3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 18 Mar 2023 19:43:00 +0100 Subject: [PATCH 107/146] Implementada fiscalidad especial de impuestos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeItem - Actualizado ExportableTrait - Actualizados tests unitarios - Actualizada documentación > Closes #124 --- doc/anexos/constantes.md | 9 +++++++++ doc/productos/impuestos.md | 19 +++++++++++++++++++ src/FacturaeItem.php | 6 ++++++ src/FacturaeTraits/ExportableTrait.php | 12 ++++++++---- tests/InvoiceTest.php | 9 +++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md index 840ff36..7a26bac 100644 --- a/doc/anexos/constantes.md +++ b/doc/anexos/constantes.md @@ -118,6 +118,15 @@ permalink: /anexos/constantes.html --- +## Códigos de fiscalidad especial + +|Constante|Descripción| +|--------:|:----------| +|`FacturaeItem::SPECIAL_TAXABLE_EVENT_EXEMPT`|Operación sujeta y exenta| +|`FacturaeItem::SPECIAL_TAXABLE_EVENT_NON_SUBJECT`|Operación no sujeta| + +--- + ## Unidades de medida |Constante|Descripción| diff --git a/doc/productos/impuestos.md b/doc/productos/impuestos.md index 43773b0..66c890a 100644 --- a/doc/productos/impuestos.md +++ b/doc/productos/impuestos.md @@ -66,3 +66,22 @@ $fac->addItem(new FacturaeItem([ ] ])); ``` + +## Fiscalidad especial +Algunas operaciones son subjetivas de una fiscalidad especial (*special taxable event* en inglés). Por ejemplo, determinados productos se ven exentos de impuestos. +Habitualmente, la forma en la que se declaran estos casos es marcando la línea de producto con IVA al 0% y especificando la justificación de la fiscalidad especial: +```php +$fac->addItem(new FacturaeItem([ + "name" => "Un producto exento de IVA", + "unitPrice" => 100, + + // Se marca la línea con IVA 0% + "taxes" => [Facturae::TAX_IVA => 0], + + // Se declara el producto como exento de IVA + "specialTaxableEventCode" => FacturaeItem::SPECIAL_TAXABLE_EVENT_EXEMPT, + + // Se detalla el motivo + "specialTaxableEventReason" => "El motivo detallado de la exención de impuestos" +])); +``` diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index e64a222..4739d75 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -7,6 +7,10 @@ * Represents an invoice item */ class FacturaeItem { + /** Subject and exempt operation */ + const SPECIAL_TAXABLE_EVENT_EXEMPT = "01"; + /** Non-subject operation */ + const SPECIAL_TAXABLE_EVENT_NON_SUBJECT = "02"; private $articleCode = null; private $name = null; @@ -19,6 +23,8 @@ class FacturaeItem { private $charges = array(); private $taxesOutputs = array(); private $taxesWithheld = array(); + private $specialTaxableEventCode = null; + private $specialTaxableEventReason = null; private $issuerContractReference = null; private $issuerContractDate = null; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index eef159b..6b7c4d3 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -330,10 +330,14 @@ public function export($filePath=null) { } // Add more optional fields - $xml .= $this->addOptionalFields($item, [ - "description" => "AdditionalLineItemInformation", - "articleCode" - ]); + $xml .= $this->addOptionalFields($item, ["description" => "AdditionalLineItemInformation"]); + if (!is_null($item['specialTaxableEventCode']) && !is_null($item['specialTaxableEventReason'])) { + $xml .= ''; + $xml .= '' . XmlTools::escape($item['specialTaxableEventCode']) . ''; + $xml .= '' . XmlTools::escape($item['specialTaxableEventReason']) . ''; + $xml .= ''; + } + $xml .= $this->addOptionalFields($item, ["articleCode"]); // Close invoice line $xml .= ''; diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 530f613..2c31c5d 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -116,6 +116,15 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // Y ahora, una línea con IVA al 0% $fac->addItem("Algo exento de IVA", 100, 1, Facturae::TAX_IVA, 0); + // Otra línea con IVA 0% y código de fiscalidad especial + $fac->addItem(new FacturaeItem([ + "name" => "Otro algo exento de IVA", + "unitPrice" => 50, + "taxes" => [Facturae::TAX_IVA => 0], + "specialTaxableEventCode" => FacturaeItem::SPECIAL_TAXABLE_EVENT_EXEMPT, + "specialTaxableEventReason" => "El motivo detallado de la exención de impuestos" + ])); + // Vamos a añadir un producto utilizando la API avanzada // que tenga IVA al 10%, IRPF al 15%, descuento del 10% y recargo del 5% $fac->addItem(new FacturaeItem([ From 64ae4b7b876199abb3240ad899ae8d3465ed0e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 25 Mar 2023 11:05:44 +0100 Subject: [PATCH 108/146] v1.7.6 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index a9c533b..e3e5cef 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.5"; + const VERSION = "1.7.6"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 45839c13e04d1e850f8340ce827554487d8a87dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 1 Jul 2023 18:49:56 +0200 Subject: [PATCH 109/146] =?UTF-8?q?Mejorada=20documentaci=C3=B3n=20de=20fi?= =?UTF-8?q?rma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado firma-electronica.md - Actualizados ejemplos --- doc/ejemplos/factura-simple.md | 4 +- doc/ejemplos/sin-composer.md | 4 +- doc/firma-electronica/firma-electronica.md | 52 +++++++++++----------- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/doc/ejemplos/factura-simple.md b/doc/ejemplos/factura-simple.md index beef346..53f671a 100644 --- a/doc/ejemplos/factura-simple.md +++ b/doc/ejemplos/factura-simple.md @@ -55,8 +55,8 @@ $fac->addItem("Lámpara de pie", 20.14, 3, Facturae::TAX_IVA, 21); // Ya solo queda firmar la factura ... $fac->sign( - "ruta/hacia/clave_publica.pem", - "ruta/hacia/clave_privada.pem", + "ruta/hacia/banco-de-certificados.p12", + null, "passphrase" ); diff --git a/doc/ejemplos/sin-composer.md b/doc/ejemplos/sin-composer.md index 15f7680..4bd70c3 100644 --- a/doc/ejemplos/sin-composer.md +++ b/doc/ejemplos/sin-composer.md @@ -44,8 +44,8 @@ $fac->addItem("Lámpara de pie", 20.14, 3, Facturae::TAX_IVA, 21); // Ya solo queda firmar la factura ... $fac->sign( - "ruta/hacia/clave_publica.pem", - "ruta/hacia/clave_privada.pem", + "ruta/hacia/banco-de-certificados.p12", + null, "passphrase" ); diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md index 6cf303a..a545cbc 100644 --- a/doc/firma-electronica/firma-electronica.md +++ b/doc/firma-electronica/firma-electronica.md @@ -9,54 +9,56 @@ permalink: /firma-electronica/ Aunque es posible exportar las facturas sin firmarlas, es un paso obligatorio para prácticamente cualquier trámite relacionado con la Administración Pública. Para firmar facturas se necesita un certificado electrónico (generalmente expedido por la FNMT) del que extraer su clave pública y su clave privada. -## Firmado con clave pública y privada X.509 -Si se tiene la clave pública (un certificado) y la clave privada en archivos independientes, se debe utilizar este método con los siguientes argumentos: +## Firmado con PKCS#12 (recomendado) +Desde la versión 1.0.5 de Facturae-PHP ya es posible cargar un banco de certificados desde un archivo `.pfx` o `.p12`: ```php -$fac->sign("clave_publica.pem", "clave_privada.pem", "passphrase"); +$fac->sign("certificado.pfx", null, "passphrase"); ``` -También se pueden pasar como parámetros los bytes de ambos ficheros en vez de sus rutas, o instancias de `OpenSSLCertificate` y `OpenSSLAsymmetricKey`, respectivamente: +También se pueden pasar como parámetro los bytes del banco PKCS#12: ```php -$publicKey = openssl_x509_read("clave_publica.pem"); -$encryptedPrivateKey = file_get_contents("clave_privada.pem"); -$fac->sign($publicKey, $encryptedPrivateKey, "passphrase"); +$encryptedStore = file_get_contents("certificado.pfx"); +$fac->sign($encryptedStore, null, "passphrase"); ``` > #### NOTA -> Los siguientes comandos permiten extraer el certificado (clave pública) y la clave privada de un archivo PFX: +> Al utilizar un banco PKCS#12, Facturae-PHP incluirá la cadena completa de certificados en la factura al firmarla. +> +> Aunque en la mayoría de los casos esto no supone ninguna diferencia con respecto a firmar desde ficheros PEM, los validadores presentan problemas **con algunos certificados expedidos recientemente por la FNMT**. +> Dicho problema se soluciona cuando se incluyen los certificados raíz e intermedios de la Entidad de Certificación, por lo que es recomendable usar este método de firma con Facturae-PHP. + +> #### NOTA +> A partir de OpenSSL v3.0.0, algunos algoritmos de digest como RC4 fueron [marcados como obsoletos](https://www.openssl.org/docs/man3.0/man7/migration_guide.html#Deprecated-low-level-encryption-functions). +> Esto puede suponer un problema para bancos de certificados exportados desde el Gestor de Certificados de Windows. +> Se recomienda validar estos ficheros antes de usarlos en la librería: > > ``` -> openssl pkcs12 -in certificado_de_entrada.pfx -clcerts -nokeys -out clave_publica.pem -> openssl pkcs12 -in certificado_de_entrada.pfx -nocerts -out clave_privada.pem +> openssl pkcs12 -in certificado.pfx -info -nokeys -nocerts > ``` --- -## Firmado con PKCS#12 -Desde la versión 1.0.5 de Facturae-PHP ya es posible cargar un banco de certificados desde un archivo `.pfx` o `.p12` sin necesidad de convertirlo previamente a X.509: +## Firmado con clave pública y privada X.509 +Si se tiene la clave pública (un certificado) y la clave privada en archivos independientes, se debe utilizar este método con los siguientes argumentos: ```php -$fac->sign("certificado.pfx", null, "passphrase"); +$fac->sign("clave_publica.pem", "clave_privada.pem", "passphrase"); ``` -También se pueden pasar como parámetro los bytes del banco PKCS#12: +También se pueden pasar como parámetros los bytes de ambos ficheros en vez de sus rutas, o instancias de `OpenSSLCertificate` y `OpenSSLAsymmetricKey`, respectivamente: ```php -$encryptedStore = file_get_contents("certificado.pfx"); -$fac->sign($encryptedStore, null, "passphrase"); +$publicKey = openssl_x509_read("clave_publica.pem"); +$encryptedPrivateKey = file_get_contents("clave_privada.pem"); +$fac->sign($publicKey, $encryptedPrivateKey, "passphrase"); ``` -> #### NOTA -> Al utilizar un banco PKCS#12, Facturae-PHP incluirá la cadena completa de certificados en la factura al firmarla. -> -> Aunque en la mayoría de los casos esto no supone ninguna diferencia con respecto a firmar desde ficheros PEM, el validador del Gobierno de España **presenta problemas para verificar firmas de certificados expedidos recientemente por la FNMT**. -> Dicho problema se soluciona cuando se incluyen los certificados raíz e intermedios de la Entidad de Certificación, por lo que es recomendable usar este método de firma con Facturae-PHP. +Este método de firma no añade la cadena completa de certificados a la factura y, por tanto, no se recomienda. > #### NOTA -> A partir de OpenSSL v3.0.0, algunos algoritmos de digest como RC4 fueron [marcados como obsoletos](https://www.openssl.org/docs/man3.0/man7/migration_guide.html#Deprecated-low-level-encryption-functions). -> Esto puede suponer un problema para bancos de certificados exportados desde el Gestor de Certificados de Windows. -> Se recomienda validar estos ficheros antes de usarlos en la librería: +> Los siguientes comandos permiten extraer el certificado (clave pública) y la clave privada de un archivo PFX: > > ``` -> openssl pkcs12 -in certificado.pfx -info -nokeys -nocerts +> openssl pkcs12 -in certificado_de_entrada.pfx -clcerts -nokeys -out clave_publica.pem +> openssl pkcs12 -in certificado_de_entrada.pfx -nocerts -out clave_privada.pem > ``` --- From 2536ff85dd631d4fcafb37f6afd07725a4b5a85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 1 Jul 2023 18:56:51 +0200 Subject: [PATCH 110/146] Mejoras menores en firma - Actualizada clase XmlTools - Actualizada clase FacturaeSigner --- src/Common/FacturaeSigner.php | 5 ++--- src/Common/XmlTools.php | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 6cd8d66..4f1a18b 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -204,9 +204,8 @@ public function sign($xml) { // Build element $privateData = openssl_pkey_get_details($this->privateKey); - $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76); - $modulus = str_replace("\r", '', $modulus); - $exponent = base64_encode($privateData['rsa']['e']); + $modulus = XmlTools::toBase64($privateData['rsa']['n'], true); + $exponent = XmlTools::toBase64($privateData['rsa']['e']); $dsKeyInfo = '' . "\n" . '' . "\n"; foreach ($this->publicChain as $pemCertificate) { $dsKeyInfo .= '' . "\n" . XmlTools::getCert($pemCertificate) . '' . "\n"; diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 9e70cfd..1782f20 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -146,7 +146,7 @@ public static function getDigest($input, $pretty=false) { public static function getCert($pem, $pretty=true) { $pem = str_replace("-----BEGIN CERTIFICATE-----", "", $pem); $pem = str_replace("-----END CERTIFICATE-----", "", $pem); - $pem = str_replace("\n", "", str_replace("\r", "", $pem)); + $pem = str_replace(["\r", "\n"], ['', ''], $pem); if ($pretty) $pem = self::prettify($pem); return $pem; } From cc5ef039c53511fefed018dd384a5ffa5d52910c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 2 Jul 2023 09:59:26 +0200 Subject: [PATCH 111/146] =?UTF-8?q?A=C3=B1adido=20.gitattributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creado .gitattributes - Actualizado .editorconfig --- .editorconfig | 2 +- .gitattributes | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig index 7d5d961..c19ed1d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,5 @@ indent_style = space indent_size = 2 trim_trailing_whitespace = true -[*.php] +[*.{md,php}] insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file From de667c659a528fd1a7574889556a35bc52ae74ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 2 Jul 2023 10:02:25 +0200 Subject: [PATCH 112/146] Implementado residence type code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeParty - Actualizados tests unitarios - Actualizada documentación > Closes #130 --- doc/entidades/otros-paises.md | 33 +++++++++++++++++++++++++++++++++ doc/propiedades/suplidos.md | 2 +- src/FacturaeParty.php | 35 ++++++++++++++++++++++++++++++++--- tests/InvoiceTest.php | 2 +- tests/OverseasTest.php | 16 ++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 doc/entidades/otros-paises.md diff --git a/doc/entidades/otros-paises.md b/doc/entidades/otros-paises.md new file mode 100644 index 0000000..d17b1ae --- /dev/null +++ b/doc/entidades/otros-paises.md @@ -0,0 +1,33 @@ +--- +title: Otros países +parent: Entidades +nav_order: 5 +permalink: /entidades/otros-paises.html +--- + +# Otros países +Por defecto, Facturae-PHP asume que las entidades residen en España. +Para establecer el código de país de una entidad, se usa la propiedad "countryCode": +```php +$entity = new FacturaeParty([ + "countryCode" => "FRA", + "taxNumber" => "12345678901", + "name" => "Una empresa de Francia", + // [...] +]); +``` + +El valor del campo XML `` se calcula automáticamente en función del país de acuerdo a la especificación. +Es decir, toma los siguientes valores dependiendo del país: + +- `R`: Para España +- `U`: Para países de la Unión Europea +- `E`: Resto de países + +Se puede forzar que una entidad se considere (o no) de la Unión Europea usando la propiedad "isEuropeanUnionResident": +```php +$entity = new FacturaeParty([ + "isEuropeanUnionResident" => true, + // [...] +]); +``` diff --git a/doc/propiedades/suplidos.md b/doc/propiedades/suplidos.md index f4eb973..4b8b045 100644 --- a/doc/propiedades/suplidos.md +++ b/doc/propiedades/suplidos.md @@ -11,7 +11,7 @@ Para ello, se debe hacer uso de la clase `ReimbursableExpense`: ```php $fac->addReimbursableExpense(new ReimbursableExpense([ "seller" => new FacturaeParty(["taxNumber" => "00000000A"]), - "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]), + "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "countryCode" => "PRT"]), "issueDate" => "2017-11-27", "invoiceNumber" => "EX-19912", "invoiceSeriesCode" => "156A", diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index d0eff4e..863f12d 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -11,8 +11,12 @@ */ class FacturaeParty { + const EU_COUNTRY_CODES = [ + 'AUT', 'BEL', 'BGR', 'CYP', 'CZE', 'DEU', 'DNK', 'ESP', 'EST', 'FIN', 'FRA', 'GRC', 'HRV', 'HUN', + 'IRL', 'ITA', 'LTU', 'LUX', 'LVA', 'MLT', 'NLD', 'POL', 'PRT', 'ROU', 'SVK', 'SVN', 'SWE' + ]; + public $isLegalEntity = true; // By default is a company and not a person - public $isEuropeanUnionResident = true; // By default resides in the EU public $taxNumber = null; public $name = null; @@ -33,6 +37,8 @@ class FacturaeParty { public $town = null; public $province = null; public $countryCode = "ESP"; + /** @var boolean|null */ + public $isEuropeanUnionResident = null; // By default is calculated based on the country code public $email = null; public $phone = null; @@ -68,7 +74,7 @@ public function getXML($includeAdministrativeCentres) { // Add tax identification $xml = '' . '' . ($this->isLegalEntity ? 'J' : 'F') . '' . - 'R' . + '' . $this->getResidenceTypeCode() . '' . '' . XmlTools::escape($this->taxNumber) . '' . ''; @@ -180,6 +186,29 @@ public function getXML($includeAdministrativeCentres) { } + /** + * Get residence type code + * + * @return string Residence type code + */ + public function getResidenceTypeCode() { + if ($this->countryCode === "ESP") { + return "R"; + } + + // Handle overrides + if ($this->isEuropeanUnionResident === true) { + return "U"; + } + if ($this->isEuropeanUnionResident === false) { + return "E"; + } + + // Handle European countries + return in_array($this->countryCode, self::EU_COUNTRY_CODES, true) ? "U" : "E"; + } + + /** * Get contact details XML * @@ -227,7 +256,7 @@ private function getContactDetailsXML() { */ public function getReimbursableExpenseXML() { $xml = '' . ($this->isLegalEntity ? 'J' : 'F') . ''; - $xml .= '' . ($this->isEuropeanUnionResident ? 'R' : 'E') . ''; + $xml .= '' . $this->getResidenceTypeCode() . ''; $xml .= '' . XmlTools::escape($this->taxNumber) . ''; return $xml; } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 2c31c5d..5b6cfb8 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -210,7 +210,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // Añadimos un suplido $fac->addReimbursableExpense(new ReimbursableExpense([ "seller" => new FacturaeParty(["taxNumber" => "00000000A"]), - "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]), + "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "countryCode" => "PRT"]), "issueDate" => "2017-11-27", "invoiceNumber" => "EX-19912", "invoiceSeriesCode" => "156A", diff --git a/tests/OverseasTest.php b/tests/OverseasTest.php index 90a808d..06c3df1 100644 --- a/tests/OverseasTest.php +++ b/tests/OverseasTest.php @@ -19,8 +19,24 @@ public function testOverseasAddress() { $fac->getBuyer()->countryCode = "PRT"; $fac->addItem("Línea de producto", 100, 1, Facturae::TAX_IVA, 21); + // Validate invoice as-is $fac->export(self::FILE_PATH); $this->validateInvoiceXML(self::FILE_PATH); + $this->assertEquals("R", $fac->getSeller()->getResidenceTypeCode()); + $this->assertEquals("U", $fac->getBuyer()->getResidenceTypeCode()); + + // Switch buyer to United States + $fac->getBuyer()->countryCode = "USA"; + $this->assertEquals("E", $fac->getBuyer()->getResidenceTypeCode()); + + // Force European-resident type code + $fac->getBuyer()->isEuropeanUnionResident = true; + $this->assertEquals("U", $fac->getBuyer()->getResidenceTypeCode()); + + // Force non-European-resident type code + $fac->getBuyer()->countryCode = "PRT"; + $fac->getBuyer()->isEuropeanUnionResident = false; + $this->assertEquals("E", $fac->getBuyer()->getResidenceTypeCode()); } } From 0e42dcc85cc62898f4f8c1e590b1b3c6f4814aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 2 Jul 2023 10:03:46 +0200 Subject: [PATCH 113/146] v1.7.7 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index e3e5cef..2ef97e4 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.6"; + const VERSION = "1.7.7"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From d0f871eb4622673758bc6b3b073d73bf15565385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 22 Jul 2023 11:44:56 +0200 Subject: [PATCH 114/146] =?UTF-8?q?Arregladas=20l=C3=ADneas=20de=20product?= =?UTF-8?q?o=20sin=20impuestos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeItem --- src/FacturaeItem.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 4739d75..f5e6e15 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -55,6 +55,7 @@ public function __construct($properties=array()) { // Catalog taxes property (backward compatibility) if (isset($properties['taxes'])) { foreach ($properties['taxes'] as $r=>$tax) { + if (empty($r)) continue; if (!is_array($tax)) $tax = array("rate"=>$tax, "amount"=>0); if (!isset($tax['isWithheld'])) { // Get value by default $tax['isWithheld'] = Facturae::isWithheldTax($r); From 926147364e4ff3469e49291e753e89aa974b0155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 27 Jul 2023 17:42:44 +0200 Subject: [PATCH 115/146] Normalizado indexado de impuestos en totales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado trait PropertiesTrait > Dependiendo del modo de precisión, hasta ahora era posible crear impuestos duplicados por falta de normalización en el indexado (ej. "10.0:0.0" y "10:0" aparecían como grupos distintos). --- src/FacturaeTraits/PropertiesTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index d1f566b..f62a1f2 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -832,7 +832,7 @@ public function getTotals() { if (!isset($totals[$taxGroup][$type])) { $totals[$taxGroup][$type] = array(); } - $taxKey = $tax['rate'] . ":" . $tax['surcharge']; + $taxKey = floatval($tax['rate']) . ":" . floatval($tax['surcharge']); if (!isset($totals[$taxGroup][$type][$taxKey])) { $totals[$taxGroup][$type][$taxKey] = array( "base" => 0, From 6a780161b3b6271b214be088bc229f60e30f7a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 27 Jul 2023 17:46:51 +0200 Subject: [PATCH 116/146] =?UTF-8?q?Arreglado=20c=C3=A1lculo=20de=20impuest?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeItem - Actualizados tests unitarios > Closes #133 --- src/FacturaeItem.php | 21 ++++++++--- tests/PrecisionTest.php | 83 ++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index f5e6e15..18077c1 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -116,18 +116,26 @@ public function getData($fac) { $unitPriceWithoutTax = $this->unitPriceWithoutTax; $totalAmountWithoutTax = $quantity * $unitPriceWithoutTax; + // NOTE: Special case for Schema v3.2 + // In this schema, an item's total cost () has 6 decimals but taxable bases only have 2. + // We round the first property when using line precision mode to prevent the pair from having different values. + if ($fac->getSchemaVersion() === Facturae::SCHEMA_3_2) { + $totalAmountWithoutTax = $fac->pad($totalAmountWithoutTax, 'Tax/TaxableBase', Facturae::PRECISION_LINE); + } + // Process charges and discounts $grossAmount = $totalAmountWithoutTax; foreach (['discounts', 'charges'] as $i=>$groupTag) { $factor = ($i == 0) ? -1 : 1; foreach ($this->{$groupTag} as $group) { if (isset($group['rate'])) { - $rate = $group['rate']; + $rate = $fac->pad($group['rate'], 'DiscountCharge/Rate', Facturae::PRECISION_LINE); $amount = $totalAmountWithoutTax * ($rate / 100); } else { $rate = null; $amount = $group['amount']; } + $amount = $fac->pad($amount, 'DiscountCharge/Amount', Facturae::PRECISION_LINE); $addProps[$groupTag][] = array( "reason" => $group['reason'], "rate" => $rate, @@ -142,12 +150,13 @@ public function getData($fac) { $totalTaxesWithheld = 0; foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { foreach ($this->{$taxesGroup} as $type=>$tax) { - $taxRate = $tax['rate']; - $surcharge = $tax['surcharge']; - $taxAmount = $grossAmount * ($taxRate / 100); - $surchargeAmount = $grossAmount * ($surcharge / 100); + $taxRate = $fac->pad($tax['rate'], 'Tax/TaxRate', Facturae::PRECISION_LINE); + $surcharge = $fac->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge', Facturae::PRECISION_LINE); + $taxableBase = $fac->pad($grossAmount, 'Tax/TaxableBase', Facturae::PRECISION_LINE); + $taxAmount = $fac->pad($taxableBase*($taxRate/100), 'Tax/TaxAmount', Facturae::PRECISION_LINE); + $surchargeAmount = $fac->pad($taxableBase*($surcharge/100), 'Tax/EquivalenceSurchargeAmount', Facturae::PRECISION_LINE); $addProps[$taxesGroup][$type] = array( - "base" => $grossAmount, + "base" => $taxableBase, "rate" => $taxRate, "surcharge" => $surcharge, "amount" => $taxAmount, diff --git a/tests/PrecisionTest.php b/tests/PrecisionTest.php index 344c480..19e5014 100644 --- a/tests/PrecisionTest.php +++ b/tests/PrecisionTest.php @@ -5,18 +5,37 @@ use josemmo\Facturae\FacturaeItem; final class PrecisionTest extends AbstractTest { - private function _runTest($schema, $precision) { + /** + * @param string $schema Invoice schema + * @param string $precision Rounding precision mode + */ + private function runTestWithParams($schema, $precision) { $fac = $this->getBaseInvoice($schema); $fac->setPrecision($precision); // Add items - $amounts = [37.76, 26.8, 5.5]; - foreach ($amounts as $i=>$amount) { + $items = [ + ['unitPriceWithoutTax'=>16.90, 'quantity'=>3.40, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>1.20, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.00, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.75, 'tax'=>10], + ['unitPriceWithoutTax'=>6.90, 'quantity'=>2.65, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>1.80, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.95, 'tax'=>10], + ['unitPriceWithoutTax'=>3.00, 'quantity'=>11.30, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>46.13, 'tax'=>10], + ['unitPriceWithoutTax'=>37.76, 'quantity'=>1, 'tax'=>21], + ['unitPriceWithoutTax'=>13.40, 'quantity'=>2, 'tax'=>21], + ['unitPriceWithoutTax'=>5.50, 'quantity'=>1, 'tax'=>21] + ]; + foreach ($items as $i=>$item) { $fac->addItem(new FacturaeItem([ "name" => "Línea de producto #$i", - "quantity" => 1, - "unitPriceWithoutTax" => $amount, - "taxes" => [Facturae::TAX_IVA => 21] + "unitPriceWithoutTax" => $item['unitPriceWithoutTax'], + "quantity" => $item['quantity'], + "taxes" => [ + Facturae::TAX_IVA => $item['tax'] + ] ])); } @@ -32,15 +51,45 @@ private function _runTest($schema, $precision) { $actualTotal = floatval($beforeTaxes + $taxOutputs - $taxesWithheld); $this->assertEqualsWithDelta($actualTotal, $invoiceTotal, 0.000000001, 'Incorrect invoice totals element'); - // Validate total invoice amount - if ($precision === Facturae::PRECISION_INVOICE) { - $expectedTotal = round(array_sum($amounts)*1.21, 2); - } else { - $expectedTotal = array_sum(array_map(function($amount) { - return round($amount*1.21, 2); - }, $amounts)); + // Calculate expected invoice totals + $expectedTotal = 0; + $expectedTaxes = []; + $decimals = ($precision === Facturae::PRECISION_INVOICE) ? 15 : 2; + foreach ($items as $item) { + if (!isset($expectedTaxes[$item['tax']])) { + $expectedTaxes[$item['tax']] = [ + "base" => 0, + "amount" => 0 + ]; + } + $taxableBase = round($item['unitPriceWithoutTax'] * $item['quantity'], $decimals); + $taxAmount = round($taxableBase * ($item['tax']/100), $decimals); + $expectedTotal += $taxableBase + $taxAmount; + $expectedTaxes[$item['tax']]['base'] += $taxableBase; + $expectedTaxes[$item['tax']]['amount'] += $taxAmount; + } + foreach ($expectedTaxes as $key=>$value) { + $expectedTaxes[$key]['base'] = round($value['base'], 2); + $expectedTaxes[$key]['amount'] = round($value['amount'], 2); + } + $expectedTotal = round($expectedTotal, 2); + + // Validate invoice total + // NOTE: When in invoice precision mode, we use a 1 cent tolerance as this mode prioritizes accurate invoice total + // over invoice lines totals. This is the maximum tolerance allowed by the FacturaE specification. + $tolerance = ($precision === Facturae::PRECISION_INVOICE) ? 0.01 : 0.000000001; + $this->assertEqualsWithDelta($expectedTotal, $invoiceTotal, $tolerance, 'Incorrect total invoice amount'); + + // Validate tax totals + foreach ($invoiceXml->TaxesOutputs->Tax as $taxNode) { + $rate = (float) $taxNode->TaxRate; + $actualBase = (float) $taxNode->TaxableBase->TotalAmount; + $actualAmount = (float) $taxNode->TaxAmount->TotalAmount; + $expectedBase = $expectedTaxes[$rate]['base']; + $expectedAmount = $expectedTaxes[$rate]['amount']; + $this->assertEqualsWithDelta($expectedBase, $actualBase, 0.000000001, "Incorrect taxable base for $rate% rate"); + $this->assertEqualsWithDelta($expectedAmount, $actualAmount, 0.000000001, "Incorrect tax amount for $rate% rate"); } - $this->assertEqualsWithDelta($expectedTotal, $invoiceTotal, 0.000000001, 'Incorrect total invoice amount'); } @@ -49,7 +98,7 @@ private function _runTest($schema, $precision) { */ public function testLinePrecision() { foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { - $this->_runTest($schema, Facturae::PRECISION_LINE); + $this->runTestWithParams($schema, Facturae::PRECISION_LINE); } } @@ -58,8 +107,8 @@ public function testLinePrecision() { * Test invoice precision */ public function testInvoicePrecision() { - foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { - $this->_runTest($schema, Facturae::PRECISION_INVOICE); + foreach ([Facturae::SCHEMA_3_2_1] as $schema) { + $this->runTestWithParams($schema, Facturae::PRECISION_INVOICE); } } } From 982757092574827cfd1eb545ae1f6e77c23ce926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 27 Jul 2023 17:50:13 +0200 Subject: [PATCH 117/146] Corregido typo en ejemplos - Actualizado envio-faceb2b.md --- doc/ejemplos/envio-faceb2b.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ejemplos/envio-faceb2b.md b/doc/ejemplos/envio-faceb2b.md index cd76604..ffd0c94 100644 --- a/doc/ejemplos/envio-faceb2b.md +++ b/doc/ejemplos/envio-faceb2b.md @@ -13,7 +13,7 @@ require_once 'ruta/hacia/vendor/autoload.php'; use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaeFile; -use josemmo\Facturae\Face\FaceB2bClient; +use josemmo\Facturae\Face\Faceb2bClient; // Creamos una factura válida (ver ejemplo simple) $fac = new Facturae(); @@ -24,7 +24,7 @@ $invoice = new FacturaeFile(); $invoice->loadData($fac->export(), "test-invoice.xsig"); // Creamos una conexión con FACe -$faceb2b = new FaceB2bClient("path_to_certificate.pfx", null, "passphrase"); +$faceb2b = new Faceb2bClient("path_to_certificate.pfx", null, "passphrase"); //$faceb2b->setProduction(false); // Descomenta esta línea para entorno de desarrollo // Subimos la factura a FACeB2B From e406808ebc77689e75eca22e3ab23a54aa87e814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 6 Aug 2023 17:31:21 +0200 Subject: [PATCH 118/146] v1.7.8 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 2ef97e4..d292831 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.7"; + const VERSION = "1.7.8"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 3c05ce484436e70170531e23e1dc04ebd05205d9 Mon Sep 17 00:00:00 2001 From: FranBarro Date: Fri, 8 Sep 2023 08:29:17 +0200 Subject: [PATCH 119/146] Add namespaces for DocBlock. Add missing returns in some functions of PropertiesTrait. --- src/Extensions/FacturaeExtension.php | 1 + src/Face/SoapClient.php | 1 + src/Face/Traits/FaceTrait.php | 2 ++ src/Face/Traits/Faceb2bTrait.php | 2 ++ src/FacturaeTraits/PropertiesTrait.php | 5 +++++ 5 files changed, 11 insertions(+) diff --git a/src/Extensions/FacturaeExtension.php b/src/Extensions/FacturaeExtension.php index c50267b..9891242 100644 --- a/src/Extensions/FacturaeExtension.php +++ b/src/Extensions/FacturaeExtension.php @@ -1,5 +1,6 @@ $isPercentage ? $value : null, "amount" => $isPercentage ? null : $value ); + return $this; } @@ -622,6 +625,7 @@ public function clearCharges() { */ public function setRelatedInvoice($relatedInvoice) { $this->header['relatedInvoice'] = $relatedInvoice; + return $this; } @@ -671,6 +675,7 @@ public function addAttachment($file, $description=null) { "file" => $file, "description" => $description ); + return $this; } From ad7611ea01da0502f7f0c1a254c8c1b93f473766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 9 Sep 2023 12:20:22 +0200 Subject: [PATCH 120/146] Mejoras en DocBlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadidos tipos faltantes en DocBlocks - Corregidos typos en documentación --- src/Common/FacturaeSigner.php | 4 +-- src/Common/XmlTools.php | 2 +- src/CorrectiveDetails.php | 4 +-- src/Extensions/FacturaeExtension.php | 1 + src/Face/SoapClient.php | 5 ++-- src/Face/Traits/FaceTrait.php | 33 +++++++++++----------- src/Face/Traits/Faceb2bTrait.php | 12 ++++---- src/Facturae.php | 38 +++++++++++++------------- src/FacturaePayment.php | 2 +- src/FacturaeTraits/ExportableTrait.php | 7 +++-- src/FacturaeTraits/PropertiesTrait.php | 2 ++ src/FacturaeTraits/SignableTrait.php | 3 ++ src/FacturaeTraits/UtilsTrait.php | 11 ++++++-- src/ReimbursableExpense.php | 2 +- 14 files changed, 68 insertions(+), 58 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 4f1a18b..848fe0f 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -45,7 +45,7 @@ final class FacturaeSigner { public $timestampId; /** - * Class constuctor + * Class constructor */ public function __construct() { $this->regenerateIds(); @@ -72,7 +72,7 @@ public function regenerateIds() { /** * Set signing time - * @param int|string $time Time of the signature as UNIX timestamp or parseable date + * @param int|string $time Time of the signature as UNIX timestamp or parsable date * @return self This instance */ public function setSigningTime($time) { diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index 1782f20..d8f46e3 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -119,7 +119,7 @@ public static function toBase64($bytes, $pretty=false) { /** * Prettify * @param string $input Input string - * @return string Multi-line resposne + * @return string Multi-line response */ private static function prettify($input) { return chunk_split($input, 76, "\n"); diff --git a/src/CorrectiveDetails.php b/src/CorrectiveDetails.php index 3a38529..d5a99f5 100644 --- a/src/CorrectiveDetails.php +++ b/src/CorrectiveDetails.php @@ -35,13 +35,13 @@ class CorrectiveDetails { public $reasonDescription = null; /** - * Start of tax period (as UNIX timestamp or parseable date string) + * Start of tax period (as UNIX timestamp or parsable date string) * @var string|int|null */ public $taxPeriodStart = null; /** - * End of tax period (as UNIX timestamp or parseable date string) + * End of tax period (as UNIX timestamp or parsable date string) * @var string|int|null */ public $taxPeriodEnd = null; diff --git a/src/Extensions/FacturaeExtension.php b/src/Extensions/FacturaeExtension.php index 9891242..73ab121 100644 --- a/src/Extensions/FacturaeExtension.php +++ b/src/Extensions/FacturaeExtension.php @@ -1,5 +1,6 @@ request(''); @@ -26,8 +25,8 @@ public function getStatus() { /** * Get administrations - * @param boolean $onlyTopLevel Get only top level administrations - * @return SimpleXMLElement Response + * @param boolean $onlyTopLevel Get only top level administrations + * @return \SimpleXMLElement Response */ public function getAdministrations($onlyTopLevel=true) { $tag = "consultarAdministraciones"; @@ -38,8 +37,8 @@ public function getAdministrations($onlyTopLevel=true) { /** * Get units - * @param string|null $code Administration code - * @return SimpleXMLElement Response + * @param string|null $code Administration code + * @return \SimpleXMLElement Response */ public function getUnits($code=null) { if (is_null($code)) return $this->request(''); @@ -51,8 +50,8 @@ public function getUnits($code=null) { /** * Get NIFs - * @param string|null $code Administration code - * @return SimpleXMLElement Response + * @param string|null $code Administration code + * @return \SimpleXMLElement Response */ public function getNifs($code=null) { if (is_null($code)) return $this->request(''); @@ -64,8 +63,8 @@ public function getNifs($code=null) { /** * Get invoice - * @param string|string[] $regId Invoice register ID(s) - * @return SimpleXMLElement Response + * @param string|string[] $regId Invoice register ID(s) + * @return \SimpleXMLElement Response */ public function getInvoices($regId) { if (is_string($regId)) { @@ -82,10 +81,10 @@ public function getInvoices($regId) { /** * Send invoice - * @param string $email Email address - * @param FacturaeFile $invoice Invoice - * @param FacturaeFile[] $attachments Attachments - * @return SimpleXMLElement Response + * @param string $email Email address + * @param FacturaeFile $invoice Invoice + * @param FacturaeFile[] $attachments Attachments + * @return \SimpleXMLElement Response */ public function sendInvoice($email, $invoice, $attachments=array()) { $req = ''; @@ -111,9 +110,9 @@ public function sendInvoice($email, $invoice, $attachments=array()) { /** * Cancel invoice - * @param string $regId Invoice register ID - * @param string $reason Cancelation reason - * @return SimpleXMLElement Response + * @param string $regId Invoice register ID + * @param string $reason Cancellation reason + * @return \SimpleXMLElement Response */ public function cancelInvoice($regId, $reason) { return $this->request('' . diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php index bec71f4..37084b5 100644 --- a/src/Face/Traits/Faceb2bTrait.php +++ b/src/Face/Traits/Faceb2bTrait.php @@ -118,8 +118,8 @@ public function downloadInvoice($regId, $validate=true) { /** * Confirm invoice download - * @param string $regId Registry number - * @return SimpleXMLElement Response + * @param string $regId Registry number + * @return SimpleXMLElement Response */ public function confirmInvoiceDownload($regId) { return $this->request('' . @@ -149,8 +149,8 @@ public function rejectInvoice($regId, $reason, $comment=null) { /** * Mark invoice as paid - * @param string $regId Registry number - * @return SimpleXMLElement Response + * @param string $regId Registry number + * @return SimpleXMLElement Response */ public function markInvoiceAsPaid($regId) { return $this->request('' . @@ -161,8 +161,8 @@ public function markInvoiceAsPaid($regId) { /** * Accept invoice cancellation - * @param string $regId Registry number - * @return SimpleXMLElement Response + * @param string $regId Registry number + * @return SimpleXMLElement Response */ public function acceptInvoiceCancellation($regId) { return $this->request('' . diff --git a/src/Facturae.php b/src/Facturae.php index d292831..72b76f9 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -33,43 +33,43 @@ class Facturae { const PRECISION_LINE = 1; const PRECISION_INVOICE = 2; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CASH = "01"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_DEBIT = "02"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_RECEIPT = "03"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_TRANSFER = "04"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_ACCEPTED_BILL_OF_EXCHANGE = "05"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_DOCUMENTARY_CREDIT = "06"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CONTRACT_AWARD = "07"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_BILL_OF_EXCHANGE = "08"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_TRANSFERABLE_IOU = "09"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_IOU = "10"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CHEQUE = "11"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_REIMBURSEMENT = "12"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_SPECIAL = "13"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_SETOFF = "14"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_POSTGIRO = "15"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CERTIFIED_CHEQUE = "16"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_BANKERS_DRAFT = "17"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CASH_ON_DELIVERY = "18"; - /** @deprecated 1.7.3 Use constants from @see{FacturaePayment} class instead. */ + /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */ const PAYMENT_CARD = "19"; const TAX_IVA = "01"; diff --git a/src/FacturaePayment.php b/src/FacturaePayment.php index a8ca0a4..ef4047a 100644 --- a/src/FacturaePayment.php +++ b/src/FacturaePayment.php @@ -34,7 +34,7 @@ class FacturaePayment { public $method = self::TYPE_CASH; /** - * Payment due date (as UNIX timestamp or parseable date string) + * Payment due date (as UNIX timestamp or parsable date string) * @var int|string|null */ public $dueDate = null; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 6b7c4d3..1f07184 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -2,18 +2,20 @@ namespace josemmo\Facturae\FacturaeTraits; use josemmo\Facturae\Common\XmlTools; -use josemmo\Facturae\CorrectiveDetails; +use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaePayment; use josemmo\Facturae\ReimbursableExpense; /** * Allows a Facturae instance to be exported to XML. + * + * @var Facturae $this */ trait ExportableTrait { /** * Add optional fields - * @param object $item Subject item + * @param array $item Subject item * @param string[] $fields Optional fields * @return string Output XML */ @@ -43,7 +45,6 @@ public function export($filePath=null) { // Prepare document $xml = ''; $totals = $this->getTotals(); - /** @var CorrectiveDetails|null */ $corrective = $this->getCorrective(); $paymentDetailsXML = $this->getPaymentDetailsXML($totals); diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index ad2cc25..5702480 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -13,6 +13,8 @@ * Implements all attributes and methods needed to make Facturae instantiable. * This includes all properties that define an electronic invoice, but without * additional functionalities such as signing or exporting. + * + * @var Facturae $this */ trait PropertiesTrait { protected $currency = "EUR"; diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php index 65b1791..23a7032 100644 --- a/src/FacturaeTraits/SignableTrait.php +++ b/src/FacturaeTraits/SignableTrait.php @@ -2,10 +2,13 @@ namespace josemmo\Facturae\FacturaeTraits; use josemmo\Facturae\Common\FacturaeSigner; +use josemmo\Facturae\Facturae; /** * Implements all properties and methods needed for an instantiable * Facturae to be signed and time stamped. + * + * @var Facturae $this */ trait SignableTrait { /** @var FacturaeSigner|null */ diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index 858435c..02dea57 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -1,8 +1,13 @@ Date: Sat, 28 Oct 2023 18:43:28 +0200 Subject: [PATCH 121/146] Arreglo X509IssuerName - Actualizada clase FacturaeSigner > Fixes #142 --- src/Common/FacturaeSigner.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 848fe0f..d802d52 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -154,8 +154,11 @@ public function sign($xml) { $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; - foreach ($certData['issuer'] as $item=>$value) { - $certIssuer[] = "$item=$value"; + foreach ($certData['issuer'] as $item=>$rawValues) { + $values = is_array($rawValues) ? $rawValues : [$rawValues]; + foreach ($values as $value) { + $certIssuer[] = "$item=$value"; + } } $certIssuer = implode(',', array_reverse($certIssuer)); $xadesSignedProperties = '' . From 96c5b830a349e97e0136407868c7d026adde6a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 12 Nov 2023 13:53:47 +0100 Subject: [PATCH 122/146] =?UTF-8?q?A=C3=B1adida=20correcci=C3=B3n=20de=20a?= =?UTF-8?q?tributos=20OID=20al=20firmar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner > Related to #143 --- src/Common/FacturaeSigner.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index d802d52..b97ca04 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -13,6 +13,27 @@ final class FacturaeSigner { const SIGN_POLICY_NAME = 'Política de Firma FacturaE v3.1'; const SIGN_POLICY_URL = 'http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf'; const SIGN_POLICY_DIGEST = 'Ohixl6upD6av8N7pEvDABhEL6hM='; + const ALLOWED_OID_TYPES = [ + // Mandatory fields in https://datatracker.ietf.org/doc/html/rfc4514#section-3 + 'CN' => 'CN', + 'L' => 'L', + 'ST' => 'ST', + 'O' => 'O', + 'OU' => 'OU', + 'C' => 'C', + 'STREET' => 'STREET', + 'DC' => 'DC', + 'UID' => 'UID', + + // Other fields with well-known names + 'GN' => 'GN', + 'SN' => 'SN', + + // Other fields with compatibility issues + 'organizationIdentifier' => 'OID.2.5.4.97', + 'serialNumber' => 'OID.2.5.4.5', + 'title' => 'OID.2.5.4.12', + ]; use KeyPairReaderTrait; @@ -155,12 +176,16 @@ public function sign($xml) { $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; foreach ($certData['issuer'] as $item=>$rawValues) { + if (!isset(self::ALLOWED_OID_TYPES[$item])) { + continue; + } + $item = self::ALLOWED_OID_TYPES[$item]; $values = is_array($rawValues) ? $rawValues : [$rawValues]; foreach ($values as $value) { $certIssuer[] = "$item=$value"; } } - $certIssuer = implode(',', array_reverse($certIssuer)); + $certIssuer = implode(', ', array_reverse($certIssuer)); $xadesSignedProperties = '' . '' . '' . date('c', $signingTime) . '' . From 69194142bf7e0d3b797341b3691dbbb9f629f230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 12 Nov 2023 13:59:12 +0100 Subject: [PATCH 123/146] =?UTF-8?q?A=C3=B1adida=20correcci=C3=B3n=20de=20a?= =?UTF-8?q?tributos=20OID=20al=20firmar=20(II)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner para añadir compatibilidad con PHP 5.6 > Related to #143 --- src/Common/FacturaeSigner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index b97ca04..9b69cde 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -176,7 +176,7 @@ public function sign($xml) { $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; foreach ($certData['issuer'] as $item=>$rawValues) { - if (!isset(self::ALLOWED_OID_TYPES[$item])) { + if (!array_key_exists($item, self::ALLOWED_OID_TYPES)) { continue; } $item = self::ALLOWED_OID_TYPES[$item]; From 40b66637f3f7b9c444522d56fc837daf9fb75b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 19 Nov 2023 09:43:01 +0100 Subject: [PATCH 124/146] v1.7.9 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 72b76f9..10ec12d 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.8"; + const VERSION = "1.7.9"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From 0be90478f33fafb2f78d4221d91deccbbb994561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 8 Feb 2024 18:48:55 +0100 Subject: [PATCH 125/146] =?UTF-8?q?A=C3=B1adido=20PHP=208.4=20a=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado workflow de tests --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdda11c..0dbac8d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,17 +13,17 @@ jobs: continue-on-error: ${{ matrix.experimental || false }} strategy: matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] include: - - php-version: '8.2' + - php-version: '8.3' test-ws: true send-coverage: true - - php-version: '8.3' + - php-version: '8.4' experimental: true steps: # Download code from repository - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Setup PHP and Composer - name: Setup PHP From f945c83e03d471fd414eb61cb52f2a7fa02b7df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 8 Feb 2024 18:51:36 +0100 Subject: [PATCH 126/146] Deshabilitado fail-fast en tests - Actualizado workflow de tests --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0dbac8d..8e27dee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental || false }} strategy: + fail-fast: false matrix: php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] include: From 4f62135825053d1a538aee0b1c87f62423d4bcf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 15 Apr 2024 19:49:09 +0200 Subject: [PATCH 127/146] =?UTF-8?q?A=C3=B1adido=20parche=20para=20OpenSSL?= =?UTF-8?q?=201.x.x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner > Fixes #82 --- src/Common/FacturaeSigner.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index 9b69cde..b279e31 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -175,14 +175,18 @@ public function sign($xml) { $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; $certData = openssl_x509_parse($this->publicChain[0]); $certIssuer = []; - foreach ($certData['issuer'] as $item=>$rawValues) { - if (!array_key_exists($item, self::ALLOWED_OID_TYPES)) { - continue; - } - $item = self::ALLOWED_OID_TYPES[$item]; + foreach ($certData['issuer'] as $rawType=>$rawValues) { $values = is_array($rawValues) ? $rawValues : [$rawValues]; foreach ($values as $value) { - $certIssuer[] = "$item=$value"; + if ($rawType === "UNDEF" && preg_match('/^VAT[A-Z]{2}-/', $value) === 1) { + $type = "OID.2.5.4.97"; // Fix for OpenSSL <3.0.0 + } else { + if (!array_key_exists($rawType, self::ALLOWED_OID_TYPES)) { + continue; // Skip unknown OID types + } + $type = self::ALLOWED_OID_TYPES[$rawType]; + } + $certIssuer[] = "$type=$value"; } } $certIssuer = implode(', ', array_reverse($certIssuer)); From 312ad85cec3f573de8db0322336f2608c90ea5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Mon, 15 Apr 2024 20:05:13 +0200 Subject: [PATCH 128/146] =?UTF-8?q?A=C3=B1adido=20showcase=20de=20organiza?= =?UTF-8?q?ciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index d6bb6ca..4778656 100644 --- a/README.md +++ b/README.md @@ -65,5 +65,13 @@ file_put_contents(__DIR__ . "/factura.xsig", $signedXml); - Sellado de tiempo según el [RFC3161](https://www.ietf.org/rfc/rfc3161.txt) - Envío automatizado de facturas a **FACe y FACeB2B** 🔥 +## Usan Facturae-PHP +Estas son algunas de las organizaciones y soluciones software que usan Facturae-PHP o mantienen un fork interno basado en el código de la librería: + +Holded +hotelgest +InvoiceNinja +FacturaScripts + ## Licencia Facturae-PHP se encuentra bajo [licencia MIT](LICENSE). Eso implica que puedes utilizar este paquete en cualquier proyecto (incluso con fines comerciales), siempre y cuando hagas referencia al uso y autoría de la misma. From 02cde07126daacef33aed1d23e4da668bb317563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 28 Apr 2024 12:33:12 +0200 Subject: [PATCH 129/146] Arreglado C14N en webservices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase SoapClient para usar EXC-C14N por defecto - Actualizada clase CustomFaceClient - Actualizados tests unitarios - Actualizada documentación > Fixes #152 --- doc/envio-y-recepcion/face.md | 11 +++++++++++ src/Face/CustomFaceClient.php | 18 ++++++++++++++++++ src/Face/SoapClient.php | 16 ++++++++++++++-- tests/WebservicesTest.php | 23 +++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/doc/envio-y-recepcion/face.md b/doc/envio-y-recepcion/face.md index d4876c1..20add0e 100644 --- a/doc/envio-y-recepcion/face.md +++ b/doc/envio-y-recepcion/face.md @@ -50,6 +50,17 @@ $endpointUrl = "https://w390w.gipuzkoa.net/WAS/HACI/HFAServiciosProveedoresWEB/s $face = new CustomFaceClient($endpointUrl, "certificado.pfx", null, "passphrase"); ``` +Si al intentar conectarte al punto de recepción obtienes un error similar a "A bad canonicalization algorithm was specified", es posible que el servidor de destino no soporte canonicalización de XML exclusiva ([EXC-C14N](https://www.w3.org/TR/xml-exc-c14n/)). +Para usar C14N en vez de EXC-C14N, utiliza este método del cliente: +```php +$face->setExclusiveC14n(false); +``` + +Otro error típico es "Document element namespace mismatch expected". En ese caso, el punto de entrada necesita usar un namespace personalizado, que puedes especificar usando este método: +```php +$face->setWebNamespace('https://webservice.efact.es/sspp'); // Ejemplo para e-FACT +``` + --- ## Listado de métodos diff --git a/src/Face/CustomFaceClient.php b/src/Face/CustomFaceClient.php index 310e2af..6500c9c 100644 --- a/src/Face/CustomFaceClient.php +++ b/src/Face/CustomFaceClient.php @@ -5,6 +5,7 @@ class CustomFaceClient extends SoapClient { private $endpointUrl; + private $webNamespace = "https://webservice.face.gob.es"; use FaceTrait; @@ -22,6 +23,23 @@ public function __construct($endpointUrl, $publicPath, $privatePath=null, $passp } + /** + * Set custom web service namespace + * @param string $webNamespace Web service namespace to override the default one + */ + public function setWebNamespace($webNamespace) { + $this->webNamespace = $webNamespace; + } + + + /** + * @inheritdoc + */ + protected function getWebNamespace() { + return $this->webNamespace; + } + + /** * Get endpoint URL * @return string Endpoint URL diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php index 368f7e2..f68ca1a 100644 --- a/src/Face/SoapClient.php +++ b/src/Face/SoapClient.php @@ -8,6 +8,7 @@ abstract class SoapClient { const REQUEST_EXPIRATION = 60; // In seconds + private $useExcC14n = true; use KeyPairReaderTrait; @@ -28,6 +29,15 @@ public function __construct($storeOrCertificate, $privateKey=null, $passphrase=' } + /** + * Set exclusive canonicalization mode + * @param boolean $enabled Whether to use EXC-C14N (`true`) or C14N (`false`) + */ + public function setExclusiveC14n($enabled) { + $this->useExcC14n = $enabled; + } + + /** * Get endpoint URL * @return string Endpoint URL @@ -91,8 +101,10 @@ protected function request($body) { ''; // Generate signed info + $c14nNamespace = $this->useExcC14n ? "http://www.w3.org/2001/10/xml-exc-c14n#" : "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; + $signedInfoNs = $this->useExcC14n ? ['xmlns:ds' => $ns['xmlns:ds']] : $ns; $signedInfo = '' . - '' . + '' . '' . '' . '' . @@ -104,7 +116,7 @@ protected function request($body) { '' . $bodyDigest . '' . '' . ''; - $signedInfoPayload = XmlTools::injectNamespaces($signedInfo, $ns); + $signedInfoPayload = XmlTools::injectNamespaces($signedInfo, $signedInfoNs); // Add signature and KeyInfo to header $reqHeader .= '' . diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index 8dd1396..9ef3a49 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -5,6 +5,7 @@ use josemmo\Facturae\FacturaeParty; use josemmo\Facturae\FacturaeCentre; use josemmo\Facturae\FacturaeFile; +use josemmo\Facturae\Face\CustomFaceClient; use josemmo\Facturae\Face\FaceClient; use josemmo\Facturae\Face\Faceb2bClient; @@ -59,6 +60,11 @@ public function testFace() { $this->assertNotEmpty($face->getUnits('E04921501')->relaciones); $this->assertNotEmpty($face->getNifs('E04921501')->nifs); + // Test C14N (non-exclusive) + $face->setExclusiveC14n(false); + $this->assertNotEmpty($face->getStatus()->estados); + $face->setExclusiveC14n(true); + // Generate invoice $fac = $this->getWsBaseInvoice(); $fac->setBuyer(new FacturaeParty([ @@ -107,6 +113,18 @@ public function testFace() { } + public function testCustomFace() { + $this->checkEnv(); + + $endpointUrl = "https://efact-pre.aoc.cat/bustia/services/EFactWebServiceProxyService"; + $customFace = new CustomFaceClient($endpointUrl, self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); + $customFace->setWebNamespace('https://webservice.efact.es/sspp'); + + // Test misc. methods + $this->assertNotEmpty($customFace->getStatus()->estados); + } + + /** * Test FACeB2B */ @@ -121,6 +139,11 @@ public function testFaceb2b() { $this->assertEquals(intval($faceb2b->getRegisteredInvoices()->resultStatus->code), 0); $this->assertEquals(intval($faceb2b->getInvoiceCancellations()->resultStatus->code), 0); + // Test C14N (non-exclusive) + $faceb2b->setExclusiveC14n(false); + $this->assertNotEmpty($faceb2b->getCodes()->codes); + $faceb2b->setExclusiveC14n(true); + // Generate invoice $fac = $this->getWsBaseInvoice(); $fac->setBuyer(new FacturaeParty([ From 52d4bf624bb1e65085164f67ded349ea583ad898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 28 Apr 2024 14:02:48 +0200 Subject: [PATCH 130/146] Unificados certificados de prueba - Eliminado certificado de Izenpe (faltaba la cadena entera y empezaba a dar problemas en algunas AAPP) - Actualizadoc certificado de FNMT > Descargado desde https://www.sede.fnmt.gob.es/stceres --- tests/AbstractTest.php | 1 - tests/WebservicesTest.php | 8 ++-- tests/certs/facturae-private.pem | 56 +++++++++++----------- tests/certs/facturae-public.pem | 78 +++++++++++++++---------------- tests/certs/facturae.p12 | Bin 6947 -> 7045 bytes tests/certs/webservices.p12 | Bin 5178 -> 0 bytes 6 files changed, 71 insertions(+), 72 deletions(-) delete mode 100644 tests/certs/webservices.p12 diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php index 570054f..6c6aa65 100644 --- a/tests/AbstractTest.php +++ b/tests/AbstractTest.php @@ -10,7 +10,6 @@ abstract class AbstractTest extends TestCase { const OUTPUT_DIR = __DIR__ . "/output"; const CERTS_DIR = __DIR__ . "/certs"; const FACTURAE_CERT_PASS = "1234"; - const WEBSERVICES_CERT_PASS = "IZProd2021"; const NOTIFICATIONS_EMAIL = "josemmo@pm.me"; const COOKIES_PATH = self::OUTPUT_DIR . "/cookies.txt"; diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php index 9ef3a49..0d9dd98 100644 --- a/tests/WebservicesTest.php +++ b/tests/WebservicesTest.php @@ -51,7 +51,7 @@ private function getWsBaseInvoice() { public function testFace() { $this->checkEnv(); - $face = new FaceClient(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $face = new FaceClient(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); $face->setProduction(false); // Test misc. methods @@ -92,7 +92,7 @@ public function testFace() { ]) ] ])); - $fac->sign(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); // Send invoice $invoiceFile = new FacturaeFile(); @@ -131,7 +131,7 @@ public function testCustomFace() { public function testFaceb2b() { $this->checkEnv(); - $faceb2b = new Faceb2bClient(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $faceb2b = new Faceb2bClient(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); $faceb2b->setProduction(false); // Test misc. methods @@ -158,7 +158,7 @@ public function testFaceb2b() { "code" => "ESA789231250000", "name" => "Centro administrativo receptor" ])); - $fac->sign(self::CERTS_DIR . "/webservices.p12", null, self::WEBSERVICES_CERT_PASS); + $fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS); // Send invoice $invoiceFile = new FacturaeFile(); diff --git a/tests/certs/facturae-private.pem b/tests/certs/facturae-private.pem index 2b03bdb..41e9cea 100644 --- a/tests/certs/facturae-private.pem +++ b/tests/certs/facturae-private.pem @@ -1,30 +1,30 @@ -----BEGIN ENCRYPTED PRIVATE KEY----- -MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIG4Kky+Gm/VICAggA -MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECLtOZxo5MtoqBIIEyGblv9lcZ4gh -9UMmH4DvngSuZQ8V4hboG3Eqd0YVpLs8r+/CXXh11J7f6EJKwaGKIxb9s7YI6t2u -KdtNhfv8KzBpl9yQx2LPtuyb4QUcrPuOMQ6I6AneKLyvDXWFUCRw3XM/1oqlzxjP -9G4RxEsxiLJuPaTPxhSAbi7hdm7RREXmL/4+gs1fxjk98bvDAmZDpjnxgEiXeZE1 -qOlvWm6MHL/Qo3kpbk6DKk3uqpcX12T6/LwDX35zktzu6BzrukvS5tvmZH0gFJYv -+YXllJPE5OAaxLBGPVhVOZGMs5Fe9adcr0dcTzJGc5hC25pnHIl39/aiiLQ0sKUQ -MkV/rV8ChoRiA++rlC6Pcm0ucco1pYnejCPaTPfvEL2GPWp5O4CU+UQL+X+hdmd2 -vAprvikEIxT3XjrKe+iQTRdZkwIcRjpFo6qvxN2lHhIQjAJE4U3gfnHE1tCAvrA/ -fP/ThSRGI268zw0MFx2hGELwUSbt5WXV6ajXg+tWP3FUgSy/amcxeezUUqiq4KMe -OPzKFSvekkfEQjVi5R/HxeLkCbJrhDXmxhtrLKRE47kdfNB8MSaufuhJJrstvCJ+ -7UWRWl6tvWpVB0oJPD8NXq+eV8Hv9onsuie/Vg2c13Sj2Gu21QHkIOAElvktFTi0 -kV/gj2DCPWZEi71fejpHKdQzBykbMaibzNcDqiVb0tElEo0QjZhjxc69tYgSSAY2 -zLMHybeA9ea/8S4MTC2A655X/5cgwQWRcAz8Iks4bBQLknjoIC9va8/K6JfScfxi -BTH6qYwP2YbkcgmP9s9QeqZqEdV+YKfuNOWHafRlvDBAagH4pmdN1OSl12ivkiF/ -cjt1KYTl3QO+3ABrHVNK59Np6O6KlMGgBbkqs6rmSZQ9QfracSIqgJF91iACBiZJ -uCU8q6HJJRj6If+XjGd+8VTQ+lORLjhgNXxb9KXpPNVayNUrwNKWHshgXvepXFEJ -fegQwzugFEyf0cRXWWc6zp/BOUdHk+g11QqwXXuRd1C7jLJd9N3DSfCRcBNDF1M0 -Mzk+1WuPewBoH18LNty6O/jxoxe7zpLT87A8FMeRMi+x8jBVN1sUxSPSPp2wyAuF -iOgEPjlsSHi3/Hxd8li5a7J/I9my8Y85Qyq13O4OiVohlXZA3UKaRBJS+ETs3BYY -j1aqS207Hzu7kCLlSFjW7s8QXpjUuJDkSIKLiKgn2Akge6Ay8FRFENfnd28OwYDP -QRAGXFjkqfPyTiL/CRO1fnC2tCN6HbddX02UDekg7RQCCkGHv14dz8TphovvgICf -MU+5zULnHSc7YNBsvKo8laFEZ7HZfdw8caxCxcdKptbLIPDAQIpVTwiRxOXL/dbh -/jB7nA5+aC1b0EYFd6XWoyxw1fglMrypKqOt6qSvpfl4nqGbrx102izjQCnUxNPd -yMSSrnhemoGMyYoC5DdlSyiTFFMpKkqb3rVNT+eyMtCPQDVIni7g9RKWlXyPe9Gi -vj+cvdzwnFa+LWAKJDDcCdsF9hN7I/xrHdYMzzMsIR1W0leQKmWvGbjS1LMDch/h -R2VJniGFkbYFkqUw3FfoTgSadvYQD30sG+R2rnBMuADpfLwmUpA90bLSYDdEI4kL -CQ7aDxgZUOrFC+FxxMcuKw== +MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIBvzP/2jJehkCAggA +MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBANEgcqWh4Z4q+YhIKCUaaABIIE +0MNoYmNFQCtXs4cFvGSS30slLW1to3y3emUm6Qgf7CVHWEHbWLMiXaD7Ak/XyUty +WXbAr41Ew4hM/gKYQGXh+imdzMG+kOYgcqf38JRhPvBkiwrCZnmCxJyC+RSbLU4w +wNmF+JwfDtw9269X1ewWwnKaceqhA71TDLfzJFKDydZGKXAgFeilZhhCq8K32VZo +qtZD58beHeldBvnazc1vTI6dP6gFbnDxE0S79h5lNsEeq8Qd2zCJ3OfuVviVGbpF +YF52BDG8T+clFuFIxg9zZQiVh4tUmuaFeAzuGbMM9FSlKkExn/9dx2xSS7rnFmTK +ordGD67XT51C7Z4pYFZ644niXQiK3YeFziSfITzSlyYCk0e8vcArlKu8+oUh2pU0 +o6h7Ue+I9mlXswY5bBzH+MXJyUK23EOVBftBnAGThoMFq/XCEX+We52X8zoLmeIF +gLdi9Pdk/LpsiLTsvHCKVdxWg4yCHGwACx8pdvcAwbDdXmYTbg/nUso1aFSNt1Oj +Mg2WtfQJ30SidmxfluWaxmuHbYUNLRsviizDyrvukv21DCK8FNlPzJrDrjn7ohyv ++gYqaguU0rPE+fCGkRDNfzJ3hy4Rhq8uYKW2E/CJUHexpk1nsvuNGtPUyA1qlbLm +pyZmGbhsZZOVNhFEtbxx1oHT/lrTcRnXo4Wl62Y8x+KBneBaQUTKSmD/5HChqSFU +UzL8iAWHDw1WQSeks1aEzIWzmdS8uxiKrCIWLlyJQE6gWnSwxj1MYSjrKSPCY57+ +E3yDWZFkRgRrFBaJsjo7iisuHKNMGIH1WUO5qIchBJaRlE9o3JJd16Cdst/ILgqj +AKqcOZ0hiN83f2KXsMv7rKi8IxpIHPtrEdvrMY0AUtVBRNj5hpv6w9TFH6ELfLES +Q6RLQ+3jkhzmi8Z1xeF94Kv/r5+ZhBrMuauqrbPsdT0VK/Y9eqnZCSaflP58eIN+ +i28a09xzp5N+c4pproaKHLT/iL1FOHNJb1xJ7aYIkLcXmbdNZLcAPlWPtkatfwZ7 +MfUijJCWCu0EFl9+zhmOyDmC2RCMnL4MxeOD7LtqEE2DfECWmk7rVIIQ20Orvr22 +6WH8JyMRlq/2o0TWE6/j1PPN3RmdMEJwSsLmAcQy0Px69GMC2w8HidkdDwCKRLvo +thy+gfIShXTCCwY0N2IwxUNv17XIFV+uIatR83evDEOqMypybhGA9sXHrGKJz9/e +AquF82caXsULkvdbxtd+PPn/GAP9+kY4HfWfrlWkgXk7PMQtHJT+JSfMza8qKtnV +p+cruUAvrS0DgApLWxQt2TDPPXdGxlWAJmeu2cNVUNGfbTfWx4w8sSQy0zQrG26e +X3otyVIf8fs7cvQxox9EBLcLxFwQiMeqz96QVs3ymFh1kpUthBW6894pb1RT3V66 +DtIpATu8F1Vf1Umas/TgMzJRnA0CUzPXY6xjIPGMc08kKYK8wnhEmHqT8929PGm/ +0l1D+F7vGKsbZy2vn/bdR+EdKpS7WQbysRtGuOXyWfjzfxqXvEg+dO7sNjmCBz7W +tdhZSfdBQ8knPIScdX4bkABZhSat3pGbk/HcZ4k1AbamPh63/AHT6XIRYqPxyLUU +O6kkEbFOeT76+wGstBGYPQICGUjd3EqBlJLSBEkIiWY8 -----END ENCRYPTED PRIVATE KEY----- \ No newline at end of file diff --git a/tests/certs/facturae-public.pem b/tests/certs/facturae-public.pem index 22dd397..8938204 100644 --- a/tests/certs/facturae-public.pem +++ b/tests/certs/facturae-public.pem @@ -1,43 +1,43 @@ -----BEGIN CERTIFICATE----- -MIIHhjCCBm6gAwIBAgIQSOSlyjvRFUlfo/hUFNAvqDANBgkqhkiG9w0BAQsFADBL +MIIHoDCCBoigAwIBAgIQc/g6LlAaVg9l7w+qq1iLnzANBgkqhkiG9w0BAQsFADBL MQswCQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVz -MRkwFwYDVQQDDBBBQyBGTk1UIFVzdWFyaW9zMB4XDTIwMTEwNTEzMDQyMFoXDTI0 -MTEwNTEzMDQyMFowgYUxCzAJBgNVBAYTAkVTMRgwFgYDVQQFEw9JRENFUy05OTk5 -OTk5OVIxEDAOBgNVBCoMB1BSVUVCQVMxGjAYBgNVBAQMEUVJREFTIENFUlRJRklD +MRkwFwYDVQQDDBBBQyBGTk1UIFVzdWFyaW9zMB4XDTI0MDMxMTE0MDUzMFoXDTI4 +MDMxMTE0MDUzMFowgYUxCzAJBgNVBAYTAkVTMRgwFgYDVQQFEw9JRENFUy05OTk5 +OTk3MkMxEDAOBgNVBCoMB1BSVUVCQVMxGjAYBgNVBAQMEUVJREFTIENFUlRJRklD QURPMS4wLAYDVQQDDCVFSURBUyBDRVJUSUZJQ0FETyBQUlVFQkFTIC0gOTk5OTk5 -OTlSMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAujAnB2L5X2Bm42S5 -f/axKFu1QsAcZGJAeYELZZJ04jriBu3E8V3Rus3tUxfQ+ylqBm0bNWgHfP+gekos -HaYoJNQmAVBuwpd183uHksTRUtbeOAFS2xd7v29stM7ARkec+WVV+SK8G6HECIB0 -VIAMoB2tVs0y6XRVRcjE4I7kH1h3ZbMIzvW43B4hxruYtXcvozGwvZpxQKVrjEY8 -IXH5+aXHM8WLCba4I06FyhvI+2/9WUPN2YvDoml7lQM4edgepTEZifq2ZPHGpCC5 -NhSXj2ab5FtnGTMgUaWH6tCljT0kOdfJBOHnIWOw4dBdgkik2CuxwGyMrq/P5VqQ -IC2hXQIDAQABo4IEKTCCBCUwgZIGA1UdEQSBijCBh4Edc29wb3J0ZV90ZWNuaWNv -X2NlcmVzQGZubXQuZXOkZjBkMRgwFgYJKwYBBAGsZgEEDAk5OTk5OTk5OVIxGjAY -BgkrBgEEAaxmAQMMC0NFUlRJRklDQURPMRQwEgYJKwYBBAGsZgECDAVFSURBUzEW -MBQGCSsGAQQBrGYBAQwHUFJVRUJBUzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE -AwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDBAYIKwYBBQUHAwIwHQYDVR0OBBYEFE5a -HiQQRwVYJzmmkfG/i5MxmMNdMB8GA1UdIwQYMBaAFLHUT8QjefpEBQnG6znP6DWw -uCBkMIGCBggrBgEFBQcBAQR2MHQwPQYIKwYBBQUHMAGGMWh0dHA6Ly9vY3NwdXN1 -LmNlcnQuZm5tdC5lcy9vY3NwdXN1L09jc3BSZXNwb25kZXIwMwYIKwYBBQUHMAKG -J2h0dHA6Ly93d3cuY2VydC5mbm10LmVzL2NlcnRzL0FDVVNVLmNydDCCARUGA1Ud -IASCAQwwggEIMIH6BgorBgEEAaxmAwoBMIHrMCkGCCsGAQUFBwIBFh1odHRwOi8v -d3d3LmNlcnQuZm5tdC5lcy9kcGNzLzCBvQYIKwYBBQUHAgIwgbAMga1DZXJ0aWZp -Y2FkbyBjdWFsaWZpY2FkbyBkZSBmaXJtYSBlbGVjdHLDs25pY2EuIFN1amV0byBh -IGxhcyBjb25kaWNpb25lcyBkZSB1c28gZXhwdWVzdGFzIGVuIGxhIERQQyBkZSBs -YSBGTk1ULVJDTSBjb24gTklGOiBRMjgyNjAwNC1KIChDL0pvcmdlIEp1YW4gMTA2 -LTI4MDA5LU1hZHJpZC1Fc3Bhw7FhKTAJBgcEAIvsQAEAMIG6BggrBgEFBQcBAwSB -rTCBqjAIBgYEAI5GAQEwCwYGBACORgEDAgEPMBMGBgQAjkYBBjAJBgcEAI5GAQYB -MHwGBgQAjkYBBTByMDcWMWh0dHBzOi8vd3d3LmNlcnQuZm5tdC5lcy9wZHMvUERT -QUNVc3Vhcmlvc19lcy5wZGYTAmVzMDcWMWh0dHBzOi8vd3d3LmNlcnQuZm5tdC5l -cy9wZHMvUERTQUNVc3Vhcmlvc19lbi5wZGYTAmVuMIG1BgNVHR8Ega0wgaowgaeg -gaSggaGGgZ5sZGFwOi8vbGRhcHVzdS5jZXJ0LmZubXQuZXMvY249Q1JMMzc0OCxj -bj1BQyUyMEZOTVQlMjBVc3VhcmlvcyxvdT1DRVJFUyxvPUZOTVQtUkNNLGM9RVM/ -Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDtiaW5hcnk/YmFzZT9vYmplY3RjbGFz -cz1jUkxEaXN0cmlidXRpb25Qb2ludDANBgkqhkiG9w0BAQsFAAOCAQEAH4t5/v/S -Lsm/dXRDw4QblCmTX+5pgXJ+4G1Lb3KTSPtDJ0UbQiAMUx+iqDDOoMHU5H7po/HZ -LJXgNwvKLoiLbl5/q6Mqasif87fa6awNkuz/Y6dvXw0UOJh+Ud/Wrk0EyaP9ZtrL -VsraUOobNyS6g+lOrCxRrNxGRK2yAeotO6LEo1y3b7CB+Amd2jDq8lY3AtCYlrhu -CaTf0AD9IBYYmigHzFD/VH5a8uG95l6J85FQG7tMsG6UQHFM2EmNhpbrYH+ihetz -3UhzcC5Fd/P1X7pGBymQgbCyBjCRf/HEVzyoHL72uMp2I4JXX4v8HABZT8xtlDY4 -LE0am9keJhaNcg== +NzJDMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX6zxub5QjNrWl5a +sMPracpf+dxfRFA49u3+18Ul0EdeRVyqYyeeGfoO5dL2U4PoyEkGSCVl0pf3KXtB +FCarYimaLe1GujFBwUMM3ywsJpV04vik/LRYKSTgLIXV2iiDsyThZ9vkD/nf5CJh +kxJxRtEZ6znJjCoZOrKzcSEbHvKXVe0AtJnADuUyThw/6a0kdKKPB3RdhpjYxlKe +pe9UOWewvTVYyxmoqNeQ1zj/dsYUP4gIdOo1NbjQ+OP1rw30yZSCx9IziW7LKmJN +paehNl0zSXyFRiZEOT7F/26lBsx+lRucYi8B7p7oek58Ewno2RXvR78ZVihzlhqr +FtWNjQIDAQABo4IEQzCCBD8wcQYDVR0RBGowaKRmMGQxGDAWBgkrBgEEAaxmAQQM +CTk5OTk5OTcyQzEaMBgGCSsGAQQBrGYBAwwLQ0VSVElGSUNBRE8xFDASBgkrBgEE +AaxmAQIMBUVJREFTMRYwFAYJKwYBBAGsZgEBDAdQUlVFQkFTMAwGA1UdEwEB/wQC +MAAwDgYDVR0PAQH/BAQDAgXgMCoGA1UdJQQjMCEGCCsGAQUFBwMCBgorBgEEAYI3 +CgMMBgkqhkiG9y8BAQUwHQYDVR0OBBYEFFB7rD8U2UiFZ+YXsxDmRtmFq2O4MB8G +A1UdIwQYMBaAFLHUT8QjefpEBQnG6znP6DWwuCBkMIGCBggrBgEFBQcBAQR2MHQw +PQYIKwYBBQUHMAGGMWh0dHA6Ly9vY3NwdXN1LmNlcnQuZm5tdC5lcy9vY3NwdXN1 +L09jc3BSZXNwb25kZXIwMwYIKwYBBQUHMAKGJ2h0dHA6Ly93d3cuY2VydC5mbm10 +LmVzL2NlcnRzL0FDVVNVLmNydDCCARUGA1UdIASCAQwwggEIMIH6BgorBgEEAaxm +AwoBMIHrMCkGCCsGAQUFBwIBFh1odHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNz +LzCBvQYIKwYBBQUHAgIwgbAMga1DZXJ0aWZpY2FkbyBjdWFsaWZpY2FkbyBkZSBm +aXJtYSBlbGVjdHLDs25pY2EuIFN1amV0byBhIGxhcyBjb25kaWNpb25lcyBkZSB1 +c28gZXhwdWVzdGFzIGVuIGxhIERQQyBkZSBsYSBGTk1ULVJDTSBjb24gTklGOiBR +MjgyNjAwNC1KIChDL0pvcmdlIEp1YW4gMTA2LTI4MDA5LU1hZHJpZC1Fc3Bhw7Fh +KTAJBgcEAIvsQAEAMIG6BggrBgEFBQcBAwSBrTCBqjAIBgYEAI5GAQEwCwYGBACO +RgEDAgEPMBMGBgQAjkYBBjAJBgcEAI5GAQYBMHwGBgQAjkYBBTByMDcWMWh0dHBz +Oi8vd3d3LmNlcnQuZm5tdC5lcy9wZHMvUERTQUNVc3Vhcmlvc19lcy5wZGYTAmVz +MDcWMWh0dHBzOi8vd3d3LmNlcnQuZm5tdC5lcy9wZHMvUERTQUNVc3Vhcmlvc19l +bi5wZGYTAmVuMIHkBgNVHR8EgdwwgdkwgdaggdOggdCGgZ5sZGFwOi8vbGRhcHVz +dS5jZXJ0LmZubXQuZXMvY249Q1JMVTg2OSxjbj1BQyUyMEZOTVQlMjBVc3Vhcmlv +cyxvdT1DRVJFUyxvPUZOTVQtUkNNLGM9RVM/Y2VydGlmaWNhdGVSZXZvY2F0aW9u +TGlzdDtiaW5hcnk/YmFzZT9vYmplY3RjbGFzcz1jUkxEaXN0cmlidXRpb25Qb2lu +dIYtaHR0cDovL3d3dy5jZXJ0LmZubXQuZXMvY3Jsc2FjdXN1L0NSTFU4NjkuY3Js +MA0GCSqGSIb3DQEBCwUAA4IBAQA+YKUUF+kIW32y+vbAmSr+8aYswNC7NPV/utEq +uxX3VBGL3xLDUgkQ+5iiW7G194p93Ynnl639g070Q0SmuOPlQhgqYIMrYZbm8L1e +AlbvqLsSIeRVhPaSbHzwbmUG5hQ7hZQ4ClG2xZcOC2auPb6UOLMEpSCs2TtuYZmn +Ivh+t9YRrfFAUSLB315GHycdtrjOeEz68Dz+2UdvSmla3OO3X7XgDKqSSlul2vce +Znp+cy07trzwiwyN+lq3icNT3bgu8eXeu05kvLklADjbNq509n3KSnAWRC44i1Xg +5zXzm95KmcidynauRr6XqU0ENKHRLEWn4CltdYFkb6VtvuLC -----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/certs/facturae.p12 b/tests/certs/facturae.p12 index 5867c103b5fc3321cf4ec46f188b2e19fd0f1f2a..dd9b4245457316d2232d361ace219441c1e4af4f 100644 GIT binary patch literal 7045 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%0D%FI>S&aX zK<)%w1pALv2j4!*2wYu7LNM(5=$r>~oUz8@-ja4IfV7WrPppGpZNI0^TMG6$VWa;Q z2epmm_q2LJu!(?e5=%*`C1V_PTiPv|4s#Yf#Gny{%afZm<`?G0+7(VCMmhOpkt7>! zThdK!lTN^gN{l2v#8hrRL_gV#(*k!E76UvTyN)Cjy1U(PfiyH zLf=Ciy-$i3bMhgscDkGT)x}wFnpZjsae&A5`;jOBeJOfavaic<87q&Xu9fw%^s&0m z1Rb_heK~c@F_nui8S!d|-c+fdH9tKt2X?bKh{~_A$xMZFDseGFEp4V~y%?ZW%C7ja zqCXXasyZj%Q4*{f-bA-iyUw@~GEx|_=fg=Rls;3U!Bx0P`h1zD%r_0T;F>fZ`cgc) znj;>6wh*rbZ`YBVJ(>mSht_D&0wKw0#wXM7s0jSn(zv`TXX_^U|>>dyhDN8Jp8C~8@M(K`J zzzB<8(T7b=UG%}=QM8lihCg=Od*1PTa>V&Sl==JOBu};*dw9EXG5R%Ov#CTJMi#$9 z$r%eCz4vV|THTue}c|(`Gl-3k4Y4k{X3)ZXj&RhGv=3a5H zDCWp;B+frTk~xdHkTMbR&+grgPDQc20N1_rXsC1MYy7~TL;V9$ATKH2>zKWOL&kW@ zzO)T;?{iaa$t!H0J=}n{zgzDvJw!dS-efs{Z|@Q#6@&Lqwpj=)bgS(uS4fBZ+19(A zXw_I_Jprv2w@T`=nv7@j(94eYt$c>L?o9IdYW-)7e8PSbT)imX#xzNh*<+);#p|BG z2dtd0mgOk@9$F?Y;jdz)Q3@GLf%a76aw+DHFfHY@#=FczbIKxc>0Wd-&?V|pV=48> z2rjY{{cJVaoD>G-8L(HYfcGb&8$>w0DXALzOnA^mF@b3Kb0fX`U39!_L7Qwlv~X2% zg~!j|LBIUCRdAK)9mU_#mS{a$hv)Jrxq7@z9n>1mUf*o;xHZ?Mn3jkYuPjSFzL=eu z#gga7wZ`)TLGJlh zhM%d)cODwGjGC8Roa}vyVdD>0$yA7buX?q(kP~ zqCr8}kJleH(NvhJN_p#+q5W8acA=XA}r;gmRR z401RZTRA_lHdf(nVGtVd+w|69w5e6@*Wxqa7sffEa6#!hRTO#-vUG( z@n@oTl936BpDw9(r{lfmEWZ0gvPnWY8%-Ls9)HKgOT-7EM7RyN`Z7+jNeM@V;<-X3r9v8NJ?5gmfPCOe5gXvPH+FmVNOeP65i4!;d zJ`}x4pKPMB8I>7j!+Y{TmHfNc?R1%BfaF1;PZyOtGYIL!k7mmgp;Xa0YV=3NCHr_B zBqtakdtv2${|1~L%47CTg-tyON(;(RTX{9`!y1Vi(Vj*$$9-R9v5@#KO9gPaZmILP zjIY&3z~f(CJC&bf*>1aW=$#`XU1;Vg?EVtg-IfZ(+58tD_#k`?e3)pcTzDwRl&An8 z$)@9CjKmR+6S&+5@O2q9Js*q203=DUZ+9SB%qir`0TaX-O5CydwwM0r7wdljxvAf8{O!7?1P67E%Ei zkNRKf-zAuT8{vN)Sb+9_2E6c_*o63YL(28W6W z10X{}H3^r4wS-52WkSFjR0EfUdR5n)PXmQ4$SK@{z_@jX75;Msve$*`<9@u-OB8l=SN9H8F-WT6zRny3+!#&=wDG9Dz~t)S942VJKia# zg*lMWK7Mi#))0BN(<{DTog_8W zs&}AM$z7@6y@5i&LMv%Fb_G&Rwlb&iF#A#PQ|!Y*3hJg7A|qb)kwjII&($-=v;ZG zxMFXx8b6VOG(o9us0Ag^1X9N*C!cxgFk_A{4}YS*c8I3qkT(lVwZ($F!El=9iI897M>;oTG~@^ zsI(JWeI?sf6V-wViQxAC8X|>!Iz=I!`1l?{dHvEj{fSd>$XjjuuZ2d;j#Jtu$~^4C zKfNl+$=(OOcx1WT+J>vt2QxCn{RS9}`+m4&@g+@PW`eJ+_rBl#=pd~4#rXO#_Ekjw zLWkxa9nRW1y`63uM(F2sS3s}nz_d$y?@-5TY-ku$6Fxmgm^{x@2Wlmxw>4g*Hc7Z1 zX1xfM%s8`(3I)+iNdxl8q#bTwZH@=f9pZAhI&2kp6i7n$lydOR)lG_i*gA=jyg<4d zhJNV6#R>YoS)&-aO0##XHO<=elF-ujM_*Ptg-`RpI<+xU#eH`Ga#i@ZfS{ zr3Ez_e=J`k?o~wd*S@{a9S+9{Fg3fb$vd6Ae{xG}VUSXEQc^aA&g{3s#uldT%%368 zhIdaaHC@66sZs5QH^)BmWt8o7Ll!_)dpo$8*Ar(8a}Kf_nKM2VQPfh5LEzu!OGMaI zdxwSU(_bM$LF7{VPd8jdP2xHse5Sz6SmHfL!SPXCe)1Re%hZe|=kG>YrmLR%MU7hI zY-Z4G9RveQ%dW5Xs>onttLoMY9rNBhYH-)+C zGF>?|6(iOcp7x4v&c#hZe;^!;9d9vn00Di5b;MF`&M9i%v$nlWla#PRlTBX$ZXcZf zUF?g@%W~TLP|>;Klhg3uH`8*!X%seDp=yy3l$Zb%Pti^n79Wh=cz}2mlwd!U`@)al z6gf(Dm5?UL@<|z?jn3Z#_vlc0uF+b(j@&C7B4#!0xo%8Uz|6yeZXYU`6-W(lM0Swb zQwrr9uhvO>e4HuVxE{DN9TEULx|tmgBB`(yL6}d6|6)^Md5woam21?W;}0k&}O?h954-d>Y0?4jnF9Oi>P7=@VcLnF7?nMmB@0GH26G3A64o+iRv-DDZhXI42p)VYCKTjQc6TB+-GJv$KC0gKP4x|j1G%x zRiCG&^xF~bw;J`cxJE89}|c z#id|k=00_y?d}j%6B?$BmpPY0%yR69Z)QwOV{Ebz)H#OAu1uw+5+7!Y3Yv?d^It#& zMGyVu!?DD^-Y|a^paFbcuy{a>wJCCJ*+^C(sl)1?8ar4*o47ro1LX0Y*s^LQZ)eUS z^pOnM?9Rh|2Pr`~_@95pRf>*<&@OGrHiFG)QSg;> z4daHGFqA)i!!>|9(L57O<+NL)bavnNYVZu~YwOj6ZLk5XA3L1;bM-mVN87Y*+yU$_ zkd_t=U46J3a{e^Ej&l~_T4jo{@Ga#Ax|7CFdfi-I)uB#{EzL8jlo0+{B`96^?)u(PO~8+XE*D$xkzs=HS( zKYsiYL6KB{;ry7=4u@VFLihT&ImCy$g!`Dd^GkvFXQHFJ0W(?4?SD2121>M7>jNAy z15ea9_VgC}E=>%BZqO8BH*iK>O|KZO#t_5a*ab#-`xgRcZ}vlszb#vGQV!ysJqW2x zd@-Cj)XDo;<`v$x>yqieB@NUu&8T6SDu26QFwhqIfB|i@kVqgFq%E!Y&i|y?^3P;> zA@EF965a^nM`SKar4k9}qaL#QxF|LL>}p;0^$TYjKb^;7|gU4Yl{|O_uqIkYZ^EK8C6{29oxK6*QYFxMmn#_;qqcC=-0=; zl-81wBSJP`*{M?^qMP#Hy;ErYMF#UqZCZyzcE2xPjpoF002B++Ia&+T{q_b-x*bP-yM=$puEYja%FJ15Nw;w=;t z0&9&|)|vrm!Z$?lq^t8(5*fs=b<#D}6A_wC*uDu%^d*P8t`+q8D=#FgandMZ3p0K>B*&u4Z46&vs5so!#p@B_huH z<{;*1MoNIwTeT0*LrayRA!sp@ShpZsmtohJpaiZssf$+OFYZ^1CWAX`eE|@m%9axN z&x&dY?a2C)rb+WtoSWCcQ=_tv5OZkoGOv;{I8v(ObBkerN*k`l-%own-|KpczSnhZ z37pBlJXijFL0#lFo!EmZ`k1Tpbx#4*jqC3VB2I@VF8yV_-ajg{e8!G|C!%mTlvQM$ z!aQ+nXLX8|YDLeri@VxVq%1Rf7glzG73u8Py7Q@m=M1`8GYdc7E&@F$Dt491k+DDo zcVFSbME@M0ba_vlEUv{l^JADM)L&)3PpS}P3~s<b%0u^wN5*o@ zSBHoSUGGp2mn2{ddBaA$6`-J5)lDr}6L@Mm=#QjBMp~9E#q$bbuS|}o>anw$ zAPx6G*4N9Ga_G5$|A~XVVXP)Y=dMof(z>|3PWst}jKL>(y8G7&nMKB{e0l*<1F0&qDVNYnTY@1b zXo&NYTnWJk*n-hkxcv8WR$)vIWPQY@Dd>IR4LYpqw3c*H=oJZn!?MkF^1`qEt?B4` zF=a+=tG73gt1IDjOE6Y2p<=ZMyHyy~jLG$(tgf$#+-7xjBAhu`D}ONs?`tV{@Q zMDorsIBUTr=7r@$IIlmS{DUy=qxjujD)Cy|ZQIjO>7?sOo6MCR4$2#o*$l~aBe6RU ze;J4#zT*%2qKSu^v?_ON4++~o?s*IE7@T5^ciFcdHY)cpp`C3X-<5LM2W5Bx$$jg> zN*rK_=O*=j$2Y0be2(s{+q3fvkx+jjidF5-mv%1xh6s6J)E39G)K4<@V-Hz zu0@<=HytX8Knby60GS}pI zIsO8EZ*aehWU^+?qtHyFqj1Z(MizRv9u3Cc#m%z6ZHe9x8l1$(D1n~3{P>Nf5k<7z zFrA{9_wK7u=S6Z`MBUe?C;G6iV61Uv@+fdrdsZRT>mEwSB0HLgoGKJNU-8tQdE4N_ zooIDr&ifXPT95WO(-HHG{3Zc0Qn#buZGGz}Ol3u@p7%diA0APa=mA=YJ4M4Q*A3zI zXjgWY4nN%m@~eHKVihsJ>(C7h)>BnLaOHi(n3Ek8SU`@V?Z%U#Hj|f9xS1I^olysJ z)RN-uedB4L61J^o0@WrVj0aY!e76HtALw2Cd1qstS=K9cUe(lis;m{a$ti`aUPi?OJyGPh(oh9_?fc6I7oAetU0m?EXwp;z3ks#XG4Bd`w z>xlYYal^gvw-zRnG9Dt-tP)gZ(H5*$(o?vO@PC;3@NuzIaxG?&O`edv{+vCW|CuMH zJnBL-g!S~AjGLny6cz{U{~oAlZ}@ZSHPOt{_Y2NeG6|W1X4-{@o7vdb5BGX0yPPMd z9Pi6&KYCNy=;JlkRSaEw!=k|0Kj5Ggx`sjWARCNIGN+%{Yjgul$>=z@$jzJ7v_{(p z;HKq*;7i)lQ0hon{8u7|9p-H-ycF!0cmamF93uH3d}UvvDiyYo-Xs%Ei8V4y-rHO} zrSSsa2Cz*WHcqmu4RBY#lXZfI zK7VlatJwpGXrFXW#COyfZ#J?yj_>4N!r`@n9Rvc78n;sp1w3H?i31hCr9a|Xd^c&x zlvRia$ytC;Zzos+66H@*Fw}&Y*)S`u{FWH>`xSk~tD_F=F2FCY62GQ7a-Y^W?SDb@ zfMI>VNqK9yC?Fx{vFsA}EF>iojxpklqr!K5Z1OvqkZUv3RR@)xXbEF9Zj3QLK4g%B z08#@r4Ii5Tg2P-Zye5fJ$$J&*=Qa0^QztwFS12Q+rtX{~3F<3z{WdC=^e$JFwaaJg zy#8mIm%W+N7m`IQ9ub%KK;?!_dIpz0{&5gRb?f8#A9XD|(t)>ExY^0$T)3TfYLz-K z10|Liu6?<6Qf**SrL2VC2I1LKnaS@oY%>vRQz?qt`-3lxo`B6ekE@kI_0XkP&DF*!XyKN2lYXzFc{z6AIqDT9{vHVnCa9=j%b z7I7C-lL<1dE#f$Aj=UHEQcC**1D$Q95&TPL$$k2J5vfX(s~_E#{z$zDiOhF^Pf2SN zT>3^;pctMdKk09&r2lB$fD~2r6*DV=r8r&xVNj`)obSVO2f7 zLs)2~=Zc6UjXC|5F&Uh+e5WjQ96}6IJ@Jxz`pMXhd+AZEwDaMqjS($m5Yj_JA?+Vi zfKPNH=oE3tvaYeL_%3P$LY91)$~{%8-h`2BszX@)wHHM@u)wxQX~L5l;G;uvRJa7( zdI1L59mh43XmlZj?%9>m`lMYIMnk~hYG&wkQmWgtQXhQPI!xmouE`Q_=&uP2`gTk2X>&l;*wZazu~EuKt@uMS9?y(c`Q`~`&BUG-A)(~=Zf zFaeR8vfh+A6vHSG6+E9VhJ;**TTy7DgF*_-cwvDiJuGiXCcMaRwbAOj(BqJi zeCP&Oq|AwAZ;KyaMsT0#qwa`Sx7iVf$GhiilPv9a)uwZhoU8ATG7YTdjC^;F<-hN{ zO6$&?k&X^Gi3dW+qL0^xqDI)qtYY7k6+yHGfbG7{&kOA_i?c07g97o3cOZ%~5sRMW zDwBF6mAOW^HmXm(B z{`&x@Bq9T*MXA`xb=n$j^5qITJ)y8K`pY3m(AQ&C@A$58F)rP%_Kf zhd0IJtafxJe{;TKmmlw#@r)5W{NI?vLGZ-gI>TK#n~!fI4nQ&>f3vCVqovhW){c?5 zSIBr)yNCN7#11>!`7ai3Dun+S^qkIuhkWajy2|pd->t+rQn~4s?ftSWud2N}Q>A$9 zA^A8UnkRRUsF**jb#I%IpOcPL%~NK1>}+V`AEMd6K+$xhTzq45{(<-0N02vWlh~)Y zmdbA)PAMree_ByUs;eVs0o$9G>#>1kLH0wGhgM=uo}EW6?&?KLLKU=l@_lY4KoMjy zM_4Lu#}p;@jd37UDdZ?o(YE`c@W_2(L} zMY5ycJNwy~DC+eiV$i7UTDja7$nDKsZtmpPr}1@t6eO@|>EAJNDs5CoW8KDxi~N<# zI-wkLKEi(}Z4?3B%31jR&c1EnI^_5)WWbGwGoVjgsk*^UY0$7%Ee~_zRd=Ff_AoKW zq=5zr+6qg+kx>|y-{dH55TC)*{N+9F9Jod|rJc*FBr5xx=mriJtnk75?3~KInZF=| z207DLL{1=y-ZdHXiYYBkQ0re}9@y2wnv?H9qQNe!*UXJ7T5Atclej*7>oXQF|B4(l zKK$2FR7brl*&~xn1b@>twxqKlNOoPYW$424=%ZG8iR=EKk#)O8|mL1wu{> z^FK-*itjaT_}wTi?$*S_&g$gw_&w-z9wrZWztH9uODaMLC|;ynbaExikM7rra~reU zs3_0y^4H#zb4XW_D>r&gI< zh;S#*>Yr5C;eeP^iefu*OrKr9K^%C!OC(x6wb$cqarbc{9R}p< diff --git a/tests/certs/webservices.p12 b/tests/certs/webservices.p12 deleted file mode 100644 index 53c7051f3f8b7a47dfbfba7c39ea5d36777b454f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5178 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 Date: Sat, 11 May 2024 20:11:33 +0200 Subject: [PATCH 131/146] =?UTF-8?q?Documentados=20m=C3=A1s=20tipos=20de=20?= =?UTF-8?q?PHPDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizadas clases FacturaeExtension, Fb2bExtension - Actualizadas clases FacturaeCentre, FacturaeItem y FacturaeParty - Actualizado trait UtilsTrait > Closes #156 --- src/Extensions/FacturaeExtension.php | 1 + src/Extensions/Fb2bExtension.php | 4 ++ src/FacturaeCentre.php | 11 +++++ src/FacturaeItem.php | 22 +++++++++ src/FacturaeParty.php | 69 ++++++++++++++++++++++++---- src/FacturaeTraits/UtilsTrait.php | 1 + 6 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/Extensions/FacturaeExtension.php b/src/Extensions/FacturaeExtension.php index 73ab121..29f1c7b 100644 --- a/src/Extensions/FacturaeExtension.php +++ b/src/Extensions/FacturaeExtension.php @@ -4,6 +4,7 @@ use josemmo\Facturae\Facturae; abstract class FacturaeExtension { + /** @var Facturae */ private $fac; /** diff --git a/src/Extensions/Fb2bExtension.php b/src/Extensions/Fb2bExtension.php index d4331d6..864485e 100644 --- a/src/Extensions/Fb2bExtension.php +++ b/src/Extensions/Fb2bExtension.php @@ -4,9 +4,13 @@ use josemmo\Facturae\FacturaeCentre; class Fb2bExtension extends FacturaeExtension { + /** @var array */ private $publicSectorInfo = array(); + /** @var FacturaeCentre|null */ private $receiver = null; + /** @var FacturaeCentre[] */ private $sellerCentres = array(); + /** @var FacturaeCentre[] */ private $buyerCentres = array(); /** diff --git a/src/FacturaeCentre.php b/src/FacturaeCentre.php index d9e7a67..a2f2ec0 100644 --- a/src/FacturaeCentre.php +++ b/src/FacturaeCentre.php @@ -25,18 +25,29 @@ class FacturaeCentre { const ROLE_B2B_COLLECTION_RECEIVER = "Collection receiver"; const ROLE_B2B_ISSUER = "Issuer"; + /** @var string|null */ public $code = null; + /** @var string|null */ public $role = null; + /** @var string|null */ public $name = null; + /** @var string|null */ public $firstSurname = null; + /** @var string|null */ public $lastSurname = null; + /** @var string|null */ public $description = null; + /** @var string|null */ public $address = null; + /** @var string|null */ public $postCode = null; + /** @var string|null */ public $town = null; + /** @var string|null */ public $province = null; + /** @var string */ public $countryCode = "ESP"; diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 18077c1..5e8b252 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -12,32 +12,54 @@ class FacturaeItem { /** Non-subject operation */ const SPECIAL_TAXABLE_EVENT_NON_SUBJECT = "02"; + /** @var int|string|null */ private $articleCode = null; + /** @var string|null */ private $name = null; + /** @var string|null */ private $description = null; + /** @var int|float */ private $quantity = 1; + /** @var string */ private $unitOfMeasure = Facturae::UNIT_DEFAULT; + /** @var int|float|null */ private $unitPrice = null; + /** @var int|float|null */ private $unitPriceWithoutTax = null; private $discounts = array(); private $charges = array(); private $taxesOutputs = array(); private $taxesWithheld = array(); + /** @var string|null */ private $specialTaxableEventCode = null; + /** @var string|null */ private $specialTaxableEventReason = null; + /** @var string|null */ private $issuerContractReference = null; + /** @var string|null */ private $issuerContractDate = null; + /** @var string|null */ private $issuerTransactionReference = null; + /** @var string|null */ private $issuerTransactionDate = null; + /** @var string|null */ private $receiverContractReference = null; + /** @var string|null */ private $receiverContractDate = null; + /** @var string|null */ private $receiverTransactionReference = null; + /** @var string|null */ private $receiverTransactionDate = null; + /** @var string|null */ private $fileReference = null; + /** @var string|null */ private $fileDate = null; + /** @var string|null */ private $sequenceNumber = null; + /** @var string|null */ private $periodStart = null; + /** @var string|null */ private $periodEnd = null; diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 863f12d..2f3fc16 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -16,38 +16,87 @@ class FacturaeParty { 'IRL', 'ITA', 'LTU', 'LUX', 'LVA', 'MLT', 'NLD', 'POL', 'PRT', 'ROU', 'SVK', 'SVN', 'SWE' ]; + /** @var boolean */ public $isLegalEntity = true; // By default is a company and not a person + /** @var string|null */ public $taxNumber = null; + /** @var string|null */ public $name = null; - // This block is only used for legal entities - public $book = null; // "Libro" - public $registerOfCompaniesLocation = null; // "Registro mercantil" - public $sheet = null; // "Hoja" - public $folio = null; // "Folio" - public $section = null; // "Sección" - public $volume = null; // "Tomo" + /** + * Libro (only for legal entities) + * @var string|null + */ + public $book = null; + /** + * Registro mercantil (only for legal entities) + * @var string|null + */ + public $registerOfCompaniesLocation = null; + /** + * Hoja (only for legal entities) + * @var string|null + */ + public $sheet = null; + /** + * Folio (only for legal entities) + * @var string|null + */ + public $folio = null; + /** + * Sección (only for legal entities) + * @var string|null + */ + public $section = null; + /** + * Tomo (only for legal entities) + * @var string|null + */ + public $volume = null; - // This block is only required for individuals + /** + * First surname (required for individuals) + * @var string|null + */ public $firstSurname = null; + /** + * Last surname (required for individuals) + * @var string|null + */ public $lastSurname = null; + /** @var string|null */ public $address = null; + /** @var string|null */ public $postCode = null; + /** @var string|null */ public $town = null; + /** @var string|null */ public $province = null; + /** @var string */ public $countryCode = "ESP"; - /** @var boolean|null */ - public $isEuropeanUnionResident = null; // By default is calculated based on the country code + /** + * NOTE: By default (when `null`) is calculated based on the country code + * @var boolean|null + */ + public $isEuropeanUnionResident = null; + /** @var string|null */ public $email = null; + /** @var string|null */ public $phone = null; + /** @var string|null */ public $fax = null; + /** @var string|null */ public $website = null; + /** @var string|null */ public $contactPeople = null; + /** @var string|null */ public $cnoCnae = null; + /** @var string|null */ public $ineTownCode = null; + /** @var FacturaeCentre[] */ public $centres = array(); diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php index 02dea57..b94551d 100644 --- a/src/FacturaeTraits/UtilsTrait.php +++ b/src/FacturaeTraits/UtilsTrait.php @@ -10,6 +10,7 @@ * @var Facturae $this */ trait UtilsTrait { + /** @var FacturaeExtension[] */ protected $extensions = array(); /** From 6a6f9f57759b091d01032cce017cd44fecfa9997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 11 May 2024 20:14:05 +0200 Subject: [PATCH 132/146] v1.8.0 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 10ec12d..e3401cb 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.9"; + const VERSION = "1.8.0"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From bd81c7026c489f93b6807901669888aaf0aa39b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 29 May 2024 20:28:30 +0200 Subject: [PATCH 133/146] Arreglados campos hexadecimales en OpenSSL 1.x.x - Actualizada clase FacturaeSigner - Actualizada clase XmlTools - Actualizados tests unitarios > Fixes #160 --- src/Common/FacturaeSigner.php | 39 +------------------------ src/Common/XmlTools.php | 54 ++++++++++++++++++++++++++++++++++ tests/XmlToolsTest.php | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 38 deletions(-) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index b279e31..ba512db 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -13,27 +13,6 @@ final class FacturaeSigner { const SIGN_POLICY_NAME = 'Política de Firma FacturaE v3.1'; const SIGN_POLICY_URL = 'http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf'; const SIGN_POLICY_DIGEST = 'Ohixl6upD6av8N7pEvDABhEL6hM='; - const ALLOWED_OID_TYPES = [ - // Mandatory fields in https://datatracker.ietf.org/doc/html/rfc4514#section-3 - 'CN' => 'CN', - 'L' => 'L', - 'ST' => 'ST', - 'O' => 'O', - 'OU' => 'OU', - 'C' => 'C', - 'STREET' => 'STREET', - 'DC' => 'DC', - 'UID' => 'UID', - - // Other fields with well-known names - 'GN' => 'GN', - 'SN' => 'SN', - - // Other fields with compatibility issues - 'organizationIdentifier' => 'OID.2.5.4.97', - 'serialNumber' => 'OID.2.5.4.5', - 'title' => 'OID.2.5.4.12', - ]; use KeyPairReaderTrait; @@ -174,22 +153,6 @@ public function sign($xml) { // Build element $signingTime = ($this->signingTime === null) ? time() : $this->signingTime; $certData = openssl_x509_parse($this->publicChain[0]); - $certIssuer = []; - foreach ($certData['issuer'] as $rawType=>$rawValues) { - $values = is_array($rawValues) ? $rawValues : [$rawValues]; - foreach ($values as $value) { - if ($rawType === "UNDEF" && preg_match('/^VAT[A-Z]{2}-/', $value) === 1) { - $type = "OID.2.5.4.97"; // Fix for OpenSSL <3.0.0 - } else { - if (!array_key_exists($rawType, self::ALLOWED_OID_TYPES)) { - continue; // Skip unknown OID types - } - $type = self::ALLOWED_OID_TYPES[$rawType]; - } - $certIssuer[] = "$type=$value"; - } - } - $certIssuer = implode(', ', array_reverse($certIssuer)); $xadesSignedProperties = '' . '' . '' . date('c', $signingTime) . '' . @@ -200,7 +163,7 @@ public function sign($xml) { '' . XmlTools::getCertDigest($this->publicChain[0]) . '' . '' . '' . - '' . $certIssuer . '' . + '' . XmlTools::getCertDistinguishedName($certData['issuer']) . '' . '' . $certData['serialNumber'] . '' . '' . '' . diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php index d8f46e3..90c0f90 100644 --- a/src/Common/XmlTools.php +++ b/src/Common/XmlTools.php @@ -2,6 +2,27 @@ namespace josemmo\Facturae\Common; class XmlTools { + const ALLOWED_OID_TYPES = [ + // Mandatory fields in https://datatracker.ietf.org/doc/html/rfc4514#section-3 + 'CN' => 'CN', + 'L' => 'L', + 'ST' => 'ST', + 'O' => 'O', + 'OU' => 'OU', + 'C' => 'C', + 'STREET' => 'STREET', + 'DC' => 'DC', + 'UID' => 'UID', + + // Other fields with well-known names + 'GN' => 'GN', + 'SN' => 'SN', + + // Other fields with compatibility issues + 'organizationIdentifier' => 'OID.2.5.4.97', + 'serialNumber' => 'OID.2.5.4.5', + 'title' => 'OID.2.5.4.12', + ]; /** * Escape XML value @@ -164,6 +185,39 @@ public static function getCertDigest($publicKey, $pretty=false) { } + /** + * Get certificate distinguished name + * @param array $data Certificate issuer or subject name data + * @return string Distinguished name + */ + public static function getCertDistinguishedName($data) { + $name = []; + foreach ($data as $rawType=>$rawValues) { + $values = is_array($rawValues) ? $rawValues : [$rawValues]; + foreach ($values as $value) { + // Default case: allowed OID type + if (array_key_exists($rawType, self::ALLOWED_OID_TYPES)) { + $type = self::ALLOWED_OID_TYPES[$rawType]; + $name[] = "$type=$value"; + continue; + } + + // Fix for undefined properties in OpenSSL <3.0.0 + if ($rawType === "UNDEF") { + $decodedValue = (substr($value, 0, 1) === '#') ? hex2bin(substr($value, 5)) : $value; + if (preg_match('/^VAT[A-Z]{2}-/', $decodedValue) === 1) { + $name[] = "OID.2.5.4.97=$value"; + } + } + + // Unknown OID type, ignore + } + } + $name = implode(', ', array_reverse($name)); + return $name; + } + + /** * Get signature in SHA-512 * @param string $payload Data to sign diff --git a/tests/XmlToolsTest.php b/tests/XmlToolsTest.php index 1235c00..9db3bce 100644 --- a/tests/XmlToolsTest.php +++ b/tests/XmlToolsTest.php @@ -45,4 +45,59 @@ public function testCanCanonicalizeXml() { $this->assertEquals('', $c14n); } + + public function testCanGenerateDistinguishedNames() { + $this->assertEquals( + 'CN=EIDAS CERTIFICADO PRUEBAS - 99999999R, SN=EIDAS CERTIFICADO, GN=PRUEBAS, OID.2.5.4.5=IDCES-99999999R, C=ES', + XmlTools::getCertDistinguishedName([ + 'C' => 'ES', + 'serialNumber' => 'IDCES-99999999R', + 'GN' => 'PRUEBAS', + 'SN' => 'EIDAS CERTIFICADO', + 'CN' => 'EIDAS CERTIFICADO PRUEBAS - 99999999R' + ]) + ); + $this->assertEquals( + 'OID.2.5.4.97=VATFR-12345678901, CN=A Common Name, OU=Field, OU=Repeated, C=FR', + XmlTools::getCertDistinguishedName([ + 'C' => 'FR', + 'OU' => ['Repeated', 'Field'], + 'CN' => 'A Common Name', + 'ignoreMe' => 'This should not be here', + 'organizationIdentifier' => 'VATFR-12345678901', + ]) + ); + $this->assertEquals( + 'OID.2.5.4.97=VATES-A11223344, CN=ACME ROOT, OU=ACME-CA, O=ACME Inc., L=Barcelona, C=ES', + XmlTools::getCertDistinguishedName([ + 'C' => 'ES', + 'L' => 'Barcelona', + 'O' => 'ACME Inc.', + 'OU' => 'ACME-CA', + 'CN' => 'ACME ROOT', + 'UNDEF' => 'VATES-A11223344' + ]) + ); + $this->assertEquals( + 'OID.2.5.4.97=#0c0f56415445532d413030303030303030, CN=Common Name (UTF-8), OU=Unit, O=Organization, C=ES', + XmlTools::getCertDistinguishedName([ + 'C' => 'ES', + 'O' => 'Organization', + 'OU' => 'Unit', + 'CN' => 'Common Name (UTF-8)', + 'UNDEF' => '#0c0f56415445532d413030303030303030' + ]) + ); + $this->assertEquals( + 'OID.2.5.4.97=#130f56415445532d413636373231343939, CN=Common Name (printable), OU=Unit, O=Organization, C=ES', + XmlTools::getCertDistinguishedName([ + 'C' => 'ES', + 'O' => 'Organization', + 'OU' => 'Unit', + 'CN' => 'Common Name (printable)', + 'UNDEF' => '#130f56415445532d413636373231343939' + ]) + ); + } + } From f0026a07936ec99904def318d822979ae7e9ba5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 16 Jun 2024 12:07:41 +0200 Subject: [PATCH 134/146] v1.8.1 --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index e3401cb..3186aad 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.8.0"; + const VERSION = "1.8.1"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From f2daacc82f190d9ad71a0747718a15f847b63459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 3 Aug 2024 16:27:17 +0200 Subject: [PATCH 135/146] =?UTF-8?q?A=C3=B1adido=20autoloader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creado autoload.php - Actualizada documentación > Fixes #162 --- autoload.php | 21 +++++++++++++++++++++ doc/ejemplos/sin-composer.md | 14 +++----------- 2 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 autoload.php diff --git a/autoload.php b/autoload.php new file mode 100644 index 0000000..3bec9dd --- /dev/null +++ b/autoload.php @@ -0,0 +1,21 @@ + Date: Sat, 23 Nov 2024 19:01:46 +0100 Subject: [PATCH 136/146] =?UTF-8?q?A=C3=B1adido=20nombre=20comercial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeParty - Actualizada documentación - Actualizados tests unitarios Co-Authored-By: Eduardo G. <358397+egrueda@users.noreply.github.com> --- doc/entidades/compradores-y-vendedores.md | 1 + src/FacturaeParty.php | 11 +++++++---- tests/InvoiceTest.php | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/entidades/compradores-y-vendedores.md b/doc/entidades/compradores-y-vendedores.md index 5beb798..6a13118 100644 --- a/doc/entidades/compradores-y-vendedores.md +++ b/doc/entidades/compradores-y-vendedores.md @@ -12,6 +12,7 @@ $empresa = new FacturaeParty([ "isLegalEntity" => true, // Se asume true si se omite "taxNumber" => "A00000000", "name" => "Perico el de los Palotes S.A.", + "tradeName" => "Peri Co.", // Nombre comercial "address" => "C/ Falsa, 123", "postCode" => "12345", "town" => "Madrid", diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 2f3fc16..4e79682 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -22,6 +22,8 @@ class FacturaeParty { public $taxNumber = null; /** @var string|null */ public $name = null; + /** @var string|null */ + public $tradeName = null; /** * Libro (only for legal entities) @@ -182,14 +184,15 @@ public function getXML($includeAdministrativeCentres) { // Add data exclusive to `LegalEntity` if ($this->isLegalEntity) { $xml .= '' . XmlTools::escape($this->name) . ''; - $fields = array("book", "registerOfCompaniesLocation", "sheet", "folio", - "section", "volume"); + if (!empty($this->tradeName)) { + $xml .= '' . XmlTools::escape($this->tradeName) . ''; + } - $nonEmptyFields = array(); + $fields = ["book", "registerOfCompaniesLocation", "sheet", "folio", "section", "volume"]; + $nonEmptyFields = []; foreach ($fields as $fieldName) { if (!empty($this->{$fieldName})) $nonEmptyFields[] = $fieldName; } - if (count($nonEmptyFields) > 0) { $xml .= ''; foreach ($nonEmptyFields as $fieldName) { diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 5b6cfb8..25b9a22 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -44,6 +44,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { $fac->setSeller(new FacturaeParty([ "taxNumber" => "A00000000", "name" => "Perico el de los Palotes S.A.", + "tradeName" => "Peri Co.", "address" => "C/ Falsa, 123", "postCode" => "23456", "town" => "Madrid", From 563c77368f6c0e4f3350bcec9681284fc4d56f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 23 Nov 2024 19:05:17 +0100 Subject: [PATCH 137/146] Actualizados enlaces a validadores - Actualizado validacion.md --- doc/anexos/validacion.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/anexos/validacion.md b/doc/anexos/validacion.md index a7ab583..16b0df0 100644 --- a/doc/anexos/validacion.md +++ b/doc/anexos/validacion.md @@ -9,6 +9,6 @@ permalink: /anexos/validacion.html Todas las facturas generadas y firmadas con Facturae-PHP son probadas con las siguientes herramientas online para garantizar el cumplimiento del estándar: - [https://viewer.facturadirecta.com/](https://viewer.facturadirecta.com/) (no soporta 3.2.2) -- [http://plataforma.firma-e.com/VisualizadorFacturae/](http://plataforma.firma-e.com/VisualizadorFacturae/) (no soporta 3.2.2) -- [http://sedeaplicaciones2.minetur.gob.es/FacturaE/index.jsp](http://sedeaplicaciones2.minetur.gob.es/FacturaE/index.jsp) +- [https://plataforma.firma-e.com/VisualizadorFacturae/](https://plataforma.firma-e.com/VisualizadorFacturae/) - [https://face.gob.es/es/facturas/validar-visualizar-facturas](https://face.gob.es/es/facturas/validar-visualizar-facturas) +- [https://sedeaplicaciones2.minetur.gob.es/FacturaE/index.jsp](https://sedeaplicaciones2.minetur.gob.es/FacturaE/index.jsp) (ya no existe) From 8f97b7e00410fae7040c291fe6fd59e74425c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 23 Nov 2024 19:07:08 +0100 Subject: [PATCH 138/146] Actualizadas versiones de PHP - Actualizado tests.yml workflow --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e27dee..ee5bed7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,12 +14,12 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] include: - - php-version: '8.3' + - php-version: '8.4' test-ws: true send-coverage: true - - php-version: '8.4' + - php-version: '8.5' experimental: true steps: # Download code from repository From 5412dd017a079a56472ba91bed556e4a069ff295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 23 Nov 2024 19:12:46 +0100 Subject: [PATCH 139/146] v1.8.2 [skip ci] --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 3186aad..2e1e800 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.8.1"; + const VERSION = "1.8.2"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; From c0bc067143bb0998ccf09fa04719d38ba77cb8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 7 Dec 2024 17:22:57 +0100 Subject: [PATCH 140/146] =?UTF-8?q?Normalizados=20saltos=20de=20l=C3=ADnea?= =?UTF-8?q?=20al=20firmar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeSigner - Actualizados tests unitarios > Relacionado con #175 --- src/Common/FacturaeSigner.php | 4 ++++ tests/SignerTest.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php index ba512db..8615bbc 100644 --- a/src/Common/FacturaeSigner.php +++ b/src/Common/FacturaeSigner.php @@ -129,6 +129,10 @@ public function sign($xml) { throw new RuntimeException('Invalid signing key material: failed to read private key'); } + // Normalize line breaks + $xml = str_replace("\r\n", "\n", $xml); + $xml = str_replace("\r", "\n", $xml); + // Extract root element $openTagPosition = mb_strpos($xml, 'This contains\r\nWindows line breaks\n" . + " This contains\rclassic MacOS line breaks\n" . + ''; + $signedXml = $this->getSigner()->sign($xml); + $this->assertStringNotContainsString("\r", $signedXml); + } + + public function testCannotTimestampWithoutTsaDetails() { $this->expectException(RuntimeException::class); $signer = new FacturaeSigner(); From 036137961914a0095009f6e184fc0d00a331d54f Mon Sep 17 00:00:00 2001 From: smagret Date: Fri, 7 Mar 2025 17:32:40 +0100 Subject: [PATCH 141/146] =?UTF-8?q?A=C3=B1adir=20AdditionalReasonDescripti?= =?UTF-8?q?on=20y=20InvoiceIssueDate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade los campos AdditionalReasonDescription y InvoiceIssueDate para las facturas rectificativas --- src/CorrectiveDetails.php | 13 +++++++++++++ src/FacturaeTraits/ExportableTrait.php | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/src/CorrectiveDetails.php b/src/CorrectiveDetails.php index d5a99f5..842ed17 100644 --- a/src/CorrectiveDetails.php +++ b/src/CorrectiveDetails.php @@ -61,6 +61,19 @@ class CorrectiveDetails { */ public $correctionMethodDescription = null; + /** + * Free text to describe the reason why the invoice is corrected. + * @var string|null + */ + public $additionalReasonDescription = null; + + /** + * Date on which the corrective invoice is issued. (as UNIX timestamp or parsable date string) + * Mandatory where "CorrectionMethod" takes the * value "01" or "02" + * @var string|int|null + */ + public $invoiceIssueDate = null; + /** * Class constructor * @param array $properties Corrective details properties as an array diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 1f07184..8527c28 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -123,6 +123,13 @@ public function export($filePath=null) { $xml .= '' . XmlTools::escape($corrective->getCorrectionMethodDescription()) . ''; + if ($corrective->additionalReasonDescription !== null) { + $xml .= ''.XmlTools::escape($corrective->additionalReasonDescription).''; + } + if ($corrective->invoiceIssueDate !== null) { + $invoiceIssueDate = is_string($corrective->invoiceIssueDate) ? strtotime($corrective->invoiceIssueDate) : $corrective->invoiceIssueDate; + $xml .= ''.date('Y-m-d', $invoiceIssueDate).''; + } $xml .= ''; } $xml .= ''; From 8b1bd7ce44705d8792b2156c58cd0496b263161f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 13 Apr 2025 11:40:04 +0200 Subject: [PATCH 142/146] =?UTF-8?q?Actualizada=20documentaci=C3=B3n=20y=20?= =?UTF-8?q?tests=20unitarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modificado propiedades/rectificativas.md - Modificada clase CorrectiveDetails - Linteado trait ExportableTrait - Modificado InvoiceTest --- doc/propiedades/rectificativas.md | 6 +++++- src/CorrectiveDetails.php | 8 +++++--- src/FacturaeTraits/ExportableTrait.php | 6 ++++-- tests/InvoiceTest.php | 14 ++++++++------ 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/doc/propiedades/rectificativas.md b/doc/propiedades/rectificativas.md index 8811a0e..e6936b8 100644 --- a/doc/propiedades/rectificativas.md +++ b/doc/propiedades/rectificativas.md @@ -10,15 +10,19 @@ Por defecto, todos los documentos generados con la librería son facturas origin **rectificativa** se deben añadir una serie de propiedades adicionales a través del método `$fac->setCorrective()`: ```php $fac->setCorrective(new CorrectiveDetails([ - // Serie y número de la factura a rectificar + // Serie, número y fecha de la factura a rectificar "invoiceSeriesCode" => "EMP201712", "invoiceNumber" => "0002", + "invoiceIssueDate" => "2017-10-03", // Desde schema v3.2.2 // Código del motivo de la rectificación según: // - RD 1496/2003 (del "01" al 16") // - Art. 80 Ley 37/92 (del "80" al "85") "reason" => "01", + // Aclaraciones opcionales del motivo de rectificación + "additionalReasonDescription" => "Una aclaración", + // Periodo de tributación de la factura a rectificar "taxPeriodStart" => "2017-10-01", "taxPeriodEnd" => "2017-10-31", diff --git a/src/CorrectiveDetails.php b/src/CorrectiveDetails.php index 842ed17..d036643 100644 --- a/src/CorrectiveDetails.php +++ b/src/CorrectiveDetails.php @@ -62,14 +62,16 @@ class CorrectiveDetails { public $correctionMethodDescription = null; /** - * Free text to describe the reason why the invoice is corrected. + * Free text to describe the reason why the invoice is corrected * @var string|null */ public $additionalReasonDescription = null; /** - * Date on which the corrective invoice is issued. (as UNIX timestamp or parsable date string) - * Mandatory where "CorrectionMethod" takes the * value "01" or "02" + * Issue date for corrected invoice (as UNIX timestamp or parsable date string) + * + * NOTE: Mandatory when correction method is {@see CorrectiveDetails::METHOD_FULL} or {@see CorrectiveDetails::METHOD_DIFFERENCES}. + * * @var string|int|null */ public $invoiceIssueDate = null; diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php index 8527c28..d9796be 100644 --- a/src/FacturaeTraits/ExportableTrait.php +++ b/src/FacturaeTraits/ExportableTrait.php @@ -124,11 +124,13 @@ public function export($filePath=null) { XmlTools::escape($corrective->getCorrectionMethodDescription()) . ''; if ($corrective->additionalReasonDescription !== null) { - $xml .= ''.XmlTools::escape($corrective->additionalReasonDescription).''; + $xml .= '' . + XmlTools::escape($corrective->additionalReasonDescription) . + ''; } if ($corrective->invoiceIssueDate !== null) { $invoiceIssueDate = is_string($corrective->invoiceIssueDate) ? strtotime($corrective->invoiceIssueDate) : $corrective->invoiceIssueDate; - $xml .= ''.date('Y-m-d', $invoiceIssueDate).''; + $xml .= '' . date('Y-m-d', $invoiceIssueDate) . ''; } $xml .= ''; } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 25b9a22..47caa4c 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -100,12 +100,14 @@ public function testCreateInvoice($schemaVersion, $isPfx) { // Creamos una factura rectificativa (solo en algunos casos) if (!$isPfx) { $fac->setCorrective(new CorrectiveDetails([ - "invoiceSeriesCode" => "EMP201712", - "invoiceNumber" => "0002", - "reason" => "03", - "taxPeriodStart" => "2017-10-01", - "taxPeriodEnd" => "2017-10-31", - "correctionMethod" => CorrectiveDetails::METHOD_DIFFERENCES + "invoiceSeriesCode" => "EMP201712", + "invoiceNumber" => "0002", + "invoiceIssueDate" => ($schemaVersion === Facturae::SCHEMA_3_2_2) ? "2017-10-03" : null, + "reason" => "03", + "additionalReasonDescription" => "Una aclaración de texto libre", + "taxPeriodStart" => "2017-10-01", + "taxPeriodEnd" => "2017-10-31", + "correctionMethod" => CorrectiveDetails::METHOD_DIFFERENCES ])); } From e719134af236d1dbcad527dd0478d6b610ef0d4e Mon Sep 17 00:00:00 2001 From: FabioIYT <37196600+FabioIYT@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:04:01 +0200 Subject: [PATCH 143/146] Update PropertiesTrait.php fix format Tax tupe. change Int to int|string value '01' --- src/FacturaeTraits/PropertiesTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index 5702480..0d76b09 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -708,7 +708,7 @@ public function clearAttachments() { * @param FacturaeItem|string|array $desc Item to add or description * @param float $unitPrice Price per unit, taxes included * @param float $quantity Quantity - * @param int $taxType Tax type + * @param string $taxType Tax type * @param float $taxRate Tax rate * @return Facturae Invoice instance */ From 532b7cc40f93a84ae584131057260563d09a0216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 22 Jun 2025 10:13:42 +0200 Subject: [PATCH 144/146] =?UTF-8?q?A=C3=B1adido=20campo=20para=20otros=20d?= =?UTF-8?q?atos=20registrales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizada clase FacturaeParty - Actualizados tests unitarios > Closes #194 --- src/FacturaeParty.php | 7 ++++++- tests/InvoiceTest.php | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php index 4e79682..b58e9f8 100644 --- a/src/FacturaeParty.php +++ b/src/FacturaeParty.php @@ -55,6 +55,11 @@ class FacturaeParty { * @var string|null */ public $volume = null; + /** + * Otros datos registrales (only for legal entities) + * @var string|null + */ + public $additionalRegistrationData = null; /** * First surname (required for individuals) @@ -188,7 +193,7 @@ public function getXML($includeAdministrativeCentres) { $xml .= '' . XmlTools::escape($this->tradeName) . ''; } - $fields = ["book", "registerOfCompaniesLocation", "sheet", "folio", "section", "volume"]; + $fields = ["book", "registerOfCompaniesLocation", "sheet", "folio", "section", "volume", "additionalRegistrationData"]; $nonEmptyFields = []; foreach ($fields as $fieldName) { if (!empty($this->{$fieldName})) $nonEmptyFields[] = $fieldName; diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 47caa4c..94e40bf 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -52,6 +52,7 @@ public function testCreateInvoice($schemaVersion, $isPfx) { "book" => "0", "sheet" => "1", "merchantRegister" => "RG", + "additionalRegistrationData" => "xxxxx", "phone" => "910112233", "fax" => "910112234", "email" => "noexiste@ejemplo.com", From 5ac37e1c9d95f06f0ad9636ae0f5a7bd4e68622a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 22 Jun 2025 10:26:14 +0200 Subject: [PATCH 145/146] =?UTF-8?q?A=C3=B1adido=20disclaimer=20de=20VERI*F?= =?UTF-8?q?ACTU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualizado README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4778656..842fdc0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ Facturae-PHP es un paquete escrito puramente en PHP que permite generar facturas electrónicas siguiendo el formato estructurado [Facturae](http://www.facturae.gob.es/), **añadirlas firma electrónica** XAdES y sellado de tiempo, e incluso **enviarlas a FACe o FACeB2B** sin necesidad de ninguna librería o clase adicional. +> [!NOTE] +> Esta librería **no** genera registros de facturación de VERI*FACTU. Para ello, consulta [Verifactu-PHP](https://github.com/josemmo/Verifactu-PHP). + En apenas 25 líneas de código y con un tiempo de ejecución inferior a 0,4 µs es posible generar, firmar y exportar una factura electrónica totalmente válida: ```php From 8ae6c7af67a3c98d19602b4e966e71c2e52d6724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 22 Jun 2025 10:28:29 +0200 Subject: [PATCH 146/146] v1.8.3 [skip ci] --- src/Facturae.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facturae.php b/src/Facturae.php index 2e1e800..23681b6 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.8.2"; + const VERSION = "1.8.3"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2";