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 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/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/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 > ``` --- 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/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; } 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"; 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()); } }