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/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..ee5bed7
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,47 @@
+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:
+ fail-fast: false
+ matrix:
+ 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.4'
+ test-ws: true
+ send-coverage: true
+ - php-version: '8.5'
+ experimental: true
+ steps:
+ # Download code from repository
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # 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: composer install && vendor/bin/simple-phpunit --testdox
+
+ # 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 9eed56a..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,36 +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
- env: SEND_COVERAGE=1 TEST_WEBSERVICES=1
- - php: 7.2
- - php: 7.3
- - 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:
- - php vendor/bin/phpunit --coverage-clover build/coverage/xml
-
-# Send coverage to Codacy (just once per build)
-after_success:
- - if [ "$SEND_COVERAGE" == "1" ]; then php vendor/bin/codacycoverage clover build/coverage/xml; fi
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
diff --git a/README.md b/README.md
index 73203fd..842fdc0 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,16 @@
# Facturae-PHP
-[](https://travis-ci.org/josemmo/Facturae-PHP)
-[](https://www.codacy.com/app/josemmo/Facturae-PHP)
-[](https://www.codacy.com/app/josemmo/Facturae-PHP)
-[](https://packagist.org/packages/josemmo/facturae-php)
-[](https://packagist.org/packages/josemmo/facturae-php)
+[](https://github.com/josemmo/Facturae-PHP/actions)
+[](https://www.codacy.com/gh/josemmo/Facturae-PHP)
+[](https://www.codacy.com/gh/josemmo/Facturae-PHP)
+[](https://packagist.org/packages/josemmo/facturae-php)
+[](composer.json)
[](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.
+> [!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
@@ -41,6 +44,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->loadPkcs12("certificado.pfx", "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)
@@ -54,5 +68,13 @@ $fac->export("mi-factura.xsig");
- 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:
+
+
+
+
+
+
## 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.
diff --git a/autoload.php b/autoload.php
new file mode 100644
index 0000000..3bec9dd
--- /dev/null
+++ b/autoload.php
@@ -0,0 +1,21 @@
+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
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 f49faa2..d18d8f8 100644
--- a/doc/ejemplos/sin-composer.md
+++ b/doc/ejemplos/sin-composer.md
@@ -8,11 +8,10 @@ permalink: /ejemplos/sin-composer.html
# Uso sin Composer
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.
+Para ello, se incluye el script "autoload.php" en el directorio raíz, que permite auto-cargar las clases de la librería.
+
```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';
-require_once 'ruta/hacia/Facturae-PHP/src/FacturaeParty.php';
+require_once 'ruta/hacia/Facturae-PHP/autoload.php'; // <-- Autoloader incluido con la librería
use josemmo\Facturae\Facturae;
use josemmo\Facturae\FacturaeParty;
@@ -37,8 +36,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/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,
diff --git a/doc/entidades/cesionarios.md b/doc/entidades/cesionarios.md
new file mode 100644
index 0000000..4a9b4d4
--- /dev/null
+++ b/doc/entidades/cesionarios.md
@@ -0,0 +1,35 @@
+---
+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->addPayment(new FacturaePayment([
+ "method" => FacturaePayment::TYPE_TRANSFER,
+ "dueDate" => "2017-12-31",
+ "iban" => "ES7620770024003102575766",
+ "bic" => "CAHMESMM"
+]));
+```
diff --git a/doc/entidades/compradores-y-vendedores.md b/doc/entidades/compradores-y-vendedores.md
index fff8e53..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",
@@ -28,7 +29,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 +49,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/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/entidades/terceros.md b/doc/entidades/terceros.md
new file mode 100644
index 0000000..7f94209
--- /dev/null
+++ b/doc/entidades/terceros.md
@@ -0,0 +1,26 @@
+---
+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"
+]));
+```
+
+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/doc/envio-y-recepcion/face.md b/doc/envio-y-recepcion/face.md
index ea64f7c..20add0e 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);
@@ -38,6 +41,28 @@ 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");
+```
+
+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
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/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/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() {
diff --git a/doc/firma-electronica/firma-avanzada.md b/doc/firma-electronica/firma-avanzada.md
new file mode 100644
index 0000000..df9b3bb
--- /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->loadPkcs12("certificado.pfx", "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);
+```
diff --git a/doc/firma-electronica/firma-electronica.md b/doc/firma-electronica/firma-electronica.md
index a4f9438..a545cbc 100644
--- a/doc/firma-electronica/firma-electronica.md
+++ b/doc/firma-electronica/firma-electronica.md
@@ -9,34 +9,64 @@ 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 tienen las clave pública y 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ámetro los bytes del banco PKCS#12:
+```php
+$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á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");
```
+Este método de firma no añade la cadena completa de certificados a la factura y, por tanto, no se recomienda.
+
+> #### NOTA
+> Los siguientes comandos permiten extraer el certificado (clave pública) y la clave privada de un archivo PFX:
+>
+> ```
+> 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
+> ```
+
---
## 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/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/doc/productos/multiples-impuestos.md b/doc/productos/impuestos.md
similarity index 58%
rename from doc/productos/multiples-impuestos.md
rename to doc/productos/impuestos.md
index 8b846c4..66c890a 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,42 @@ $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
+ ]
+]));
+```
+
+## 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/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/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"
+]));
+```
---
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 €
diff --git a/doc/propiedades/rectificativas.md b/doc/propiedades/rectificativas.md
new file mode 100644
index 0000000..e6936b8
--- /dev/null
+++ b/doc/propiedades/rectificativas.md
@@ -0,0 +1,60 @@
+---
+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, 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",
+
+ // 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/doc/propiedades/suplidos.md b/doc/propiedades/suplidos.md
new file mode 100644
index 0000000..4b8b045
--- /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", "countryCode" => "PRT"]),
+ "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/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/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/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php
new file mode 100644
index 0000000..8615bbc
--- /dev/null
+++ b/src/Common/FacturaeSigner.php
@@ -0,0 +1,377 @@
+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();
+ $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();
+ return $this;
+ }
+
+
+ /**
+ * Set signing time
+ * @param int|string $time Time of the signature as UNIX timestamp or parsable date
+ * @return self This instance
+ */
+ public function setSigningTime($time) {
+ $this->signingTime = is_string($time) ? strtotime($time) : $time;
+ return $this;
+ }
+
+
+ /**
+ * Can sign
+ * @return boolean Whether instance is ready to sign XML documents
+ */
+ public function canSign() {
+ return !empty($this->publicChain) && !empty($this->privateKey);
+ }
+
+
+ /**
+ * 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;
+ }
+
+
+ /**
+ * 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
+ * @return string Signed XML document
+ * @throws RuntimeException if failed to sign document
+ */
+ public function sign($xml) {
+ // Validate signing key material
+ if (empty($this->publicChain)) {
+ throw new RuntimeException('Invalid signing key material: chain of certificates is empty');
+ }
+ if (empty($this->privateKey)) {
+ 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, ' 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 = XmlTools::injectNamespaces($xmlRoot, [
+ 'xmlns:ds' => self::XMLNS_DS
+ ]);
+
+ // Build list of all namespaces for C14N
+ $xmlns = XmlTools::getNamespaces($xmlRoot);
+ $xmlns['xmlns:xades'] = self::XMLNS_XADES;
+
+ // Build element
+ $signingTime = ($this->signingTime === null) ? time() : $this->signingTime;
+ $certData = openssl_x509_parse($this->publicChain[0]);
+ $xadesSignedProperties = '' .
+ '' .
+ '' . date('c', $signingTime) . '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' . XmlTools::getCertDigest($this->publicChain[0]) . '' .
+ '' .
+ '' .
+ '' . XmlTools::getCertDistinguishedName($certData['issuer']) . '' .
+ '' . $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($this->privateKey);
+ $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";
+ }
+ $dsKeyInfo .= '' . "\n" .
+ '' . "\n" .
+ '' . "\n" .
+ '' . "\n" . $modulus . '' . "\n" .
+ '' . $exponent . '' . "\n" .
+ '' . "\n" .
+ '' . "\n" .
+ '';
+
+ // Build element
+ $dsSignedInfo = '' . "\n" .
+ '' .
+ '' . "\n" .
+ '' .
+ '' . "\n" .
+ 'signatureSignedPropertiesId . '">' . "\n" .
+ '' .
+ '' . "\n" .
+ '' .
+ XmlTools::getDigest(XmlTools::injectNamespaces($xadesSignedProperties, $xmlns)) .
+ '' . "\n" .
+ '' . "\n" .
+ '' . "\n" .
+ '' .
+ '' . "\n" .
+ '' .
+ XmlTools::getDigest(XmlTools::injectNamespaces($dsKeyInfo, $xmlns)) .
+ '' . "\n" .
+ '' . "\n" .
+ '' . "\n" .
+ '' . "\n" .
+ '' .
+ '' . "\n" .
+ '' . "\n" .
+ '' .
+ '' . "\n" .
+ '' . XmlTools::getDigest(XmlTools::c14n($xmlRoot)) . '' . "\n" .
+ '' . "\n" .
+ '';
+
+ // Build element
+ $dsSignature = '' . "\n" .
+ $dsSignedInfo . "\n" .
+ '' . "\n" .
+ XmlTools::getSignature(XmlTools::injectNamespaces($dsSignedInfo, $xmlns), $this->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) {
+ // 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 = XmlTools::getNamespaces($xmlRoot);
+ $xmlns['xmlns:xades'] = self::XMLNS_XADES;
+ $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";
+ $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 = XmlTools::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;
+ }
+}
diff --git a/src/Common/KeyPairReader.php b/src/Common/KeyPairReader.php
deleted file mode 100644
index 7606155..0000000
--- a/src/Common/KeyPairReader.php
+++ /dev/null
@@ -1,76 +0,0 @@
-publicKey;
- }
-
-
- /**
- * Get private key
- * @return string 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 $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);
- }
-
-
- /**
- * 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;
- $this->publicKey = openssl_x509_read(file_get_contents($publicPath));
- $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 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']);
- }
- }
-
-}
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/Common/XmlTools.php b/src/Common/XmlTools.php
index 3f2abdc..90c0f90 100644
--- a/src/Common/XmlTools.php
+++ b/src/Common/XmlTools.php
@@ -2,13 +2,34 @@
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
* @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,40 +42,85 @@ 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);
}
+ /**
+ * Get namespaces from root element
+ * @param string $xml XML document
+ * @return array Namespaces in the form of
+ */
+ public static function getNamespaces($xml) {
+ $namespaces = [];
+
+ // Extract element opening tag
+ $xml = explode('>', $xml, 2);
+ $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;
+ }
+
+
/**
* 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 static function injectNamespaces($xml, $namespaces) {
+ $xml = explode('>', $xml, 2);
+
+ // Get element name (in the form of "$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 static function c14n($xml) {
+ $xml = str_replace("\r", '', $xml);
+ $xml = preg_replace_callback('//', function($match) {
+ return self::escape($match[1]);
+ }, $xml);
+ $xml = preg_replace('/<([0-9a-z:]+?) ?\/>/i', '<$1>$1>', $xml);
return $xml;
}
@@ -65,71 +131,103 @@ public function injectNamespaces($xml, $newNs) {
* @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;
}
/**
* Prettify
* @param string $input Input string
- * @return string Multi-line resposne
+ * @return string Multi-line response
*/
- private function prettify($input) {
+ private static function prettify($input) {
return chunk_split($input, 76, "\n");
}
/**
- * 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);
+ public static function getDigest($input, $pretty=false) {
+ return self::toBase64(hash("sha512", $input, true), $pretty);
}
/**
* 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 static function getCert($pem, $pretty=true) {
+ $pem = str_replace("-----BEGIN CERTIFICATE-----", "", $pem);
+ $pem = str_replace("-----END CERTIFICATE-----", "", $pem);
+ $pem = str_replace(["\r", "\n"], ['', ''], $pem);
+ if ($pretty) $pem = self::prettify($pem);
+ return $pem;
}
/**
- * 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);
- return $this->toBase64($digest, $pretty);
+ public static function getCertDigest($publicKey, $pretty=false) {
+ $digest = openssl_x509_fingerprint($publicKey, "sha512", true);
+ return self::toBase64($digest, $pretty);
+ }
+
+
+ /**
+ * 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
+ * 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);
- return $this->toBase64($signature, $pretty);
+ public static function getSignature($payload, $privateKey, $pretty=true) {
+ openssl_sign($payload, $signature, $privateKey, OPENSSL_ALGO_SHA512);
+ return self::toBase64($signature, $pretty);
}
}
diff --git a/src/CorrectiveDetails.php b/src/CorrectiveDetails.php
new file mode 100644
index 0000000..d036643
--- /dev/null
+++ b/src/CorrectiveDetails.php
@@ -0,0 +1,172 @@
+$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/Extensions/FacturaeExtension.php b/src/Extensions/FacturaeExtension.php
index c50267b..29f1c7b 100644
--- a/src/Extensions/FacturaeExtension.php
+++ b/src/Extensions/FacturaeExtension.php
@@ -1,7 +1,10 @@
*/
private $publicSectorInfo = array();
+ /** @var FacturaeCentre|null */
private $receiver = null;
+ /** @var FacturaeCentre[] */
private $sellerCentres = array();
+ /** @var FacturaeCentre[] */
private $buyerCentres = array();
/**
diff --git a/src/Face/CustomFaceClient.php b/src/Face/CustomFaceClient.php
new file mode 100644
index 0000000..6500c9c
--- /dev/null
+++ b/src/Face/CustomFaceClient.php
@@ -0,0 +1,50 @@
+endpointUrl = $endpointUrl;
+ }
+
+
+ /**
+ * 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
+ */
+ 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 301bb24..f68ca1a 100644
--- a/src/Face/SoapClient.php
+++ b/src/Face/SoapClient.php
@@ -2,30 +2,39 @@
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 $useExcC14n = true;
- private $publicKey;
- private $privateKey;
- protected $production = true;
-
+ use KeyPairReaderTrait;
/**
* 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 \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($storeOrCertificate, $privateKey=null, $passphrase='') {
+ if ($privateKey === null) {
+ $this->loadPkcs12($storeOrCertificate, $passphrase);
+ } else {
+ $this->addCertificate($storeOrCertificate);
+ $this->setPrivateKey($privateKey, $passphrase);
+ }
+ }
+
+
+ /**
+ * Set exclusive canonicalization mode
+ * @param boolean $enabled Whether to use EXC-C14N (`true`) or C14N (`false`)
*/
- 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 setExclusiveC14n($enabled) {
+ $this->useExcC14n = $enabled;
}
@@ -43,52 +52,32 @@ 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
- * @return SimpleXMLElement Response
+ * @param string $body Request body
+ * @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();
-
- // 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"'
- );
+ $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 = [
+ '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 . '';
- $bodyDigest = $tools->getDigest($tools->injectNamespaces($reqBody, $ns));
+ $bodyDigest = XmlTools::getDigest(XmlTools::injectNamespaces($reqBody, $ns));
// Generate timestamp
$timeCreated = time();
@@ -97,8 +86,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
@@ -108,30 +97,32 @@ 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->publicChain[0], false) .
'';
// 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 = '' .
- '' .
+ '' .
'' .
- '' .
+ '' .
'' .
- '' .
+ '' .
'' . $timestampDigest . '' .
'' .
'' .
- '' .
+ '' .
'' . $bodyDigest . '' .
'' .
'';
- $signedInfoPayload = $tools->injectNamespaces($signedInfo, $ns);
+ $signedInfoPayload = XmlTools::injectNamespaces($signedInfo, $signedInfoNs);
// Add signature and KeyInfo to header
$reqHeader .= '' .
$signedInfo .
'' .
- $tools->getSignature($signedInfoPayload, $this->privateKey, false) .
+ XmlTools::getSignature($signedInfoPayload, $this->privateKey, false) .
'';
$reqHeader .= '' .
'' .
@@ -149,9 +140,13 @@ 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 ""
+ $soapAction = substr($body, 5, strpos($body, '>')-5);
+ $soapAction = $this->getWebNamespace() . "#$soapAction";
+
// Send request
$ch = curl_init();
curl_setopt_array($ch, array(
@@ -161,11 +156,15 @@ 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);
curl_close($ch);
+ unset($ch);
// Parse response
$xml = new \DOMDocument();
diff --git a/src/Face/Traits/FaceTrait.php b/src/Face/Traits/FaceTrait.php
new file mode 100644
index 0000000..774ed5a
--- /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()) {
+ $req = '';
+ $req .= '' . $email . '';
+ $req .= '' .
+ '' . XmlTools::toBase64($invoice->getData()) . '' .
+ '' . $invoice->getFilename() . '' .
+ 'application/xml' . // Mandatory MIME type
+ '';
+ $req .= '';
+ foreach ($attachments as $file) {
+ $req .= '' .
+ '' . XmlTools::toBase64($file->getData()) . '' .
+ '' . $file->getFilename() . '' .
+ '' . $file->getMimeType() . '' .
+ '';
+ }
+ $req .= '';
+ $req .= '';
+ return $this->request($req);
+ }
+
+
+ /**
+ * Cancel invoice
+ * @param string $regId Invoice register ID
+ * @param string $reason Cancellation 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..37084b5
--- /dev/null
+++ b/src/Face/Traits/Faceb2bTrait.php
@@ -0,0 +1,217 @@
+';
+
+ $req .= '' .
+ '' . XmlTools::toBase64($invoice->getData()) . '' .
+ '' . $invoice->getFilename() . '' .
+ 'text/xml' . // Mandatory MIME type
+ '';
+
+ if (!is_null($attachment)) {
+ $req .= '' .
+ '' . XmlTools::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|null $comment Optional comments
+ * @return SimpleXMLElement Response
+ */
+ public function requestInvoiceCancellation($regId, $reason, $comment=null) {
+ $req = '';
+ $req .= '' . $regId . '';
+ $req .= '' . $reason . '';
+ if (!is_null($comment)) {
+ $req .= '' . $comment . '';
+ }
+ $req .= '';
+ return $this->request($req);
+ }
+
+
+ /**
+ * Get registered invoices
+ * @param string|null $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|null $comment Optional comments
+ * @return SimpleXMLElement Response
+ */
+ public function rejectInvoice($regId, $reason, $comment=null) {
+ $req = '';
+ $req .= '' . $regId . '';
+ $req .= '' . $reason . '';
+ if (!is_null($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 Comment
+ * @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) {
+ $req = '';
+ $req .= '' . $regId . '';
+ $req .= '' .
+ '' . XmlTools::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;
+ }
+}
diff --git a/src/Facturae.php b/src/Facturae.php
index 0c15b42..23681b6 100644
--- a/src/Facturae.php
+++ b/src/Facturae.php
@@ -10,36 +10,66 @@
* Class for creating electronic invoices that comply with the Spanish FacturaE format.
*/
class Facturae {
- const VERSION = "1.5.0";
+ const VERSION = "1.8.3";
const USER_AGENT = "FacturaePHP/" . self::VERSION;
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",
"digest" => "Ohixl6upD6av8N7pEvDABhEL6hM="
);
+ 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;
+
+ /** @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. */
const PAYMENT_DEBIT = "02";
+ /** @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. */
const PAYMENT_TRANSFER = "04";
+ /** @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. */
const PAYMENT_DOCUMENTARY_CREDIT = "06";
+ /** @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. */
const PAYMENT_BILL_OF_EXCHANGE = "08";
+ /** @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. */
const PAYMENT_IOU = "10";
+ /** @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. */
const PAYMENT_REIMBURSEMENT = "12";
+ /** @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. */
const PAYMENT_SETOFF = "14";
+ /** @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. */
const PAYMENT_CERTIFIED_CHEQUE = "16";
+ /** @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. */
const PAYMENT_CASH_ON_DELIVERY = "18";
+ /** @deprecated 1.7.3 Use constants from {@see FacturaePayment} class instead. */
const PAYMENT_CARD = "19";
const TAX_IVA = "01";
@@ -115,24 +145,22 @@ 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],
+ 'Tax/TaxRate' => ['min'=>2, 'max'=>8],
+ 'DiscountCharge/Rate' => ['min'=>2, 'max'=>8],
+ 'Item/Quantity' => ['min'=>0, 'max'=>8],
+ 'Item/UnitPriceWithoutTax' => ['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/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 5205c5b..5e8b252 100644
--- a/src/FacturaeItem.php
+++ b/src/FacturaeItem.php
@@ -7,30 +7,60 @@
* 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";
+ /** @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;
/**
@@ -47,10 +77,14 @@ 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);
}
+ if (!isset($tax['surcharge'])) {
+ $tax['surcharge'] = 0;
+ }
if ($tax['isWithheld']) {
$this->taxesWithheld[$r] = $tax;
} else {
@@ -65,14 +99,14 @@ 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;
}
}
// 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;
@@ -100,44 +134,30 @@ 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');
- $grossAmount = $totalAmountWithoutTax;
+ $quantity = $this->quantity;
+ $unitPriceWithoutTax = $this->unitPriceWithoutTax;
+ $totalAmountWithoutTax = $quantity * $unitPriceWithoutTax;
- // 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;
- }
- }
+ // 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 = $fac->pad($group['rate'], 'Discount/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, 'Discount/Amount');
+ $amount = $fac->pad($amount, 'DiscountCharge/Amount', Facturae::PRECISION_LINE);
$addProps[$groupTag][] = array(
"reason" => $group['reason'],
"rate" => $rate,
@@ -147,13 +167,38 @@ 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/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" => $taxableBase,
+ "rate" => $taxRate,
+ "surcharge" => $surcharge,
+ "amount" => $taxAmount,
+ "surchargeAmount" => $surchargeAmount
+ );
+ if ($i == 1) { // In case of $taxesWithheld (2nd iteration)
+ $totalTaxesWithheld += $taxAmount + $surchargeAmount;
+ } else {
+ $totalTaxesOutputs += $taxAmount + $surchargeAmount;
+ }
+ }
+ }
+
// Add rest of properties
- $addProps['quantity'] = $quantity;
- $addProps['unitPriceWithoutTax'] = $unitPriceWithoutTax;
- $addProps['totalAmountWithoutTax'] = $totalAmountWithoutTax;
- $addProps['grossAmount'] = $fac->pad($grossAmount, 'Item/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/FacturaeParty.php b/src/FacturaeParty.php
index a50f019..b58e9f8 100644
--- a/src/FacturaeParty.php
+++ b/src/FacturaeParty.php
@@ -11,36 +11,99 @@
*/
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'
+ ];
+
+ /** @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;
+ /** @var string|null */
+ public $tradeName = 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;
+ /**
+ * Otros datos registrales (only for legal entities)
+ * @var string|null
+ */
+ public $additionalRegistrationData = 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";
+ /**
+ * 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();
@@ -50,9 +113,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($this->merchantRegister)) {
- $this->registerOfCompaniesLocation = $this->merchantRegister;
+ foreach ($properties as $key=>$value) {
+ if ($key === "merchantRegister") $key = "registerOfCompaniesLocation";
+ $this->{$key} = $value;
}
}
@@ -60,32 +123,30 @@ 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) {
- $tools = new XmlTools();
-
+ public function getXML($includeAdministrativeCentres) {
// Add tax identification
$xml = '' .
'' . ($this->isLegalEntity ? 'J' : 'F') . '' .
- 'R' .
- '' . $tools->escape($this->taxNumber) . '' .
+ '' . $this->getResidenceTypeCode() . '' .
+ '' . XmlTools::escape($this->taxNumber) . '' .
'';
// Add administrative centres
- if (count($this->centres) > 0) {
+ if ($includeAdministrativeCentres && count($this->centres) > 0) {
$xml .= '';
foreach ($this->centres as $centre) {
$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
@@ -96,16 +157,26 @@ 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 .= '' .
+ '' . XmlTools::escape($addressTarget->address) . '' .
+ '' . $addressTarget->postCode . '' .
+ '' . XmlTools::escape($addressTarget->town) . '' .
+ '' . XmlTools::escape($addressTarget->province) . '' .
+ '' . $addressTarget->countryCode . '' .
+ '';
+ } else {
+ $xml .= '' .
+ '' . 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 .= '';
}
@@ -117,15 +188,16 @@ public function getXML($schema) {
// Add data exclusive to `LegalEntity`
if ($this->isLegalEntity) {
- $xml .= '' . $tools->escape($this->name) . '';
- $fields = array("book", "registerOfCompaniesLocation", "sheet", "folio",
- "section", "volume");
+ $xml .= '' . XmlTools::escape($this->name) . '';
+ if (!empty($this->tradeName)) {
+ $xml .= '' . XmlTools::escape($this->tradeName) . '';
+ }
- $nonEmptyFields = array();
+ $fields = ["book", "registerOfCompaniesLocation", "sheet", "folio", "section", "volume", "additionalRegistrationData"];
+ $nonEmptyFields = [];
foreach ($fields as $fieldName) {
if (!empty($this->{$fieldName})) $nonEmptyFields[] = $fieldName;
}
-
if (count($nonEmptyFields) > 0) {
$xml .= '';
foreach ($nonEmptyFields as $fieldName) {
@@ -138,19 +210,30 @@ 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
- $xml .= '' .
- '' . $tools->escape($this->address) . '' .
- '' . $this->postCode . '' .
- '' . $tools->escape($this->town) . '' .
- '' . $tools->escape($this->province) . '' .
- '' . $this->countryCode . '' .
- '';
+ if ($this->countryCode === "ESP") {
+ $xml .= '' .
+ '' . XmlTools::escape($this->address) . '' .
+ '' . $this->postCode . '' .
+ '' . XmlTools::escape($this->town) . '' .
+ '' . XmlTools::escape($this->province) . '' .
+ '' . $this->countryCode . '' .
+ '';
+ } else {
+ $xml .= '' .
+ '' . XmlTools::escape($this->address) . '' .
+ '' . $this->postCode . ' ' . XmlTools::escape($this->town) . '' .
+ '' . XmlTools::escape($this->province) . '' .
+ '' . $this->countryCode . '' .
+ '';
+ }
+ // Add contact details
+ $xml .= $this->getContactDetailsXML();
// Close custom block
$xml .= ($this->isLegalEntity) ? '' : '';
@@ -159,4 +242,81 @@ public function getXML($schema) {
return $xml;
}
+
+ /**
+ * 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
+ *
+ * @return string Contact details XML
+ */
+ private function getContactDetailsXML() {
+ $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>" . XmlTools::escape($value) . "$xmlName>";
+ }
+ }
+ $xml .= '';
+
+ return $xml;
+ }
+
+
+ /**
+ * Get item XML for reimbursable expense node
+ *
+ * @return string Reimbursable expense XML
+ */
+ public function getReimbursableExpenseXML() {
+ $xml = '' . ($this->isLegalEntity ? 'J' : 'F') . '';
+ $xml .= '' . $this->getResidenceTypeCode() . '';
+ $xml .= '' . XmlTools::escape($this->taxNumber) . '';
+ return $xml;
+ }
+
}
+
diff --git a/src/FacturaePayment.php b/src/FacturaePayment.php
new file mode 100644
index 0000000..ef4047a
--- /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 0ec4e81..d9796be 100644
--- a/src/FacturaeTraits/ExportableTrait.php
+++ b/src/FacturaeTraits/ExportableTrait.php
@@ -2,27 +2,30 @@
namespace josemmo\Facturae\FacturaeTraits;
use josemmo\Facturae\Common\XmlTools;
+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
*/
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]) . "$xmlTag>";
+ $res .= "<$xmlTag>" . XmlTools::escape($item[$key]) . "$xmlTag>";
}
}
return $res;
@@ -36,53 +39,102 @@ 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();
// Prepare document
- $xml = 'version] . '">';
+ $xml = '';
$totals = $this->getTotals();
+ $corrective = $this->getCorrective();
+ $paymentDetailsXML = $this->getPaymentDetailsXML($totals);
// Add header
- $batchIdentifier = $this->parties['seller']->taxNumber .
- $this->header['number'] . $this->header['serie'];
- $xml .= '' .
- '' . $this->version .'' .
- 'I' .
- 'EM' .
- '' .
- '' . $batchIdentifier . '' .
- '1' .
- '' .
- '' . $totals['invoiceAmount'] . '' .
- '' .
- '' .
- '' . $totals['invoiceAmount'] . '' .
- '' .
- '' .
- '' . $totals['invoiceAmount'] . '' .
- '' .
- '' . $this->currency . '' .
- '' .
- '';
+ $batchIdentifier = $this->parties['seller']->taxNumber . $this->header['number'] . $this->header['serie'];
+ $xml .= '';
+ $xml .= '' . $this->version .'';
+ $xml .= 'I';
+ $xml .= '' . $this->header['issuerType'] . '';
+ 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(false) . '';
+ $xml .= $paymentDetailsXML;
+ if (!is_null($this->header['assignmentClauses'])) {
+ $xml .= '' .
+ XmlTools::escape($this->header['assignmentClauses']) .
+ '';
+ }
+ $xml .= '';
+ }
+
+ // Close header
+ $xml .= '';
// 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
$xml .= '';
- $xml .= '' .
- '' . $this->header['number'] . '' .
- '' . $this->header['serie'] . '' .
- 'FC' .
- '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()) .
+ '';
+ 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 .= '';
$xml .= '';
$xml .= '' . date('Y-m-d', $this->header['issueDate']) . '';
if (!is_null($this->header['startDate'])) {
@@ -108,17 +160,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/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/EquivalenceSurcharge') . '' .
+ '' .
+ '' . $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') . '' .
+ '';
+ }
+ $xml .= '';
}
}
$xml .= "$xmlTag>";
@@ -126,7 +184,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(
@@ -139,24 +197,61 @@ public function export($filePath=null) {
$xml .= '<' . $generalGroups[$g][0] . '>';
foreach ($totals[$groupTag] as $elem) {
$xml .= "<$xmlTag>";
- $xml .= "<${xmlTag}Reason>" . $tools->escape($elem['reason']) . "${xmlTag}Reason>";
+ $xml .= "<{$xmlTag}Reason>" . XmlTools::escape($elem['reason']) . "{$xmlTag}Reason>";
if (!is_null($elem['rate'])) {
- $xml .= "<${xmlTag}Rate>" . $elem['rate'] . "${xmlTag}Rate>";
+ $xml .= "<{$xmlTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . "{$xmlTag}Rate>";
}
- $xml .="<${xmlTag}Amount>" . $elem['amount'] . "${xmlTag}Amount>";
+ $xml .="<{$xmlTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . "{$xmlTag}Amount>";
$xml .= "$xmlTag>";
}
$xml .= '' . $generalGroups[$g][0] . '>';
}
- $xml .= '' . $totals['totalGeneralDiscounts'] . '';
- $xml .= '' . $totals['totalGeneralCharges'] . '';
- $xml .= '' . $totals['grossAmountBeforeTaxes'] . '';
- $xml .= '' . $totals['totalTaxesOutputs'] . '';
- $xml .= '' . $totals['totalTaxesWithheld'] . '';
- $xml .= '' . $totals['invoiceAmount'] . '';
- $xml .= '' . $totals['invoiceAmount'] . '';
- $xml .= '' . $totals['invoiceAmount'] . '';
+ // 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') . '';
+
+ // 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
@@ -175,11 +270,11 @@ public function export($filePath=null) {
]);
// Add required fields
- $xml .= '' . $tools->escape($item['name']) . '' .
- '' . $item['quantity'] . '' .
+ $xml .= '' . XmlTools::escape($item['name']) . '' .
+ '' . $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(
@@ -192,18 +287,18 @@ public function export($filePath=null) {
$xml .= '<' . $itemGroups[$g][0] . '>';
foreach ($item[$group] as $elem) {
$xml .= "<$groupTag>";
- $xml .= "<${groupTag}Reason>" . $tools->escape($elem['reason']) . "${groupTag}Reason>";
+ $xml .= "<{$groupTag}Reason>" . XmlTools::escape($elem['reason']) . "{$groupTag}Reason>";
if (!is_null($elem['rate'])) {
- $xml .= "<${groupTag}Rate>" . $elem['rate'] . "${groupTag}Rate>";
+ $xml .= "<{$groupTag}Rate>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . "{$groupTag}Rate>";
}
- $xml .="<${groupTag}Amount>" . $elem['amount'] . "${groupTag}Amount>";
+ $xml .="<{$groupTag}Amount>" . $this->pad($elem['amount'], 'DiscountCharge/Amount') . "{$groupTag}Amount>";
$xml .= "$groupTag>";
}
$xml .= '' . $itemGroups[$g][0] . '>';
}
// 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.
@@ -216,23 +311,43 @@ 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/EquivalenceSurcharge') . '' .
+ '' .
+ '' .
+ $this->pad($tax['surchargeAmount'], 'Tax/EquivalenceSurchargeAmount') .
+ '' .
+ '';
+ }
+ $xml .= '';
}
$xml .= "$xmlTag>";
}
+ // Add line period dates
+ if (!empty($item['periodStart']) && !empty($item['periodEnd'])) {
+ $xml .= '';
+ $xml .= '' . XmlTools::escape($item['periodStart']) . '';
+ $xml .= '' . XmlTools::escape($item['periodEnd']) . '';
+ $xml .= '';
+ }
+
// 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 .= '';
@@ -240,57 +355,26 @@ 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 .= "$accountType>";
- }
- $xml .= '';
- $xml .= '';
- }
+ $xml .= $paymentDetailsXML;
// Add legal literals
if (count($this->legalLiterals) > 0) {
$xml .= '';
foreach ($this->legalLiterals as $reference) {
- $xml .= '' . $tools->escape($reference) . '';
+ $xml .= '' . XmlTools::escape($reference) . '';
}
$xml .= '';
}
// Add additional data
- $extensionsXML = array();
- foreach ($this->extensions as $ext) {
- $extXML = $ext->__getAdditionalData();
- if (!empty($extXML)) $extensionsXML[] = $extXML;
- }
- if (count($extensionsXML) > 0) {
- $xml .= '';
- $xml .= implode("", $extensionsXML);
- $xml .= '';
- }
+ $xml .= $this->getAdditionalDataXML();
// Close invoice and document
$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
@@ -301,4 +385,91 @@ public function export($filePath=null) {
return $xml;
}
+
+ /**
+ * Get payment details XML
+ * @param array $totals Invoice totals
+ * @return string Payment details XML, empty string if not available
+ */
+ private function getPaymentDetailsXML($totals) {
+ if (empty($this->payments)) return "";
+
+ $xml = '';
+ /** @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 .= "$accountType>";
+ }
+ $xml .= '';
+ }
+ $xml .= '';
+
+ 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
+ $xml = '';
+ if (!empty($relInvoice)) $xml .= '' . XmlTools::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 .= '' . XmlTools::escape($type) . '';
+ $xml .= 'BASE64';
+ $xml .= '' . XmlTools::escape($att['description']) . '';
+ $xml .= '' . base64_encode($att['file']->getData()) . '';
+ $xml .= '';
+ }
+ $xml .= '';
+ }
+
+ // Add additional information
+ if (!empty($additionalInfo)) {
+ $xml .= '' . XmlTools::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 212143e..0d76b09 100644
--- a/src/FacturaeTraits/PropertiesTrait.php
+++ b/src/FacturaeTraits/PropertiesTrait.php
@@ -1,40 +1,59 @@
self::INVOICE_FULL,
+ "issuerType" => self::ISSUER_SELLER,
"serie" => null,
"number" => null,
"issueDate" => null,
- "dueDate" => null,
"startDate" => null,
"endDate" => null,
- "paymentMethod" => null,
- "paymentIBAN" => null,
- "paymentBIC" => null,
+ "assignmentClauses" => null,
"description" => null,
"receiverTransactionReference" => null,
"fileReference" => null,
- "receiverContractReference" => null
+ "receiverContractReference" => null,
+ "relatedInvoice" => null,
+ "additionalInformation" => null
);
protected $parties = array(
+ "thirdParty" => null,
+ "assignee" => null,
"seller" => null,
"buyer" => null
);
+ /** @var CorrectiveDetails|null */
+ protected $corrective = null;
+ /** @var ReimbursableExpense[] */
+ protected $reimbursableExpenses = array();
protected $items = array();
protected $legalLiterals = array();
protected $discounts = array();
protected $charges = array();
+ protected $attachments = array();
+ /** @var FacturaePayment[] */
+ protected $payments = array();
/**
@@ -66,6 +85,87 @@ 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 third party
+ * @param FacturaeParty $assignee Third party information
+ * @return Facturae Invoice instance
+ */
+ public function setThirdParty($thirdParty) {
+ $this->parties['thirdParty'] = $thirdParty;
+ $this->setIssuerType(self::ISSUER_THIRD_PARTY);
+ 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
+ * @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
@@ -106,6 +206,66 @@ 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
+ * @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 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
@@ -155,9 +315,14 @@ public function getIssueDate() {
* Set due date
* @param int|string $date Due date
* @return Facturae Invoice instance
+ * @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) {
- $this->header['dueDate'] = is_string($date) ? strtotime($date) : $date;
+ if (empty($this->payments)) {
+ $this->payments[] = new FacturaePayment();
+ }
+ $this->payments[0]->dueDate = $date;
return $this;
}
@@ -165,9 +330,13 @@ public function setDueDate($date) {
/**
* Get due date
* @return int|null Due timestamp
+ * @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() {
- return $this->header['dueDate'];
+ return empty($this->payments) ?
+ null :
+ (is_string($this->payments[0]->dueDate) ? strtotime($this->payments[0]->dueDate) : $this->payments[0]->dueDate);
}
@@ -204,6 +373,8 @@ public function getBillingPeriod() {
* @param int|string $issueDate Issue date
* @param int|string $dueDate Due date
* @return Facturae Invoice instance
+ * @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) {
$this->setIssueDate($issueDate);
@@ -212,22 +383,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.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=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;
}
@@ -235,27 +426,33 @@ public function setPaymentMethod($method=self::PAYMENT_CASH, $iban=null, $bic=nu
/**
* Get payment method
* @return string|null Payment method
+ * @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() {
- 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.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() {
- 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.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() {
- return $this->header['paymentBIC'];
+ return empty($this->payments) ? null : $this->payments[0]->bic;
}
@@ -400,6 +597,7 @@ public function addCharge($reason, $value, $isPercentage=true) {
"rate" => $isPercentage ? $value : null,
"amount" => $isPercentage ? null : $value
);
+ return $this;
}
@@ -422,6 +620,86 @@ public function clearCharges() {
}
+ /**
+ * Set related invoice
+ * @param string $relatedInvoice Related invoice number
+ * @return Facturae Invoice instance
+ */
+ public function setRelatedInvoice($relatedInvoice) {
+ $this->header['relatedInvoice'] = $relatedInvoice;
+ return $this;
+ }
+
+
+ /**
+ * 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
+ );
+ return $this;
+ }
+
+
+ /**
+ * 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
@@ -430,7 +708,7 @@ public function clearCharges() {
* @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
*/
@@ -470,6 +748,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
@@ -486,13 +794,42 @@ public function getTotals() {
"totalGeneralDiscounts" => 0,
"totalGeneralCharges" => 0,
"totalTaxesOutputs" => 0,
- "totalTaxesWithheld" => 0
+ "totalTaxesWithheld" => 0,
+ "totalReimbursableExpenses" => 0,
+ "totalOutstandingAmount" => 0,
+ "totalExecutableAmount" => 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;
+ }
+ }
+ $effectiveGeneralCharge = $totals['totalGeneralCharges'] - $totals['totalGeneralDiscounts'];
+
+ // Run through every item
+ foreach ($items as &$item) {
$totals['totalTaxesOutputs'] += $item['totalTaxesOutputs'];
$totals['totalTaxesWithheld'] += $item['totalTaxesWithheld'];
@@ -502,52 +839,62 @@ 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 = floatval($tax['rate']) . ":" . floatval($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'];
}
}
}
- // 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) {
- if (is_null($item['rate'])) {
- $rate = null;
- $amount = $item['amount'];
- } else {
- $rate = $this->pad($item['rate'], 'Discount/Rate');
- $amount = $totals['grossAmount'] * ($rate / 100);
+ // 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'];
+ }
}
- $amount = $this->pad($amount, 'Discount/Amount');
- $totals['general' . ucfirst($groupTag)][] = array(
- "reason" => $item['reason'],
- "rate" => $rate,
- "amount" => $amount
- );
- $totals['totalGeneral' . ucfirst($groupTag)] += $amount;
}
}
- // 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']);
+ // 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
- $totals['grossAmountBeforeTaxes'] = $this->pad($totals['grossAmount'] -
- $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges']);
- $totals['invoiceAmount'] = $this->pad($totals['grossAmountBeforeTaxes'] +
- $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld']);
+ $totals['grossAmountBeforeTaxes'] = $this->pad(
+ $totals['grossAmount'] - $totals['totalGeneralDiscounts'] + $totals['totalGeneralCharges'],
+ '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/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php
index 5545637..23a7032 100644
--- a/src/FacturaeTraits/SignableTrait.php
+++ b/src/FacturaeTraits/SignableTrait.php
@@ -1,28 +1,52 @@
signer === null) {
+ $this->signer = new FacturaeSigner();
+ }
+ return $this->signer;
+ }
+
+
+ /**
+ * Set signing time
+ * @param int|string $time Time of the signature
+ * @return self This instance
+ */
+ public function setSigningTime($time) {
+ $this->getSigner()->setSigningTime($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
+ * @deprecated 1.7.4 Renamed to `Facturae::setSigningTime()`.
*/
public function setSignTime($time) {
- $this->signTime = is_string($time) ? strtotime($time) : $time;
+ return $this->setSigningTime($time);
}
@@ -33,256 +57,46 @@ 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);
}
/**
* 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 \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="", $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->publicKey = $reader->getPublicKey();
- $this->privateKey = $reader->getPrivateKey();
- $this->signPolicy = $policy;
- unset($reader);
-
- // Return success
- return (!empty($this->publicKey) && !empty($this->privateKey));
+ public function sign($storeOrCertificate, $privateKey=null, $passphrase='') {
+ $signer = $this->getSigner();
+ if ($privateKey === null) {
+ $signer->loadPkcs12($storeOrCertificate, $passphrase);
+ } else {
+ $signer->addCertificate($storeOrCertificate);
+ $signer->setPrivateKey($privateKey, $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->publicKey) || 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->publicKey);
- $certIssuer = array();
- foreach ($certData['issuer'] as $item=>$value) {
- $certIssuer[] = $item . '=' . $value;
- }
- $certIssuer = implode(',', $certIssuer);
-
- // Generate signed properties
- $prop = '' .
- '' .
- '' . date('c', $signTime) . '' .
- '' .
- '' .
- '' .
- '' .
- '' . $tools->getCertDigest($this->publicKey) . '' .
- '' .
- '' .
- '' . $certIssuer . '' .
- '' . $certData['serialNumber'] . '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- '' . $this->signPolicy['url'] . '' .
- '' . $this->signPolicy['name'] . '' .
- '' .
- '' .
- '' .
- '' . $this->signPolicy['digest'] . '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- 'emisor' .
- '' .
- '' .
- '' .
- '' .
- '' .
- 'Factura electrónica' .
- '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" .
- '' . "\n" . $tools->getCert($this->publicKey) . '' . "\n" .
- '' . "\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" .
- 'signatureID . '-SignedProperties' .
- $this->signatureSignedPropertiesID . '">' . "\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-1
- $tsq = "302c0201013021300906052b0e03021a05000414";
- $tsq .= hash('sha1', $payload);
- $tsq .= "0201000101ff";
- $tsq = hex2bin($tsq);
-
- // 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;
+ if ($this->signer === null || $this->signer->canSign() === false) {
+ return $xml;
}
- $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);
- // Validate TimeStampRequest
- $responseCode = substr($tsr, 6, 3);
- if ($responseCode !== "\02\01\00") { // Bytes for INTEGER 0 in ASN1
- throw new \Exception('Invalid TSR response code');
+ // Sign and timestamp document
+ $xml = $this->signer->sign($xml);
+ if ($this->signer->canTimestamp()) {
+ $xml = $this->signer->timestamp($xml);
}
-
- // 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;
+ return $xml;
}
}
diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php
index 854ed42..b94551d 100644
--- a/src/FacturaeTraits/UtilsTrait.php
+++ b/src/FacturaeTraits/UtilsTrait.php
@@ -1,10 +1,16 @@
precision) {
+ return $val;
+ }
+
// 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;
}
- /**
- * Get XML Namespaces
- * @return string[] 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;
- }
-
-
/**
* Get extension
- * @param string $name Extension name or class name
- * @return Extension Extension instance
+ * @param string $name Extension name or class name
+ * @return FacturaeExtension Extension instance
*/
public function getExtension($name) {
$topNamespace = explode('\\', __NAMESPACE__);
diff --git a/src/ReimbursableExpense.php b/src/ReimbursableExpense.php
new file mode 100644
index 0000000..710cd02
--- /dev/null
+++ b/src/ReimbursableExpense.php
@@ -0,0 +1,51 @@
+$value) {
+ $this->{$key} = $value;
+ }
+ }
+}
diff --git a/tests/AbstractTest.php b/tests/AbstractTest.php
index 41cd008..6c6aa65 100644
--- a/tests/AbstractTest.php
+++ b/tests/AbstractTest.php
@@ -2,13 +2,106 @@
namespace josemmo\Facturae\Tests;
use PHPUnit\Framework\TestCase;
+use josemmo\Facturae\Facturae;
+use josemmo\Facturae\FacturaeParty;
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 FACTURAE_CERT_PASS = "1234";
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);
+ unset($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);
+ unset($ch);
+
+ // Validate results
+ $this->assertNotEmpty($res, 'Invalid Validator Response');
+ $this->assertNotEmpty(strpos($res, 'euro_ok.png'), 'Invalid XML Format');
+ if ($validateSignature) {
+ $this->assertNotEmpty(strpos($res, '>Nivel de Firma Válido<'), 'Invalid Signature');
+ }
+ if (strpos($res, '>Sellos de Tiempo<') !== false) {
+ $this->assertNotEmpty(strpos($res, '>XAdES_T<'), 'Invalid Timestamp');
+ }
+ }
}
diff --git a/tests/DecimalsTest.php b/tests/DecimalsTest.php
deleted file mode 100644
index b3669f0..0000000
--- a/tests/DecimalsTest.php
+++ /dev/null
@@ -1,100 +0,0 @@
-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"
- ]));
-
- // Añadimos elementos con importes aleatorios
- $unitPriceTotal = 0;
- $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
- ]
- ]));
- }
-
- // Validamos los totales de la factura
- $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/DiscountsTest.php b/tests/DiscountsTest.php
index 238549c..d661b2d 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
@@ -103,9 +71,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->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->assertEqualsWithDelta(299, $totalGrossAmount, 0.00001);
+ $this->assertEqualsWithDelta(28, $totalTaxOutputs, 0.00001);
}
@@ -113,11 +89,24 @@ public function testItemDiscounts() {
* Test general discounts
*/
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 = $this->getBaseInvoice();
+ $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());
@@ -125,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->assertEquals($totalDiscounts, $expectedDiscounts, '', 0.00001);
- $this->assertEquals($totalCharges, $expectedCharges, '', 0.00001);
- $this->assertEquals($invoiceTotal, $expectedTotal, '', 0.00001);
+ $this->assertEqualsWithDelta($totalDiscounts, 39.20, 0.00001);
+ $this->assertEqualsWithDelta($totalCharges, 14.00, 0.00001);
+ $this->assertEqualsWithDelta($invoiceTotal, 138.81, 0.00001);
}
}
diff --git a/tests/ExtensionsTest.php b/tests/ExtensionsTest.php
index ef74597..ed228e5 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
@@ -82,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);
@@ -91,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;
+ }
}
diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php
index 9764911..94e40bf 100644
--- a/tests/InvoiceTest.php
+++ b/tests/InvoiceTest.php
@@ -1,15 +1,18 @@
setPrecision(Facturae::PRECISION_INVOICE);
+ }
// Asignamos el número EMP2017120003 a la factura
// Nótese que Facturae debe recibir el lote y el
@@ -38,13 +44,20 @@ 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",
"province" => "Madrid",
"book" => "0",
"sheet" => "1",
- "merchantRegister" => "RG"
+ "merchantRegister" => "RG",
+ "additionalRegistrationData" => "xxxxx",
+ "phone" => "910112233",
+ "fax" => "910112234",
+ "email" => "noexiste@ejemplo.com",
+ "cnoCnae" => "04647",
+ "ineTownCode" => "0796"
]));
// Incluimos los datos del comprador
@@ -55,6 +68,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,
@@ -83,6 +98,20 @@ public function testCreateInvoice($schemaVersion, $isPfx) {
]
]));
+ // Creamos una factura rectificativa (solo en algunos casos)
+ if (!$isPfx) {
+ $fac->setCorrective(new CorrectiveDetails([
+ "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
+ ]));
+ }
+
// 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
@@ -91,6 +120,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([
@@ -120,7 +158,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
@@ -138,7 +178,18 @@ public function testCreateInvoice($schemaVersion, $isPfx) {
)
]));
- // Para terminar, añadimos 3 bombillas LED con un coste de 6,50 € ...
+ // 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
+ ]
+ ]));
+
+ // 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",
@@ -147,6 +198,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");
@@ -156,29 +211,86 @@ 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");
+ // Añadimos un suplido
+ $fac->addReimbursableExpense(new ReimbursableExpense([
+ "seller" => new FacturaeParty(["taxNumber" => "00000000A"]),
+ "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "countryCode" => "PRT"]),
+ "issueDate" => "2017-11-27",
+ "invoiceNumber" => "EX-19912",
+ "invoiceSeriesCode" => "156A",
+ "amount" => 99.9991172
+ ]));
+
+ // 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"
+ ]));
+ $this->assertEquals(Facturae::ISSUER_THIRD_PARTY, $fac->getIssuerType());
+ $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');
+ }
+
+ // 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");
}
+ // 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);
+ $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";
$outputPath = str_replace("*", "$schemaVersion-$isPfxStr", self::FILE_PATH);
- $res = $fac->export($outputPath);
+ $fac->export($outputPath);
// ... y validar la factura
- $this->validateInvoiceXML($outputPath);
+ $this->validateInvoiceXML($outputPath, true);
}
@@ -194,58 +306,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/MethodsTest.php b/tests/MethodsTest.php
index ea31cd5..01bab42 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;
@@ -16,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']);
@@ -75,8 +81,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();
@@ -92,6 +98,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());
}
}
diff --git a/tests/OverseasTest.php b/tests/OverseasTest.php
new file mode 100644
index 0000000..06c3df1
--- /dev/null
+++ b/tests/OverseasTest.php
@@ -0,0 +1,42 @@
+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);
+
+ // 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());
+ }
+
+}
diff --git a/tests/PerformanceTest.php b/tests/PerformanceTest.php
index cc15dbb..65bdd79 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,30 +15,9 @@ 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->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS);
$fac->export();
}
diff --git a/tests/PrecisionTest.php b/tests/PrecisionTest.php
new file mode 100644
index 0000000..19e5014
--- /dev/null
+++ b/tests/PrecisionTest.php
@@ -0,0 +1,114 @@
+getBaseInvoice($schema);
+ $fac->setPrecision($precision);
+
+ // Add items
+ $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",
+ "unitPriceWithoutTax" => $item['unitPriceWithoutTax'],
+ "quantity" => $item['quantity'],
+ "taxes" => [
+ Facturae::TAX_IVA => $item['tax']
+ ]
+ ]));
+ }
+
+ // 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->assertEqualsWithDelta($actualTotal, $invoiceTotal, 0.000000001, 'Incorrect invoice totals element');
+
+ // 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");
+ }
+ }
+
+
+ /**
+ * Test line precision
+ */
+ public function testLinePrecision() {
+ foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) {
+ $this->runTestWithParams($schema, Facturae::PRECISION_LINE);
+ }
+ }
+
+
+ /**
+ * Test invoice precision
+ */
+ public function testInvoicePrecision() {
+ foreach ([Facturae::SCHEMA_3_2_1] as $schema) {
+ $this->runTestWithParams($schema, Facturae::PRECISION_INVOICE);
+ }
+ }
+}
diff --git a/tests/SignerTest.php b/tests/SignerTest.php
new file mode 100644
index 0000000..c93d05c
--- /dev/null
+++ b/tests/SignerTest.php
@@ -0,0 +1,100 @@
+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();
+
+ $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();
+ $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 testNormalizesLineBreaks() {
+ $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();
+ $signer->timestamp(
+ '
+
+
+ '
+ );
+ }
+
+
+ public function testCanTimestampSignedDocuments() {
+ $signer = $this->getSigner();
+ $xml = $this->getBaseInvoice()->export();
+ $signedXml = $signer->sign($xml);
+ $timestampedXml = $signer->timestamp($signedXml);
+ $this->assertStringContainsString('', $timestampedXml);
+ }
+
+}
diff --git a/tests/WebservicesTest.php b/tests/WebservicesTest.php
index 9188b52..0d9dd98 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;
@@ -14,17 +15,16 @@ 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');
}
/**
- * 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'));
@@ -51,17 +51,22 @@ private function getBaseInvoice() {
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
- $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);
+
+ // Test C14N (non-exclusive)
+ $face->setExclusiveC14n(false);
+ $this->assertNotEmpty($face->getStatus()->estados);
+ $face->setExclusiveC14n(true);
// Generate invoice
- $fac = $this->getBaseInvoice();
+ $fac = $this->getWsBaseInvoice();
$fac->setBuyer(new FacturaeParty([
"taxNumber" => "V28000024",
"name" => "Banco de España",
@@ -87,20 +92,20 @@ 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();
$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);
@@ -108,25 +113,42 @@ 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
*/
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
- $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);
+ // Test C14N (non-exclusive)
+ $faceb2b->setExclusiveC14n(false);
+ $this->assertNotEmpty($faceb2b->getCodes()->codes);
+ $faceb2b->setExclusiveC14n(true);
+
// Generate invoice
- $fac = $this->getBaseInvoice();
+ $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",
@@ -136,14 +158,14 @@ 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();
$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
diff --git a/tests/XmlToolsTest.php b/tests/XmlToolsTest.php
new file mode 100644
index 0000000..9db3bce
--- /dev/null
+++ b/tests/XmlToolsTest.php
@@ -0,0 +1,103 @@
+");
+ $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);
+ }
+
+
+ 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'
+ ])
+ );
+ }
+
+}
diff --git a/tests/certs/facturae-private.pem b/tests/certs/facturae-private.pem
index a307303..41e9cea 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-----
+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 a50f469..8938204 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-----
+MIIHoDCCBoigAwIBAgIQc/g6LlAaVg9l7w+qq1iLnzANBgkqhkiG9w0BAQsFADBL
+MQswCQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVz
+MRkwFwYDVQQDDBBBQyBGTk1UIFVzdWFyaW9zMB4XDTI0MDMxMTE0MDUzMFoXDTI4
+MDMxMTE0MDUzMFowgYUxCzAJBgNVBAYTAkVTMRgwFgYDVQQFEw9JRENFUy05OTk5
+OTk3MkMxEDAOBgNVBCoMB1BSVUVCQVMxGjAYBgNVBAQMEUVJREFTIENFUlRJRklD
+QURPMS4wLAYDVQQDDCVFSURBUyBDRVJUSUZJQ0FETyBQUlVFQkFTIC0gOTk5OTk5
+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
new file mode 100644
index 0000000..dd9b424
Binary files /dev/null and b/tests/certs/facturae.p12 differ
diff --git a/tests/certs/facturae.pfx b/tests/certs/facturae.pfx
deleted file mode 100644
index 97e39d3..0000000
Binary files a/tests/certs/facturae.pfx and /dev/null differ
diff --git a/tests/certs/webservices.p12 b/tests/certs/webservices.p12
deleted file mode 100644
index ee08ed7..0000000
Binary files a/tests/certs/webservices.p12 and /dev/null differ