diff --git a/README.md b/README.md
index 7427afd..d6bb6ca 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,17 @@ $fac->sign("certificado.pfx", null, "passphrase");
$fac->export("mi-factura.xsig");
```
+También permite firmar facturas que hayan sido generadas con otro programa:
+
+```php
+$signer = new FacturaeSigner();
+$signer->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)
diff --git a/doc/anexos/constantes.md b/doc/anexos/constantes.md
index 964066e..2266fee 100644
--- a/doc/anexos/constantes.md
+++ b/doc/anexos/constantes.md
@@ -14,7 +14,6 @@ permalink: /anexos/constantes.html
|`Facturae::SCHEMA_3_2`|Formato de Facturae 3.2|
|`Facturae::SCHEMA_3_2_1`|Formato de Facturae 3.2.1|
|`Facturae::SCHEMA_3_2_2`|Formato de Facturae 3.2.2|
-|`Facturae::SIGN_POLICY_3_1`|Formato de firma 3.1|
---
diff --git a/doc/envio-y-recepcion/face.md b/doc/envio-y-recepcion/face.md
index 47c0027..d4876c1 100644
--- a/doc/envio-y-recepcion/face.md
+++ b/doc/envio-y-recepcion/face.md
@@ -29,6 +29,9 @@ Al igual que al firmar una factura electrónica, puedes usar un solo archivo `.p
$face = new FaceClient("certificado.pfx", null, "passphrase");
```
+> #### NOTA
+> Para más información sobre qué otros valores pueden tomar los parámetros del constructor, consulta la documentación de [firma electrónica](../firma-electronica/).
+
Por defecto, `FaceClient` se comunica con el entorno de producción de FACe. Para usar el entorno de pruebas (*staging*) puedes utilizar el siguiente método:
```php
$face->setProduction(false);
diff --git a/doc/envio-y-recepcion/faceb2b.md b/doc/envio-y-recepcion/faceb2b.md
index 63c84ab..d111f5b 100644
--- a/doc/envio-y-recepcion/faceb2b.md
+++ b/doc/envio-y-recepcion/faceb2b.md
@@ -32,6 +32,9 @@ Al igual que al firmar una factura electrónica, puedes usar un solo archivo `.p
$face = new Faceb2bClient("certificado.pfx", null, "passphrase");
```
+> #### NOTA
+> Para más información sobre qué otros valores pueden tomar los parámetros del constructor, consulta la documentación de [firma electrónica](../firma-electronica/).
+
Por defecto, `Faceb2bClient` se comunica con el entorno de producción de FACeB2B. Para usar el entorno de pruebas (*staging*) puedes utilizar el siguiente método:
```php
$face->setProduction(false);
diff --git a/doc/firma-electronica/firma-avanzada.md b/doc/firma-electronica/firma-avanzada.md
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 2a8bee5..6cf303a 100644
--- a/doc/firma-electronica/firma-electronica.md
+++ b/doc/firma-electronica/firma-electronica.md
@@ -10,11 +10,18 @@ Aunque es posible exportar las facturas sin firmarlas, es un paso obligatorio pa
Para firmar facturas se necesita un certificado electrónico (generalmente expedido por la FNMT) del que extraer su clave pública y su clave privada.
## Firmado con clave pública y privada X.509
-Si se tienen las clave pública y privada en archivos independientes se debe utilizar este método con los siguientes argumentos:
+Si se tiene la clave pública (un certificado) y la clave privada en archivos independientes, se debe utilizar este método con los siguientes argumentos:
```php
$fac->sign("clave_publica.pem", "clave_privada.pem", "passphrase");
```
+También se pueden pasar como parámetros los bytes de ambos ficheros en vez de sus rutas, o instancias de `OpenSSLCertificate` y `OpenSSLAsymmetricKey`, respectivamente:
+```php
+$publicKey = openssl_x509_read("clave_publica.pem");
+$encryptedPrivateKey = file_get_contents("clave_privada.pem");
+$fac->sign($publicKey, $encryptedPrivateKey, "passphrase");
+```
+
> #### NOTA
> Los siguientes comandos permiten extraer el certificado (clave pública) y la clave privada de un archivo PFX:
>
@@ -31,6 +38,12 @@ Desde la versión 1.0.5 de Facturae-PHP ya es posible cargar un banco de certifi
$fac->sign("certificado.pfx", null, "passphrase");
```
+También se pueden pasar como parámetro los bytes del banco PKCS#12:
+```php
+$encryptedStore = file_get_contents("certificado.pfx");
+$fac->sign($encryptedStore, null, "passphrase");
+```
+
> #### NOTA
> Al utilizar un banco PKCS#12, Facturae-PHP incluirá la cadena completa de certificados en la factura al firmarla.
>
@@ -51,7 +64,7 @@ $fac->sign("certificado.pfx", null, "passphrase");
## Fecha de la firma
Por defecto, al firmar una factura se utilizan la fecha y hora actuales como sello de tiempo. Si se quiere indicar otro valor, se debe utilizar el siguiente método:
```php
-$fac->setSignTime("2017-01-01T12:34:56+02:00");
+$fac->setSigningTime("2017-01-01T12:34:56+02:00");
```
> #### NOTA
diff --git a/doc/propiedades/suplidos.md b/doc/propiedades/suplidos.md
new file mode 100644
index 0000000..f4eb973
--- /dev/null
+++ b/doc/propiedades/suplidos.md
@@ -0,0 +1,22 @@
+---
+title: Suplidos
+parent: Propiedades de una factura
+nav_order: 8
+permalink: /propiedades/suplidos.html
+---
+
+# Suplidos
+La especificación de FacturaE permite añadir gastos a cuenta de terceros a una factura (suplidos).
+Para ello, se debe hacer uso de la clase `ReimbursableExpense`:
+```php
+$fac->addReimbursableExpense(new ReimbursableExpense([
+ "seller" => new FacturaeParty(["taxNumber" => "00000000A"]),
+ "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]),
+ "issueDate" => "2017-11-27",
+ "invoiceNumber" => "EX-19912",
+ "invoiceSeriesCode" => "156A",
+ "amount" => 100.00
+]));
+```
+
+Todos las propiedades de un suplido son opcionales excepto el importe (`amount`).
diff --git a/src/Common/FacturaeSigner.php b/src/Common/FacturaeSigner.php
new file mode 100644
index 0000000..6cd8d66
--- /dev/null
+++ b/src/Common/FacturaeSigner.php
@@ -0,0 +1,379 @@
+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 parseable 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');
+ }
+
+ // 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]);
+ $certIssuer = [];
+ foreach ($certData['issuer'] as $item=>$value) {
+ $certIssuer[] = "$item=$value";
+ }
+ $certIssuer = implode(',', array_reverse($certIssuer));
+ $xadesSignedProperties = '' .
+ '' .
+ '' . date('c', $signingTime) . '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' . XmlTools::getCertDigest($this->publicChain[0]) . '' .
+ '' .
+ '' .
+ '' . $certIssuer . '' .
+ '' . $certData['serialNumber'] . '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' . self::SIGN_POLICY_URL . '' .
+ '' . self::SIGN_POLICY_NAME . '' .
+ '' .
+ '' .
+ '' .
+ '' . self::SIGN_POLICY_DIGEST . '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'emisor' .
+ '' .
+ '' .
+ '' .
+ '' .
+ '' .
+ 'Factura electrónica' .
+ '' .
+ 'urn:oid:1.2.840.10003.5.109.10' .
+ '' .
+ 'text/xml' .
+ '' .
+ '' .
+ '';
+
+ // Build element
+ $privateData = openssl_pkey_get_details($this->privateKey);
+ $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76);
+ $modulus = str_replace("\r", '', $modulus);
+ $exponent = base64_encode($privateData['rsa']['e']);
+ $dsKeyInfo = '' . "\n" . '' . "\n";
+ foreach ($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 c6c551c..0000000
--- a/src/Common/KeyPairReader.php
+++ /dev/null
@@ -1,95 +0,0 @@
-publicChain;
- }
-
-
- /**
- * Get public key
- * @return string|null Certificate for the Public Key in PEM format
- */
- public function getPublicKey() {
- return empty($this->publicChain) ? null : $this->publicChain[0];
- }
-
-
- /**
- * Get private key
- * @return \OpenSSLAsymmetricKey|resource|null Decrypted Private Key
- */
- public function getPrivateKey() {
- return $this->privateKey;
- }
-
-
- /**
- * KeyPairReader constructor
- *
- * @param string $publicPath Path to public key in PEM or PKCS#12 file
- * @param string|null $privatePath Path to private key (null for PKCS#12)
- * @param string $passphrase Private key passphrase
- */
- public function __construct($publicPath, $privatePath=null, $passphrase="") {
- if (is_null($privatePath)) {
- $this->readPkcs12($publicPath, $passphrase);
- } else {
- $this->readX509($publicPath, $privatePath, $passphrase);
- }
- }
-
-
- /**
- * Read a X.509 certificate and PEM encoded private key
- *
- * @param string $publicPath Path to public key PEM file
- * @param string $privatePath Path to private key PEM file
- * @param string $passphrase Private key passphrase
- */
- private function readX509($publicPath, $privatePath, $passphrase) {
- if (!is_file($publicPath) || !is_file($privatePath)) return;
-
- // Validate and normalize public key
- $publicKey = openssl_x509_read(file_get_contents($publicPath));
- if (empty($publicKey)) return;
- openssl_x509_export($publicKey, $publicKeyPem);
- $this->publicChain = array($publicKeyPem);
-
- // Decrypt private key
- $this->privateKey = openssl_pkey_get_private(file_get_contents($privatePath), $passphrase);
- }
-
-
- /**
- * Read a PKCS#12 Certificate Store
- *
- * @param string $certPath The certificate store file name
- * @param string $passphrase Password for unlocking the PKCS#12 file
- */
- private function readPkcs12($certPath, $passphrase) {
- if (!is_file($certPath)) return;
- if (openssl_pkcs12_read(file_get_contents($certPath), $store, $passphrase)) {
- $this->publicChain = array($store['cert']);
- if (!empty($store['extracerts'])) {
- $this->publicChain = array_merge($this->publicChain, $store['extracerts']);
- }
- $this->privateKey = openssl_pkey_get_private($store['pkey']);
- unset($store);
- }
- }
-
-}
diff --git a/src/Common/KeyPairReaderTrait.php b/src/Common/KeyPairReaderTrait.php
new file mode 100644
index 0000000..7041b6f
--- /dev/null
+++ b/src/Common/KeyPairReaderTrait.php
@@ -0,0 +1,109 @@
+publicChain[] = $normalizedCertificate;
+ return true;
+ }
+
+
+ /**
+ * Set private key
+ *
+ * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string $privateKey OpenSSL instance, PEM string or filepath
+ * @param string $passphrase Passphrase to decrypt (optional)
+ * @return boolean Success result
+ */
+ public function setPrivateKey($privateKey, $passphrase='') {
+ // Read file from path
+ if (is_string($privateKey) && strpos($privateKey, ' PRIVATE KEY-----') === false) {
+ $privateKey = file_get_contents($privateKey);
+ }
+
+ // Validate and extract private key
+ if (empty($privateKey)) {
+ return false;
+ }
+ $privateKey = openssl_pkey_get_private($privateKey, $passphrase);
+ if ($privateKey === false) {
+ return false;
+ }
+
+ // Set private key
+ $this->privateKey = $privateKey;
+ return true;
+ }
+
+
+ /**
+ * Load public chain and private key from PKCS#12 Certificate Store
+ *
+ * @param string $certificateStore PKCS#12 bytes or filepath
+ * @param string $passphrase Password for unlocking the PKCS#12 file
+ * @return boolean Success result
+ */
+ public function loadPkcs12($certificateStore, $passphrase) {
+ // Read file from path
+ // (look for "1.2.840.113549.1.7.1" ASN.1 object identifier)
+ if (strpos($certificateStore, "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x07\x01") === false) {
+ $certificateStore = file_get_contents($certificateStore);
+ }
+
+ // Validate and parse certificate store
+ if (empty($certificateStore)) {
+ return false;
+ }
+ if (!openssl_pkcs12_read($certificateStore, $parsed, $passphrase)) {
+ return false;
+ }
+
+ // Set public chain and private key
+ $this->publicChain = [];
+ $this->publicChain[] = $parsed['cert'];
+ if (!empty($parsed['extracerts'])) {
+ $this->publicChain = array_merge($this->publicChain, $parsed['extracerts']);
+ }
+ $this->privateKey = openssl_pkey_get_private($parsed['pkey']);
+ return true;
+ }
+
+}
diff --git a/src/Common/XmlTools.php b/src/Common/XmlTools.php
index 3e9770d..9e70cfd 100644
--- a/src/Common/XmlTools.php
+++ b/src/Common/XmlTools.php
@@ -8,7 +8,7 @@ class XmlTools {
* @param string $value Input value
* @return string Escaped input
*/
- public function escape($value) {
+ public static function escape($value) {
return htmlspecialchars($value, ENT_XML1, 'UTF-8');
}
@@ -21,40 +21,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,9 +110,9 @@ 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;
}
@@ -76,7 +121,7 @@ public function toBase64($bytes, $pretty=false) {
* @param string $input Input string
* @return string Multi-line resposne
*/
- private function prettify($input) {
+ private static function prettify($input) {
return chunk_split($input, 76, "\n");
}
@@ -87,8 +132,8 @@ private function prettify($input) {
* @param boolean $pretty Pretty Base64 response
* @return string Digest
*/
- public function getDigest($input, $pretty=false) {
- return $this->toBase64(hash("sha512", $input, true), $pretty);
+ public static function getDigest($input, $pretty=false) {
+ return self::toBase64(hash("sha512", $input, true), $pretty);
}
@@ -98,11 +143,11 @@ public function getDigest($input, $pretty=false) {
* @param boolean $pretty Pretty Base64 response
* @return string Base64 Certificate
*/
- public function getCert($pem, $pretty=true) {
+ public static function getCert($pem, $pretty=true) {
$pem = str_replace("-----BEGIN CERTIFICATE-----", "", $pem);
$pem = str_replace("-----END CERTIFICATE-----", "", $pem);
$pem = str_replace("\n", "", str_replace("\r", "", $pem));
- if ($pretty) $pem = $this->prettify($pem);
+ if ($pretty) $pem = self::prettify($pem);
return $pem;
}
@@ -113,9 +158,9 @@ public function getCert($pem, $pretty=true) {
* @param boolean $pretty Pretty Base64 response
* @return string Base64 Digest
*/
- public function getCertDigest($publicKey, $pretty=false) {
+ public static function getCertDigest($publicKey, $pretty=false) {
$digest = openssl_x509_fingerprint($publicKey, "sha512", true);
- return $this->toBase64($digest, $pretty);
+ return self::toBase64($digest, $pretty);
}
@@ -126,9 +171,9 @@ public function getCertDigest($publicKey, $pretty=false) {
* @param boolean $pretty Pretty Base64 response
* @return string Base64 Signature
*/
- public function getSignature($payload, $privateKey, $pretty=true) {
+ public static function getSignature($payload, $privateKey, $pretty=true) {
openssl_sign($payload, $signature, $privateKey, OPENSSL_ALGO_SHA512);
- return $this->toBase64($signature, $pretty);
+ return self::toBase64($signature, $pretty);
}
}
diff --git a/src/Face/SoapClient.php b/src/Face/SoapClient.php
index 215f53f..7790a3d 100644
--- a/src/Face/SoapClient.php
+++ b/src/Face/SoapClient.php
@@ -2,29 +2,29 @@
namespace josemmo\Facturae\Face;
use josemmo\Facturae\Facturae;
-use josemmo\Facturae\Common\KeyPairReader;
+use josemmo\Facturae\Common\KeyPairReaderTrait;
use josemmo\Facturae\Common\XmlTools;
abstract class SoapClient {
const REQUEST_EXPIRATION = 60; // In seconds
- private $publicKey;
- private $privateKey;
-
+ use KeyPairReaderTrait;
/**
* SoapClient constructor
*
- * @param string $publicPath Path to public key in PEM or PKCS#12 file
- * @param string $privatePath Path to private key (null for PKCS#12)
- * @param string $passphrase Private key passphrase
+ * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string $storeOrCertificate Certificate or PKCS#12 store
+ * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|null $privateKey Private key (`null` for PKCS#12)
+ * @param string $passphrase Store or private key passphrase
*/
- public function __construct($publicPath, $privatePath=null, $passphrase="") {
- $reader = new KeyPairReader($publicPath, $privatePath, $passphrase);
- $this->publicKey = $reader->getPublicKey();
- $this->privateKey = $reader->getPrivateKey();
- unset($reader);
+ public function __construct($storeOrCertificate, $privateKey=null, $passphrase='') {
+ if ($privateKey === null) {
+ $this->loadPkcs12($storeOrCertificate, $passphrase);
+ } else {
+ $this->addCertificate($storeOrCertificate);
+ $this->setPrivateKey($privateKey, $passphrase);
+ }
}
@@ -48,28 +48,26 @@ protected abstract function getWebNamespace();
* @return SimpleXMLElement Response
*/
protected function request($body) {
- $tools = new XmlTools();
-
// Generate random IDs for this request
- $bodyId = "BodyId-" . $tools->randomId();
- $certId = "CertId-" . $tools->randomId();
- $keyId = "KeyId-" . $tools->randomId();
- $strId = "SecTokId-" . $tools->randomId();
- $timestampId = "TimestampId-" . $tools->randomId();
- $sigId = "SignatureId-" . $tools->randomId();
-
- // 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();
@@ -78,8 +76,8 @@ protected function request($body) {
'' . date('c', $timeCreated) . '' .
'' . date('c', $timeExpires) . '' .
'';
- $timestampDigest = $tools->getDigest(
- $tools->injectNamespaces($reqTimestamp, $ns)
+ $timestampDigest = XmlTools::getDigest(
+ XmlTools::injectNamespaces($reqTimestamp, $ns)
);
// Generate request header
@@ -89,7 +87,7 @@ protected function request($body) {
'EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" ' .
'wsu:Id="' . $certId . '" ' .
'ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3">' .
- $tools->getCert($this->publicKey, false) .
+ XmlTools::getCert($this->publicChain[0], false) .
'';
// Generate signed info
@@ -106,13 +104,13 @@ protected function request($body) {
'' . $bodyDigest . '' .
'' .
'';
- $signedInfoPayload = $tools->injectNamespaces($signedInfo, $ns);
+ $signedInfoPayload = XmlTools::injectNamespaces($signedInfo, $ns);
// Add signature and KeyInfo to header
$reqHeader .= '' .
$signedInfo .
'' .
- $tools->getSignature($signedInfoPayload, $this->privateKey, false) .
+ XmlTools::getSignature($signedInfoPayload, $this->privateKey, false) .
'';
$reqHeader .= '' .
'' .
@@ -130,7 +128,7 @@ protected function request($body) {
// Generate final request
$req = '' . $reqHeader . $reqBody . '';
- $req = $tools->injectNamespaces($req, $ns);
+ $req = XmlTools::injectNamespaces($req, $ns);
$req = '' . "\n" . $req;
// Extract SOAP action from ""
diff --git a/src/Face/Traits/FaceTrait.php b/src/Face/Traits/FaceTrait.php
index 0abb719..33a3557 100644
--- a/src/Face/Traits/FaceTrait.php
+++ b/src/Face/Traits/FaceTrait.php
@@ -86,18 +86,17 @@ public function getInvoices($regId) {
* @return SimpleXMLElement Response
*/
public function sendInvoice($email, $invoice, $attachments=array()) {
- $tools = new XmlTools();
$req = '';
$req .= '' . $email . '';
$req .= '' .
- '' . $tools->toBase64($invoice->getData()) . '' .
+ '' . XmlTools::toBase64($invoice->getData()) . '' .
'' . $invoice->getFilename() . '' .
'application/xml' . // Mandatory MIME type
'';
$req .= '';
foreach ($attachments as $file) {
$req .= '' .
- '' . $tools->toBase64($file->getData()) . '' .
+ '' . XmlTools::toBase64($file->getData()) . '' .
'' . $file->getFilename() . '' .
'' . $file->getMimeType() . '' .
'';
diff --git a/src/Face/Traits/Faceb2bTrait.php b/src/Face/Traits/Faceb2bTrait.php
index 8a90a7a..a4494d9 100644
--- a/src/Face/Traits/Faceb2bTrait.php
+++ b/src/Face/Traits/Faceb2bTrait.php
@@ -20,18 +20,17 @@ protected function getWebNamespace() {
* @return SimpleXMLElement Response
*/
public function sendInvoice($invoice, $attachment=null) {
- $tools = new XmlTools();
$req = '';
$req .= '' .
- '' . $tools->toBase64($invoice->getData()) . '' .
+ '' . XmlTools::toBase64($invoice->getData()) . '' .
'' . $invoice->getFilename() . '' .
'text/xml' . // Mandatory MIME type
'';
if (!is_null($attachment)) {
$req .= '' .
- '' . $tools->toBase64($attachment->getData()) . '' .
+ '' . XmlTools::toBase64($attachment->getData()) . '' .
'' . $attachment->getFilename() . '' .
'' . $attachment->getMimeType() . '' .
'';
@@ -191,11 +190,10 @@ public function rejectInvoiceCancellation($regId, $comment) {
* @return SimpleXMLElement Response
*/
public function validateInvoiceSignature($regId, $invoice) {
- $tools = new XmlTools();
$req = '';
$req .= '' . $regId . '';
$req .= '' .
- '' . $tools->toBase64($invoice->getData()) . '' .
+ '' . XmlTools::toBase64($invoice->getData()) . '' .
'' . $invoice->getFilename() . '' .
'' . $invoice->getMimeType() . '' .
'';
diff --git a/src/Facturae.php b/src/Facturae.php
index 8d132ec..6db8631 100644
--- a/src/Facturae.php
+++ b/src/Facturae.php
@@ -10,12 +10,13 @@
* Class for creating electronic invoices that comply with the Spanish FacturaE format.
*/
class Facturae {
- const VERSION = "1.7.3";
+ const VERSION = "1.7.4";
const USER_AGENT = "FacturaePHP/" . self::VERSION;
const SCHEMA_3_2 = "3.2";
const SCHEMA_3_2_1 = "3.2.1";
const SCHEMA_3_2_2 = "3.2.2";
+ /** @deprecated 1.7.4 Not needed anymore (only existing signing policy). */
const SIGN_POLICY_3_1 = array(
"name" => "Política de Firma FacturaE v3.1",
"url" => "http://www.facturae.es/politica_de_firma_formato_facturae/politica_de_firma_formato_facturae_v3_1.pdf",
diff --git a/src/FacturaeParty.php b/src/FacturaeParty.php
index c8a3feb..f318e6d 100644
--- a/src/FacturaeParty.php
+++ b/src/FacturaeParty.php
@@ -12,6 +12,7 @@
class FacturaeParty {
public $isLegalEntity = true; // By default is a company and not a person
+ public $isEuropeanUnionResident = true; // By default resides in the EU
public $taxNumber = null;
public $name = null;
@@ -64,13 +65,11 @@ public function __construct($properties=array()) {
* @return string Entity as Facturae XML
*/
public function getXML($schema) {
- $tools = new XmlTools();
-
// Add tax identification
$xml = '' .
'' . ($this->isLegalEntity ? 'J' : 'F') . '' .
'R' .
- '' . $tools->escape($this->taxNumber) . '' .
+ '' . XmlTools::escape($this->taxNumber) . '' .
'';
// Add administrative centres
@@ -80,12 +79,12 @@ public function getXML($schema) {
$xml .= '';
$xml .= '' . $centre->code . '';
$xml .= '' . $centre->role . '';
- $xml .= '' . $tools->escape($centre->name) . '';
+ $xml .= '' . XmlTools::escape($centre->name) . '';
if (!is_null($centre->firstSurname)) {
- $xml .= '' . $tools->escape($centre->firstSurname) . '';
+ $xml .= '' . XmlTools::escape($centre->firstSurname) . '';
}
if (!is_null($centre->lastSurname)) {
- $xml .= '' . $tools->escape($centre->lastSurname) . '';
+ $xml .= '' . XmlTools::escape($centre->lastSurname) . '';
}
// Get centre address, else use fallback
@@ -99,23 +98,23 @@ public function getXML($schema) {
if ($addressTarget->countryCode === "ESP") {
$xml .= '' .
- '' . $tools->escape($addressTarget->address) . '' .
+ '' . XmlTools::escape($addressTarget->address) . '' .
'' . $addressTarget->postCode . '' .
- '' . $tools->escape($addressTarget->town) . '' .
- '' . $tools->escape($addressTarget->province) . '' .
+ '' . XmlTools::escape($addressTarget->town) . '' .
+ '' . XmlTools::escape($addressTarget->province) . '' .
'' . $addressTarget->countryCode . '' .
'';
} else {
$xml .= '' .
- '' . $tools->escape($addressTarget->address) . '' .
- '' . $addressTarget->postCode . ' ' . $tools->escape($addressTarget->town) . '' .
- '' . $tools->escape($addressTarget->province) . '' .
+ '' . XmlTools::escape($addressTarget->address) . '' .
+ '' . $addressTarget->postCode . ' ' . XmlTools::escape($addressTarget->town) . '' .
+ '' . XmlTools::escape($addressTarget->province) . '' .
'' . $addressTarget->countryCode . '' .
'';
}
if (!is_null($centre->description)) {
- $xml .= '' . $tools->escape($centre->description) . '';
+ $xml .= '' . XmlTools::escape($centre->description) . '';
}
$xml .= '';
}
@@ -127,7 +126,7 @@ public function getXML($schema) {
// Add data exclusive to `LegalEntity`
if ($this->isLegalEntity) {
- $xml .= '' . $tools->escape($this->name) . '';
+ $xml .= '' . XmlTools::escape($this->name) . '';
$fields = array("book", "registerOfCompaniesLocation", "sheet", "folio",
"section", "volume");
@@ -148,25 +147,25 @@ public function getXML($schema) {
// Add data exclusive to `Individual`
if (!$this->isLegalEntity) {
- $xml .= '' . $tools->escape($this->name) . '';
- $xml .= '' . $tools->escape($this->firstSurname) . '';
- $xml .= '' . $tools->escape($this->lastSurname) . '';
+ $xml .= '' . XmlTools::escape($this->name) . '';
+ $xml .= '' . XmlTools::escape($this->firstSurname) . '';
+ $xml .= '' . XmlTools::escape($this->lastSurname) . '';
}
// Add address
if ($this->countryCode === "ESP") {
$xml .= '' .
- '' . $tools->escape($this->address) . '' .
+ '' . XmlTools::escape($this->address) . '' .
'' . $this->postCode . '' .
- '' . $tools->escape($this->town) . '' .
- '' . $tools->escape($this->province) . '' .
+ '' . XmlTools::escape($this->town) . '' .
+ '' . XmlTools::escape($this->province) . '' .
'' . $this->countryCode . '' .
'';
} else {
$xml .= '' .
- '' . $tools->escape($this->address) . '' .
- '' . $this->postCode . ' ' . $tools->escape($this->town) . '' .
- '' . $tools->escape($this->province) . '' .
+ '' . XmlTools::escape($this->address) . '' .
+ '' . $this->postCode . ' ' . XmlTools::escape($this->town) . '' .
+ '' . XmlTools::escape($this->province) . '' .
'' . $this->countryCode . '' .
'';
}
@@ -187,7 +186,6 @@ public function getXML($schema) {
* @return string Contact details XML
*/
private function getContactDetailsXML() {
- $tools = new XmlTools();
$contactFields = [
"phone" => "Telephone",
"fax" => "TeleFax",
@@ -213,7 +211,7 @@ private function getContactDetailsXML() {
foreach ($contactFields as $field=>$xmlName) {
$value = $this->$field;
if (!empty($value)) {
- $xml .= "<$xmlName>" . $tools->escape($value) . "$xmlName>";
+ $xml .= "<$xmlName>" . XmlTools::escape($value) . "$xmlName>";
}
}
$xml .= '';
@@ -221,5 +219,18 @@ private function getContactDetailsXML() {
return $xml;
}
+
+ /**
+ * Get item XML for reimbursable expense node
+ *
+ * @return string Reimbursable expense XML
+ */
+ public function getReimbursableExpenseXML() {
+ $xml = '' . ($this->isLegalEntity ? 'J' : 'F') . '';
+ $xml .= '' . ($this->isEuropeanUnionResident ? 'R' : 'E') . '';
+ $xml .= '' . XmlTools::escape($this->taxNumber) . '';
+ return $xml;
+ }
+
}
diff --git a/src/FacturaeTraits/ExportableTrait.php b/src/FacturaeTraits/ExportableTrait.php
index 66ac19a..df9f25e 100644
--- a/src/FacturaeTraits/ExportableTrait.php
+++ b/src/FacturaeTraits/ExportableTrait.php
@@ -3,6 +3,7 @@
use josemmo\Facturae\Common\XmlTools;
use josemmo\Facturae\FacturaePayment;
+use josemmo\Facturae\ReimbursableExpense;
/**
* Allows a Facturae instance to be exported to XML.
@@ -16,14 +17,12 @@ trait ExportableTrait {
* @return string Output XML
*/
private function addOptionalFields($item, $fields) {
- $tools = new XmlTools();
-
$res = "";
foreach ($fields as $key=>$name) {
if (is_int($key)) $key = $name; // Allow $item to have a different property name
if (!empty($item[$key])) {
$xmlTag = ucfirst($name);
- $res .= "<$xmlTag>" . $tools->escape($item[$key]) . "$xmlTag>";
+ $res .= "<$xmlTag>" . XmlTools::escape($item[$key]) . "$xmlTag>";
}
}
return $res;
@@ -37,14 +36,11 @@ 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();
$paymentDetailsXML = $this->getPaymentDetailsXML($totals);
@@ -61,10 +57,10 @@ public function export($filePath=null) {
'' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' .
'' .
'' .
- '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' .
+ '' . $this->pad($totals['totalOutstandingAmount'], 'InvoiceTotal') . '' .
'' .
'' .
- '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '' .
+ '' . $this->pad($totals['totalExecutableAmount'], 'InvoiceTotal') . '' .
'' .
'' . $this->currency . '' .
'';
@@ -76,7 +72,7 @@ public function export($filePath=null) {
$xml .= $paymentDetailsXML;
if (!is_null($this->header['assignmentClauses'])) {
$xml .= '' .
- $tools->escape($this->header['assignmentClauses']) .
+ XmlTools::escape($this->header['assignmentClauses']) .
'';
}
$xml .= '';
@@ -161,7 +157,7 @@ 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>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . "{$xmlTag}Rate>";
}
@@ -171,14 +167,51 @@ public function export($filePath=null) {
$xml .= '' . $generalGroups[$g][0] . '>';
}
+ // Add some total amounts
$xml .= '' . $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts') . '';
$xml .= '' . $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges') . '';
$xml .= '' . $this->pad($totals['grossAmountBeforeTaxes'], 'TotalGrossAmountBeforeTaxes') . '';
$xml .= '' . $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs') . '';
$xml .= '' . $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld') . '';
$xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '';
- $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '';
- $xml .= '' . $this->pad($totals['invoiceAmount'], 'InvoiceTotal') . '';
+
+ // Add reimbursable expenses
+ if (!empty($this->reimbursableExpenses)) {
+ $xml .= '';
+ foreach ($this->reimbursableExpenses as $expense) { /** @var ReimbursableExpense $expense */
+ $xml .= '';
+ if ($expense->seller !== null) {
+ $xml .= '';
+ $xml .= $expense->seller->getReimbursableExpenseXML();
+ $xml .= '';
+ }
+ if ($expense->buyer !== null) {
+ $xml .= '';
+ $xml .= $expense->buyer->getReimbursableExpenseXML();
+ $xml .= '';
+ }
+ if ($expense->issueDate !== null) {
+ $issueDate = is_string($expense->issueDate) ? strtotime($expense->issueDate) : $expense->issueDate;
+ $xml .= '' . date('Y-m-d', $issueDate) . '';
+ }
+ if ($expense->invoiceNumber !== null) {
+ $xml .= '' . XmlTools::escape($expense->invoiceNumber) . '';
+ }
+ if ($expense->invoiceSeriesCode !== null) {
+ $xml .= '' . XmlTools::escape($expense->invoiceSeriesCode) . '';
+ }
+ $xml .= '' . $this->pad($expense->amount, 'ReimbursableExpense/Amount') . '';
+ $xml .= '';
+ }
+ $xml .= '';
+ }
+
+ // Add more total amounts
+ $xml .= '' . $this->pad($totals['totalOutstandingAmount'], 'TotalOutstandingAmount') . '';
+ $xml .= '' . $this->pad($totals['totalExecutableAmount'], 'TotalExecutableAmount') . '';
+ if (!empty($this->reimbursableExpenses)) {
+ $xml .= '' . $this->pad($totals['totalReimbursableExpenses'], 'TotalReimbursableExpenses') . '';
+ }
$xml .= '';
// Add invoice items
@@ -197,7 +230,7 @@ public function export($filePath=null) {
]);
// Add required fields
- $xml .= '' . $tools->escape($item['name']) . '' .
+ $xml .= '' . XmlTools::escape($item['name']) . '' .
'' . $this->pad($item['quantity'], 'Item/Quantity') . '' .
'' . $item['unitOfMeasure'] . '' .
'' . $this->pad($item['unitPriceWithoutTax'], 'Item/UnitPriceWithoutTax') . '' .
@@ -214,7 +247,7 @@ 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>" . $this->pad($elem['rate'], 'DiscountCharge/Rate') . "{$groupTag}Rate>";
}
@@ -261,8 +294,8 @@ public function export($filePath=null) {
// Add line period dates
if (!empty($item['periodStart']) && !empty($item['periodEnd'])) {
$xml .= '';
- $xml .= '' . $tools->escape($item['periodStart']) . '';
- $xml .= '' . $tools->escape($item['periodEnd']) . '';
+ $xml .= '' . XmlTools::escape($item['periodStart']) . '';
+ $xml .= '' . XmlTools::escape($item['periodEnd']) . '';
$xml .= '';
}
@@ -284,7 +317,7 @@ public function export($filePath=null) {
if (count($this->legalLiterals) > 0) {
$xml .= '';
foreach ($this->legalLiterals as $reference) {
- $xml .= '' . $tools->escape($reference) . '';
+ $xml .= '' . XmlTools::escape($reference) . '';
}
$xml .= '';
}
@@ -296,8 +329,8 @@ public function export($filePath=null) {
$xml .= '';
foreach ($this->extensions as $ext) $xml = $ext->__onBeforeSign($xml);
- // Add signature
- $xml = $this->injectSignature($xml);
+ // Add signature and timestamp
+ $xml = $this->injectSignatureAndTimestamp($xml);
foreach ($this->extensions as $ext) $xml = $ext->__onAfterSign($xml);
// Prepend content type
@@ -363,9 +396,8 @@ private function getAdditionalDataXML() {
if (!$hasData) return "";
// Generate initial XML block
- $tools = new XmlTools();
$xml = '';
- if (!empty($relInvoice)) $xml .= '' . $tools->escape($relInvoice) . '';
+ if (!empty($relInvoice)) $xml .= '' . XmlTools::escape($relInvoice) . '';
// Add attachments
if (!empty($this->attachments)) {
@@ -375,9 +407,9 @@ private function getAdditionalDataXML() {
$type = end($type);
$xml .= '';
$xml .= 'NONE';
- $xml .= '' . $tools->escape($type) . '';
+ $xml .= '' . XmlTools::escape($type) . '';
$xml .= 'BASE64';
- $xml .= '' . $tools->escape($att['description']) . '';
+ $xml .= '' . XmlTools::escape($att['description']) . '';
$xml .= '' . base64_encode($att['file']->getData()) . '';
$xml .= '';
}
@@ -386,7 +418,7 @@ private function getAdditionalDataXML() {
// Add additional information
if (!empty($additionalInfo)) {
- $xml .= '' . $tools->escape($additionalInfo) . '';
+ $xml .= '' . XmlTools::escape($additionalInfo) . '';
}
// Add extensions data
diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php
index 02c3347..d32a48c 100644
--- a/src/FacturaeTraits/PropertiesTrait.php
+++ b/src/FacturaeTraits/PropertiesTrait.php
@@ -4,6 +4,7 @@
use josemmo\Facturae\FacturaeFile;
use josemmo\Facturae\FacturaeItem;
use josemmo\Facturae\FacturaePayment;
+use josemmo\Facturae\ReimbursableExpense;
/**
* Implements all attributes and methods needed to make Facturae instantiable.
@@ -34,6 +35,8 @@ trait PropertiesTrait {
"seller" => null,
"buyer" => null
);
+ /** @var ReimbursableExpense[] */
+ protected $reimbursableExpenses = array();
protected $items = array();
protected $legalLiterals = array();
protected $discounts = array();
@@ -651,6 +654,36 @@ public function clearItems() {
}
+ /**
+ * Add reimbursable expense
+ * @param ReimbursableExpense $item Reimbursable expense
+ * @return Facturae Invoice instance
+ */
+ public function addReimbursableExpense($item) {
+ $this->reimbursableExpenses[] = $item;
+ return $this;
+ }
+
+
+ /**
+ * Get reimbursable expenses
+ * @return ReimbursableExpense[] Reimbursable expenses
+ */
+ public function getReimbursableExpenses() {
+ return $this->reimbursableExpenses;
+ }
+
+
+ /**
+ * Clear reimbursable expenses
+ * @return Facturae Invoice instance
+ */
+ public function clearReimbursableExpenses() {
+ $this->reimbursableExpenses = array();
+ return $this;
+ }
+
+
/**
* Get totals
* @return array Invoice totals
@@ -667,7 +700,10 @@ public function getTotals() {
"totalGeneralDiscounts" => 0,
"totalGeneralCharges" => 0,
"totalTaxesOutputs" => 0,
- "totalTaxesWithheld" => 0
+ "totalTaxesWithheld" => 0,
+ "totalReimbursableExpenses" => 0,
+ "totalOutstandingAmount" => 0,
+ "totalExecutableAmount" => 0
);
// Precalculate total global amount (needed for general discounts and charges)
@@ -742,11 +778,19 @@ public function getTotals() {
}
}
+ // Get total reimbursable expenses amount
+ if (!empty($this->reimbursableExpenses)) {
+ foreach ($this->reimbursableExpenses as $expense) {
+ $totals['totalReimbursableExpenses'] += $expense->amount;
+ }
+ }
+
// Pre-round some total values (needed to create a sum-reasonable invoice total)
$totals['totalTaxesOutputs'] = $this->pad($totals['totalTaxesOutputs'], 'TotalTaxOutputs');
$totals['totalTaxesWithheld'] = $this->pad($totals['totalTaxesWithheld'], 'TotalTaxesWithheld');
$totals['totalGeneralDiscounts'] = $this->pad($totals['totalGeneralDiscounts'], 'TotalGeneralDiscounts');
$totals['totalGeneralCharges'] = $this->pad($totals['totalGeneralCharges'], 'TotalGeneralSurcharges');
+ $totals['totalReimbursableExpenses'] = $this->pad($totals['totalReimbursableExpenses'], 'TotalReimbursableExpenses');
$totals['grossAmount'] = $this->pad($totals['grossAmount'], 'TotalGrossAmount');
// Fill missing values
@@ -755,6 +799,8 @@ public function getTotals() {
'TotalGrossAmountBeforeTaxes'
);
$totals['invoiceAmount'] = $totals['grossAmountBeforeTaxes'] + $totals['totalTaxesOutputs'] - $totals['totalTaxesWithheld'];
+ $totals['totalOutstandingAmount'] = $totals['invoiceAmount'];
+ $totals['totalExecutableAmount'] = $totals['invoiceAmount'] + $totals['totalReimbursableExpenses'];
return $totals;
}
diff --git a/src/FacturaeTraits/SignableTrait.php b/src/FacturaeTraits/SignableTrait.php
index a631d0d..65b1791 100644
--- a/src/FacturaeTraits/SignableTrait.php
+++ b/src/FacturaeTraits/SignableTrait.php
@@ -1,37 +1,49 @@
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;
+ }
- private $signatureID;
- private $signedInfoID;
- private $signedPropertiesID;
- private $signatureValueID;
- private $certificateID;
- private $referenceID;
- private $signatureSignedPropertiesID;
- private $signatureObjectID;
/**
- * Set sign time
- * @param int|string $time Time of the signature
+ * 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);
}
@@ -42,262 +54,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|null $privatePath Path to private key PEM file (should be null in case of PKCS#12)
- * @param string $passphrase Private key passphrase
- * @param array $policy Facturae sign policy
- * @return boolean Success
+ * @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->publicChain = $reader->getPublicChain();
- $this->privateKey = $reader->getPrivateKey();
- $this->signPolicy = $policy;
- unset($reader);
-
- // Return success
- return (!empty($this->publicChain) && !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->publicChain) || empty($this->privateKey)) return $xml;
- $tools = new XmlTools();
-
- // Normalize document
- $xml = str_replace("\r", "", $xml);
-
- // Prepare signed properties
- $signTime = is_null($this->signTime) ? time() : $this->signTime;
- $certData = openssl_x509_parse($this->publicChain[0]);
- $certIssuer = [];
- foreach ($certData['issuer'] as $item=>$value) {
- $certIssuer[] = "$item=$value";
+ if ($this->signer === null || $this->signer->canSign() === false) {
+ return $xml;
}
- $certIssuer = implode(',', array_reverse($certIssuer));
-
- // Generate signed properties
- $prop = '' .
- '' .
- '' . date('c', $signTime) . '' .
- '' .
- '' .
- '' .
- '' .
- '' . $tools->getCertDigest($this->publicChain[0]) . '' .
- '' .
- '' .
- '' . $certIssuer . '' .
- '' . $certData['serialNumber'] . '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- '' . $this->signPolicy['url'] . '' .
- '' . $this->signPolicy['name'] . '' .
- '' .
- '' .
- '' .
- '' . $this->signPolicy['digest'] . '' .
- '' .
- '' .
- '' .
- '' .
- '' .
- 'emisor' .
- '' .
- '' .
- '' .
- '' .
- '' .
- 'Factura electrónica' .
- '' .
- 'urn:oid:1.2.840.10003.5.109.10' .
- '' .
- 'text/xml' .
- '' .
- '' .
- '';
- // Extract public exponent (e) and modulus (n)
- $privateData = openssl_pkey_get_details($this->privateKey);
- $modulus = chunk_split(base64_encode($privateData['rsa']['n']), 76);
- $modulus = str_replace("\r", "", $modulus);
- $exponent = base64_encode($privateData['rsa']['e']);
-
- // Generate KeyInfo
- $kInfo = '' . "\n" .
- '' . "\n";
- foreach ($this->publicChain as $pemCertificate) {
- $kInfo .= '' . "\n" . $tools->getCert($pemCertificate) . '' . "\n";
+ // Sign and timestamp document
+ $xml = $this->signer->sign($xml);
+ if ($this->signer->canTimestamp()) {
+ $xml = $this->signer->timestamp($xml);
}
- $kInfo .= '' . "\n" .
- '' . "\n" .
- '' . "\n" .
- '' . "\n" . $modulus . '' . "\n" .
- '' . $exponent . '' . "\n" .
- '' . "\n" .
- '' . "\n" .
- '';
-
- // Calculate digests
- $xmlns = $this->getNamespaces();
- $propDigest = $tools->getDigest($tools->injectNamespaces($prop, $xmlns));
- $kInfoDigest = $tools->getDigest($tools->injectNamespaces($kInfo, $xmlns));
- $documentDigest = $tools->getDigest($xml);
-
- // Generate SignedInfo
- $sInfo = '' . "\n" .
- '' .
- '' . "\n" .
- '' .
- '' . "\n" .
- '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-512
- $tsq = "\x30\x59\x02\x01\x01\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40";
- $tsq .= hash('sha512', $payload, true);
- $tsq .= "\x01\x01\xff";
-
- // Await TimeStampRequest
- $chOpts = array(
- CURLOPT_URL => $this->timestampServer,
- CURLOPT_RETURNTRANSFER => 1,
- CURLOPT_BINARYTRANSFER => 1,
- CURLOPT_SSL_VERIFYPEER => 0,
- CURLOPT_FOLLOWLOCATION => 1,
- CURLOPT_CONNECTTIMEOUT => 0,
- CURLOPT_TIMEOUT => 10, // 10 seconds timeout
- CURLOPT_POST => 1,
- CURLOPT_POSTFIELDS => $tsq,
- CURLOPT_HTTPHEADER => array("Content-Type: application/timestamp-query"),
- CURLOPT_USERAGENT => self::USER_AGENT
- );
- if (!empty($this->timestampUser) && !empty($this->timestampPass)) {
- $chOpts[CURLOPT_USERPWD] = $this->timestampUser . ":" . $this->timestampPass;
- }
- $ch = curl_init();
- curl_setopt_array($ch, $chOpts);
- $tsr = curl_exec($ch);
- if ($tsr === false) throw new \Exception('cURL error: ' . curl_error($ch));
- curl_close($ch);
- unset($ch);
-
- // Validate TimeStampRequest
- $responseCode = substr($tsr, 6, 3);
- if ($responseCode !== "\x02\x01\x00") { // Bytes for INTEGER 0 in ASN1
- throw new \Exception('Invalid TSR response code');
- }
-
- // Extract TimeStamp from TimeStampRequest and inject into XML document
- $tools = new XmlTools();
- $timeStamp = substr($tsr, 9);
- $timeStamp = $tools->toBase64($timeStamp, true);
- $tsXml = '' .
- '' .
- '' .
- '' .
- '' .
- '' . "\n" . $timeStamp . '' .
- '' .
- '' .
- '';
- $signedXml = str_replace('', $tsXml . '', $signedXml);
- return $signedXml;
- }
-
}
diff --git a/src/FacturaeTraits/UtilsTrait.php b/src/FacturaeTraits/UtilsTrait.php
index f815460..858435c 100644
--- a/src/FacturaeTraits/UtilsTrait.php
+++ b/src/FacturaeTraits/UtilsTrait.php
@@ -46,19 +46,6 @@ public function pad($val, $field, $precision=null) {
}
- /**
- * Get XML Namespaces
- * @return 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
diff --git a/src/ReimbursableExpense.php b/src/ReimbursableExpense.php
new file mode 100644
index 0000000..353019a
--- /dev/null
+++ b/src/ReimbursableExpense.php
@@ -0,0 +1,51 @@
+$value) {
+ $this->{$key} = $value;
+ }
+ }
+}
diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php
index bc3c670..d563048 100644
--- a/tests/InvoiceTest.php
+++ b/tests/InvoiceTest.php
@@ -7,6 +7,7 @@
use josemmo\Facturae\FacturaeParty;
use josemmo\Facturae\FacturaePayment;
use josemmo\Facturae\FacturaeCentre;
+use josemmo\Facturae\ReimbursableExpense;
final class InvoiceTest extends AbstractTest {
@@ -184,6 +185,16 @@ public function testCreateInvoice($schemaVersion, $isPfx) {
$fac->addDiscount('A mitad de precio', 50);
$fac->addCharge('Recargo del 50%', 50);
+ // Añadimos un suplido
+ $fac->addReimbursableExpense(new ReimbursableExpense([
+ "seller" => new FacturaeParty(["taxNumber" => "00000000A"]),
+ "buyer" => new FacturaeParty(["taxNumber" => "12-3456789", "isEuropeanUnionResident" => false]),
+ "issueDate" => "2017-11-27",
+ "invoiceNumber" => "EX-19912",
+ "invoiceSeriesCode" => "156A",
+ "amount" => 99.9991172
+ ]));
+
// Establecemos un un cesionario (solo en algunos casos)
if ($isPfx) {
$fac->setAssignee(new FacturaeParty([
@@ -230,11 +241,11 @@ public function testCreateInvoice($schemaVersion, $isPfx) {
// Ya solo queda firmar la factura ...
if ($isPfx) {
$fac->sign(self::CERTS_DIR . "/facturae.p12", null, self::FACTURAE_CERT_PASS);
+ $fac->setTimestampServer("http://tss.accv.es:8318/tsa");
} else {
$fac->sign(self::CERTS_DIR . "/facturae-public.pem",
self::CERTS_DIR . "/facturae-private.pem", self::FACTURAE_CERT_PASS);
}
- $fac->setTimestampServer("http://tss.accv.es:8318/tsa");
// ... exportarlo a un archivo ...
$isPfxStr = $isPfx ? "PKCS12" : "X509";
diff --git a/tests/SignerTest.php b/tests/SignerTest.php
new file mode 100644
index 0000000..327e12e
--- /dev/null
+++ b/tests/SignerTest.php
@@ -0,0 +1,90 @@
+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 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/XmlToolsTest.php b/tests/XmlToolsTest.php
new file mode 100644
index 0000000..1235c00
--- /dev/null
+++ b/tests/XmlToolsTest.php
@@ -0,0 +1,48 @@
+");
+ $this->assertEquals([
+ 'xmlns:a' => 'abc',
+ 'xmlns:b' => 'xyz',
+ 'xmlns:c' => 'o o o',
+ ], $xmlns);
+
+ $xmlns = XmlTools::getNamespaces('');
+ $this->assertEquals([
+ 'xmlns:a' => 'abc',
+ 'xmlns:b' => 'xyz'
+ ], $xmlns);
+ }
+
+
+ public function testCanInjectNamespaces() {
+ $xml = XmlTools::injectNamespaces('Hey', ['xmlns:abc' => 'abc']);
+ $this->assertEquals('Hey', $xml);
+
+ $xml = XmlTools::injectNamespaces("", [
+ 'test' => 'A test value',
+ 'xmlns:b' => 'XXXX',
+ 'xmlns:zzz' => 'Last namespace'
+ ]);
+ $this->assertEquals(
+ '',
+ $xml
+ );
+ }
+
+
+ public function testCanCanonicalizeXml() {
+ $c14n = XmlTools::c14n(']]>');
+ $this->assertEquals('äëïöüThis is a <test>', $c14n);
+
+ $c14n = XmlTools::c14n('');
+ $this->assertEquals('', $c14n);
+ }
+
+}