Created
May 4, 2018 07:56
-
-
Save josemmo/f88e566b67c2579e3fbf1ec90a00ad24 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace josemmo\Facturae; | |
/** | |
* Facturae | |
* | |
* This file contains everything you need to create invoices. | |
* | |
* @package josemmo\Facturae | |
* @version 1.2.3 | |
* @license http://www.opensource.org/licenses/mit-license.php MIT License | |
* @author josemmo | |
*/ | |
/** | |
* Facturae | |
* | |
* Standalone class designed to create full compliance Facturae files from | |
* scratch, without the need of any other tools for signing. | |
*/ | |
class Facturae { | |
/* CONSTANTS */ | |
const SCHEMA_3_2 = "3.2"; | |
const SCHEMA_3_2_1 = "3.2.1"; | |
const SCHEMA_3_2_2 = "3.2.2"; | |
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 PAYMENT_CASH = "01"; | |
const PAYMENT_TRANSFER = "04"; | |
const TAX_IVA = "01"; | |
const TAX_IPSI = "02"; | |
const TAX_IGIC = "03"; | |
const TAX_IRPF = "04"; | |
const TAX_OTHER = "05"; | |
const TAX_ITPAJD = "06"; | |
const TAX_IE = "07"; | |
const TAX_RA = "08"; | |
const TAX_IGTECM = "09"; | |
const TAX_IECDPCAC = "10"; | |
const TAX_IIIMAB = "11"; | |
const TAX_ICIO = "12"; | |
const TAX_IMVDN = "13"; | |
const TAX_IMSN = "14"; | |
const TAX_IMGSN = "15"; | |
const TAX_IMPN = "16"; | |
const TAX_REIVA = "17"; | |
const TAX_REIGIC = "18"; | |
const TAX_REIPSI = "19"; | |
/* PRIVATE CONSTANTS */ | |
private static $SCHEMA_NS = array( | |
self::SCHEMA_3_2 => "http://www.facturae.es/Facturae/2009/v3.2/Facturae", | |
self::SCHEMA_3_2_1 => "http://www.facturae.es/Facturae/2014/v3.2.1/Facturae", | |
self::SCHEMA_3_2_2 => "http://www.facturae.gob.es/formato/Versiones/Facturaev3_2_2.xml" | |
); | |
private static $USER_AGENT = "FacturaePHP/1.2.3"; | |
/* ATTRIBUTES */ | |
private $currency = "EUR"; | |
private $itemsPrecision = 6; | |
private $itemsPadding = 6; | |
private $totalsPrecision = 2; | |
private $totalsPadding = 2; | |
private $totalitemsPrecision = 6; | |
private $totalitemsPadding = 6; | |
private $version = NULL; | |
private $header = array( | |
"serie" => NULL, | |
"number" => NULL, | |
"issueDate" => NULL, | |
"dueDate" => NULL, | |
"startDate" => NULL, | |
"endDate" => NULL, | |
"paymentMethod" => NULL, | |
"paymentIBAN" => NULL | |
); | |
private $parties = array( | |
"seller" => NULL, | |
"buyer" => NULL | |
); | |
private $items = array(); | |
private $legalLiterals = array(); | |
private $signTime = NULL; | |
private $timestampServer = NULL; | |
private $timestampUser = NULL; | |
private $timestampPass = NULL; | |
private $signPolicy = NULL; | |
private $publicKey = NULL; | |
private $privateKey = NULL; | |
/** | |
* Construct | |
* | |
* @param string $schemaVersion If omitted, latest version available | |
*/ | |
public function __construct($schemaVersion=self::SCHEMA_3_2_1) { | |
$this->setSchemaVersion($schemaVersion); | |
} | |
/** | |
* Generate random ID | |
* | |
* This method is used for generating random IDs required when signing the | |
* document. | |
* | |
* @return int Random number | |
*/ | |
private function random() { | |
if (function_exists('random_int')) { | |
return random_int(0x10000000, 0x7FFFFFFF); | |
} else { | |
return rand(100000, 999999); | |
} | |
} | |
/** | |
* Pad | |
* | |
* @param float $val Input | |
* @param int $precision Decimals to round | |
* @param int $padding Decimals to pad | |
* @return string Padded value | |
*/ | |
private function pad($val, $precision, $padding) { | |
return number_format(round($val, $precision), $padding, ".", ""); | |
} | |
/** | |
* Pad total value | |
* | |
* @param float $val Input | |
* @return string Padded value | |
*/ | |
private function padTotal($val) { | |
return $this->pad($val, $this->totalsPrecision, $this->totalsPadding); | |
} | |
/** | |
* Pad total value | |
* | |
* @param float $val Input | |
* @return string Padded value | |
*/ | |
private function padTotalItem($val) { | |
return $this->pad($val, $this->totalitemsPrecision, $this->totalitemsPadding); | |
} | |
/** | |
* Pad item value | |
* | |
* @param float $val Input | |
* @return string Padded value | |
*/ | |
private function padItem($val) { | |
return $this->pad($val, $this->itemsPrecision, $this->itemsPadding); | |
} | |
/** | |
* Is withheld tax | |
* | |
* This method returns if a tax type is, by default, a withheld tax | |
* | |
* @param string $taxCode Tax | |
* @return boolean Is withheld | |
*/ | |
public static function isWithheldTax($taxCode) { | |
return in_array($taxCode, [self::TAX_IRPF]); | |
} | |
/** | |
* Set schema version | |
* | |
* @param string $schemaVersion Facturae schema version to use | |
*/ | |
public function setSchemaVersion($schemaVersion) { | |
$this->version = $schemaVersion; | |
} | |
/** | |
* Set seller | |
* | |
* @param FacturaeParty $seller Seller information | |
*/ | |
public function setSeller($seller) { | |
$this->parties['seller'] = $seller; | |
} | |
/** | |
* Set buyer | |
* | |
* @param FacturaeParty $buyer Buyer information | |
*/ | |
public function setBuyer($buyer) { | |
$this->parties['buyer'] = $buyer; | |
} | |
/** | |
* Set invoice number | |
* | |
* @param string $serie Serie code of the invoice | |
* @param int|string $number Invoice number in given serie | |
*/ | |
public function setNumber($serie, $number) { | |
$this->header['serie'] = $serie; | |
$this->header['number'] = $number; | |
} | |
/** | |
* Set issue date | |
* | |
* @param int|string $date Issue date | |
*/ | |
public function setIssueDate($date) { | |
$this->header['issueDate'] = is_string($date) ? strtotime($date) : $date; | |
} | |
/** | |
* Set due date | |
* | |
* @param int|string $date Due date | |
*/ | |
public function setDueDate($date) { | |
$this->header['dueDate'] = is_string($date) ? strtotime($date) : $date; | |
} | |
/** | |
* Set billing period | |
* | |
* @param int|string $date Start date | |
* @param int|string $date End date | |
*/ | |
public function setBillingPeriod($startDate=NULL, $endDate=NULL) { | |
$d_start = is_string($startDate) ? strtotime($startDate) : $startDate; | |
$d_end = is_string($endDate) ? strtotime($endDate) : $endDate; | |
$this->header['startDate'] = $d_start; | |
$this->header['endDate'] = $d_end; | |
} | |
/** | |
* Set dates | |
* | |
* This is a shortcut for setting both issue and due date in a single line | |
* | |
* @param int|string $issueDate Issue date | |
* @param int|string $dueDate Due date | |
*/ | |
public function setDates($issueDate, $dueDate=NULL) { | |
$this->setIssueDate($issueDate); | |
$this->setDueDate($dueDate); | |
} | |
/** | |
* Set payment method | |
* | |
* @param string $method Payment method | |
* @param string $iban Bank account in case of bank transfer | |
*/ | |
public function setPaymentMethod($method=self::PAYMENT_CASH, $iban=NULL) { | |
$this->header['paymentMethod'] = $method; | |
if (!is_null($iban)) $iban = str_replace(" ", "", $iban); | |
$this->header['paymentIBAN'] = $iban; | |
} | |
/** | |
* Add item | |
* | |
* Adds an item row to invoice. The fist parameter ($desc), can be an string | |
* representing the item description or a 2 element array containing the item | |
* description and an additional string of information. | |
* | |
* @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 float $taxRate Tax rate | |
*/ | |
public function addItem($desc, $unitPrice=NULL, $quantity=1, $taxType=NULL, $taxRate=NULL) { | |
if ($desc instanceOf FacturaeItem) { | |
$item = $desc; | |
} else { | |
$item = new FacturaeItem([ | |
"name" => is_array($desc) ? $desc[0] : $desc, | |
"description" => is_array($desc) ? $desc[1] : NULL, | |
"quantity" => $quantity, | |
"unitPrice" => $unitPrice, | |
"taxes" => array($taxType => $taxRate) | |
]); | |
} | |
array_push($this->items, $item); | |
} | |
/** | |
* Add legal literal | |
* | |
* @param string $message Legal literal reference | |
*/ | |
public function addLegalLiteral($message) { | |
$this->legalLiterals[] = $message; | |
} | |
/** | |
* Get totals | |
* | |
* @return array Invoice totals | |
*/ | |
public function getTotals() { | |
// Define starting values | |
$totals = array( | |
"taxesOutputs" => array(), | |
"taxesWithheld" => array(), | |
"invoiceAmount" => 0, | |
"grossAmount" => 0, | |
"grossAmountBeforeTaxes" => 0, | |
"totalTaxesOutputs" => 0, | |
"totalTaxesWithheld" => 0 | |
); | |
// Run through every item | |
foreach ($this->items as $itemObj) { | |
$item = $itemObj->getData(); | |
$totals['invoiceAmount'] += $item['totalAmount']; | |
$totals['grossAmount'] += $item['grossAmount']; | |
$totals['totalTaxesOutputs'] += $item['totalTaxesOutputs']; | |
$totals['totalTaxesWithheld'] += $item['totalTaxesWithheld']; | |
// Get taxes | |
foreach (["taxesOutputs", "taxesWithheld"] as $taxGroup) { | |
foreach ($item[$taxGroup] as $type=>$tax) { | |
if (!isset($totals[$taxGroup][$type])) | |
$totals[$taxGroup][$type] = array(); | |
if (!isset($totals[$taxGroup][$type][$tax['rate']])) | |
$totals[$taxGroup][$type][$tax['rate']] = array("base"=>0, "amount"=>0); | |
$totals[$taxGroup][$type][$tax['rate']]['base'] += | |
$item['totalAmountWithoutTax']; | |
$totals[$taxGroup][$type][$tax['rate']]['amount'] += $tax['amount']; | |
} | |
} | |
} | |
// Fill rest of values | |
$totals['grossAmountBeforeTaxes'] = $totals['grossAmount']; | |
return $totals; | |
} | |
/** | |
* Set sign time | |
* | |
* @param int|string $time Time of the signature | |
*/ | |
public function setSignTime($time) { | |
$this->signTime = is_string($time) ? strtotime($time) : $time; | |
} | |
/** | |
* Set timestamp server | |
* | |
* @param string $server Timestamp Authority URL | |
* @param string $user TSA User | |
* @param string $pass TSA Password | |
*/ | |
public function setTimestampServer($server, $user=NULL, $pass=NULL) { | |
$this->timestampServer = $server; | |
$this->timestampUser = $user; | |
$this->timestampPass = $pass; | |
} | |
/** | |
* Load a PKCS#12 Certificate Store | |
* | |
* @param string $pkcs12File The certificate store file name | |
* @param string $pkcs12Pass Encryption password for unlocking the PKCS#12 file | |
* @return boolean Success | |
*/ | |
private function loadPkcs12($pkcs12File, $pkcs12Pass="") { | |
if (!is_file($pkcs12File)) return false; | |
// Extract public and private keys from store | |
if (openssl_pkcs12_read(file_get_contents($pkcs12File), $certs, $pkcs12Pass)) { | |
$this->publicKey = openssl_x509_read($certs['cert']); | |
$this->privateKey = openssl_pkey_get_private($certs['pkey']); | |
} | |
return (!empty($this->publicKey) && !empty($this->privateKey)); | |
} | |
/** | |
* Load 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 | |
* @return boolean Success | |
*/ | |
private function loadX509($publicPath, $privatePath, $passphrase="") { | |
if (is_file($publicPath) && is_file($privatePath)) { | |
$this->publicKey = openssl_x509_read(file_get_contents($publicPath)); | |
$this->privateKey = openssl_pkey_get_private( | |
file_get_contents($privatePath), | |
$passphrase | |
); | |
} | |
return (!empty($this->publicKey) && !empty($this->privateKey)); | |
} | |
/** | |
* 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 | |
*/ | |
public function sign($publicPath, $privatePath=NULL, $passphrase="", $policy=self::SIGN_POLICY_3_1) { | |
$this->publicKey = NULL; | |
$this->privateKey = NULL; | |
$this->signPolicy = $policy; | |
// Generate random IDs | |
$this->signatureID = $this->random(); | |
$this->signedInfoID = $this->random(); | |
$this->signedPropertiesID = $this->random(); | |
$this->signatureValueID = $this->random(); | |
$this->certificateID = $this->random(); | |
$this->referenceID = $this->random(); | |
$this->signatureSignedPropertiesID = $this->random(); | |
$this->signatureObjectID = $this->random(); | |
// Load public and private keys | |
if (empty($privatePath)) { | |
return $this->loadPkcs12($publicPath, $passphrase); | |
} else { | |
return $this->loadX509($publicPath, $privatePath, $passphrase); | |
} | |
} | |
/** | |
* Get XML NameSpaces | |
* | |
* NOTE: Should be defined in alphabetical order | |
* | |
* @return string XML NameSpaces | |
*/ | |
private 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#"'; | |
$xmlns = implode(' ', $xmlns); | |
return $xmlns; | |
} | |
/** | |
* Inject timestamp | |
* | |
* @param string $signedXml Signed XML document | |
* @return string Signed and timestamped XML document | |
*/ | |
private function injectTimestamp($signedXml) { | |
// Prepare data to timestamp | |
$payload = explode('<ds:SignatureValue', $signedXml)[1]; | |
$payload = explode('</ds:SignatureValue>', $payload)[0]; | |
$payload = '<ds:SignatureValue ' . $this->getNamespaces() . $payload . '</ds:SignatureValue>'; | |
// 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; | |
} | |
$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'); | |
} | |
// Extract TimeStamp from TimeStampRequest and inject into XML document | |
$timeStamp = substr($tsr, 9); | |
$tsXml = '<xades:UnsignedProperties Id="Signature' . $this->signatureID . '-UnsignedProperties' . $this->random() . '">' . | |
'<xades:UnsignedSignatureProperties>' . | |
'<xades:SignatureTimeStamp Id="Timestamp-' . $this->random() . '">' . | |
'<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315">' . | |
'</ds:CanonicalizationMethod>' . | |
'<xades:EncapsulatedTimeStamp>' . "\n" . | |
str_replace("\r", "", chunk_split(base64_encode($timeStamp), 76)) . | |
'</xades:EncapsulatedTimeStamp>' . | |
'</xades:SignatureTimeStamp>' . | |
'</xades:UnsignedSignatureProperties>' . | |
'</xades:UnsignedProperties>'; | |
$signedXml = str_replace('</xades:QualifyingProperties>', $tsXml . '</xades:QualifyingProperties>', $signedXml); | |
return $signedXml; | |
} | |
/** | |
* Inject signature | |
* | |
* @param string $xml Unsigned XML document | |
* @return string Signed XML document | |
*/ | |
private function injectSignature($xml) { | |
// Make sure we have all we need to sign the document | |
if (empty($this->publicKey) || empty($this->privateKey)) return $xml; | |
// Normalize document | |
$xml = str_replace("\r", "", $xml); | |
// Define namespace | |
$xmlns = $this->getNamespaces(); | |
// Prepare signed properties | |
$signTime = is_null($this->signTime) ? time() : $this->signTime; | |
$certData = openssl_x509_parse($this->publicKey); | |
$certDigest = openssl_x509_fingerprint($this->publicKey, "sha1", true); | |
$certDigest = base64_encode($certDigest); | |
$certIssuer = array(); | |
foreach ($certData['issuer'] as $item=>$value) { | |
$certIssuer[] = $item . '=' . $value; | |
} | |
$certIssuer = implode(',', $certIssuer); | |
// Generate signed properties | |
$prop = '<xades:SignedProperties Id="Signature' . $this->signatureID . | |
'-SignedProperties' . $this->signatureSignedPropertiesID . '">' . | |
'<xades:SignedSignatureProperties>' . | |
'<xades:SigningTime>' . date('c', $signTime) . '</xades:SigningTime>' . | |
'<xades:SigningCertificate>' . | |
'<xades:Cert>' . | |
'<xades:CertDigest>' . | |
'<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></ds:DigestMethod>' . | |
'<ds:DigestValue>' . $certDigest . '</ds:DigestValue>' . | |
'</xades:CertDigest>' . | |
'<xades:IssuerSerial>' . | |
'<ds:X509IssuerName>' . $certIssuer . '</ds:X509IssuerName>' . | |
'<ds:X509SerialNumber>' . $certData['serialNumber'] . '</ds:X509SerialNumber>' . | |
'</xades:IssuerSerial>' . | |
'</xades:Cert>' . | |
'</xades:SigningCertificate>' . | |
'<xades:SignaturePolicyIdentifier>' . | |
'<xades:SignaturePolicyId>' . | |
'<xades:SigPolicyId>' . | |
'<xades:Identifier>' . $this->signPolicy['url'] . '</xades:Identifier>' . | |
'<xades:Description>' . $this->signPolicy['name'] . '</xades:Description>' . | |
'</xades:SigPolicyId>' . | |
'<xades:SigPolicyHash>' . | |
'<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></ds:DigestMethod>' . | |
'<ds:DigestValue>' . $this->signPolicy['digest'] . '</ds:DigestValue>' . | |
'</xades:SigPolicyHash>' . | |
'</xades:SignaturePolicyId>' . | |
'</xades:SignaturePolicyIdentifier>' . | |
'<xades:SignerRole>' . | |
'<xades:ClaimedRoles>' . | |
'<xades:ClaimedRole>emisor</xades:ClaimedRole>' . | |
'</xades:ClaimedRoles>' . | |
'</xades:SignerRole>' . | |
'</xades:SignedSignatureProperties>' . | |
'<xades:SignedDataObjectProperties>' . | |
'<xades:DataObjectFormat ObjectReference="#Reference-ID-' . $this->referenceID . '">' . | |
'<xades:Description>Factura electrónica</xades:Description>' . | |
'<xades:MimeType>text/xml</xades:MimeType>' . | |
'</xades:DataObjectFormat>' . | |
'</xades:SignedDataObjectProperties>' . | |
'</xades:SignedProperties>'; | |
// Prepare key info | |
$publicPEM = ""; | |
openssl_x509_export($this->publicKey, $publicPEM); | |
$publicPEM = str_replace("-----BEGIN CERTIFICATE-----", "", $publicPEM); | |
$publicPEM = str_replace("-----END CERTIFICATE-----", "", $publicPEM); | |
$publicPEM = str_replace("\n", "", $publicPEM); | |
$publicPEM = str_replace("\r", "", chunk_split($publicPEM, 76)); | |
$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 = '<ds:KeyInfo Id="Certificate' . $this->certificateID . '">' . "\n" . | |
'<ds:X509Data>' . "\n" . | |
'<ds:X509Certificate>' . "\n" . $publicPEM . '</ds:X509Certificate>' . "\n" . | |
'</ds:X509Data>' . "\n" . | |
'<ds:KeyValue>' . "\n" . | |
'<ds:RSAKeyValue>' . "\n" . | |
'<ds:Modulus>' . "\n" . $modulus . '</ds:Modulus>' . "\n" . | |
'<ds:Exponent>' . $exponent . '</ds:Exponent>' . "\n" . | |
'</ds:RSAKeyValue>' . "\n" . | |
'</ds:KeyValue>' . "\n" . | |
'</ds:KeyInfo>'; | |
// Calculate digests | |
$propDigest = base64_encode(sha1(str_replace('<xades:SignedProperties', | |
'<xades:SignedProperties ' . $xmlns, $prop), true)); | |
$kInfoDigest = base64_encode(sha1(str_replace('<ds:KeyInfo', | |
'<ds:KeyInfo ' . $xmlns, $kInfo), true)); | |
$documentDigest = base64_encode(sha1($xml, true)); | |
// Generate SignedInfo | |
$sInfo = '<ds:SignedInfo Id="Signature-SignedInfo' . $this->signedInfoID . '">' . "\n" . | |
'<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315">' . | |
'</ds:CanonicalizationMethod>' . "\n" . | |
'<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1">' . | |
'</ds:SignatureMethod>' . "\n" . | |
'<ds:Reference Id="SignedPropertiesID' . $this->signedPropertiesID . '" ' . | |
'Type="http://uri.etsi.org/01903#SignedProperties" ' . | |
'URI="#Signature' . $this->signatureID . '-SignedProperties' . | |
$this->signatureSignedPropertiesID . '">' . "\n" . | |
'<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1">' . | |
'</ds:DigestMethod>' . "\n" . | |
'<ds:DigestValue>' . $propDigest . '</ds:DigestValue>' . "\n" . | |
'</ds:Reference>' . "\n" . | |
'<ds:Reference URI="#Certificate' . $this->certificateID . '">' . "\n" . | |
'<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1">' . | |
'</ds:DigestMethod>' . "\n" . | |
'<ds:DigestValue>' . $kInfoDigest . '</ds:DigestValue>' . "\n" . | |
'</ds:Reference>' . "\n" . | |
'<ds:Reference Id="Reference-ID-' . $this->referenceID . '" URI="">' . "\n" . | |
'<ds:Transforms>' . "\n" . | |
'<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature">' . | |
'</ds:Transform>' . "\n" . | |
'</ds:Transforms>' . "\n" . | |
'<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1">' . | |
'</ds:DigestMethod>' . "\n" . | |
'<ds:DigestValue>' . $documentDigest . '</ds:DigestValue>' . "\n" . | |
'</ds:Reference>' . "\n" . | |
'</ds:SignedInfo>'; | |
// Calculate signature | |
$signaturePayload = str_replace('<ds:SignedInfo', '<ds:SignedInfo ' . $xmlns, $sInfo); | |
openssl_sign($signaturePayload, $signatureResult, $this->privateKey); | |
$signatureResult = chunk_split(base64_encode($signatureResult), 76); | |
$signatureResult = str_replace("\r", "", $signatureResult); | |
// Make signature | |
$sig = '<ds:Signature xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Id="Signature' . $this->signatureID . '">' . "\n" . | |
$sInfo . "\n" . | |
'<ds:SignatureValue Id="SignatureValue' . $this->signatureValueID . '">' . "\n" . | |
$signatureResult . | |
'</ds:SignatureValue>' . "\n" . | |
$kInfo . "\n" . | |
'<ds:Object Id="Signature' . $this->signatureID . '-Object' . $this->signatureObjectID . '">' . | |
'<xades:QualifyingProperties Target="#Signature' . $this->signatureID . '">' . | |
$prop . | |
'</xades:QualifyingProperties>' . | |
'</ds:Object>' . | |
'</ds:Signature>'; | |
// Inject signature | |
$xml = str_replace('</fe:Facturae>', $sig . '</fe:Facturae>', $xml); | |
// Inject timestamp | |
if (!empty($this->timestampServer)) { | |
$xml = $this->injectTimestamp($xml); | |
} | |
return $xml; | |
} | |
/** | |
* Export | |
* | |
* Get Facturae XML data | |
* | |
* @param string $filePath Path to save invoice | |
* @return string|int XML data|Written file bytes | |
*/ | |
public function export($filePath=NULL) { | |
// Prepare document | |
$xml = '<fe:Facturae xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ' . | |
'xmlns:fe="' . self::$SCHEMA_NS[$this->version] . '">'; | |
$totals = $this->getTotals(); | |
// Add header | |
$batchIdentifier = $this->parties['seller']->taxNumber . | |
$this->header['number'] . $this->header['serie']; | |
$xml .= '<FileHeader>' . | |
'<SchemaVersion>' . $this->version .'</SchemaVersion>' . | |
'<Modality>I</Modality>' . | |
'<InvoiceIssuerType>EM</InvoiceIssuerType>' . | |
'<Batch>' . | |
'<BatchIdentifier>' . $batchIdentifier . '</BatchIdentifier>' . | |
'<InvoicesCount>1</InvoicesCount>' . | |
'<TotalInvoicesAmount>' . | |
'<TotalAmount>' . $this->padTotal($totals['invoiceAmount']) . '</TotalAmount>' . | |
'</TotalInvoicesAmount>' . | |
'<TotalOutstandingAmount>' . | |
'<TotalAmount>' . $this->padTotal($totals['invoiceAmount']) . '</TotalAmount>' . | |
'</TotalOutstandingAmount>' . | |
'<TotalExecutableAmount>' . | |
'<TotalAmount>' . $this->padTotal($totals['invoiceAmount']) . '</TotalAmount>' . | |
'</TotalExecutableAmount>' . | |
'<InvoiceCurrencyCode>' . $this->currency . '</InvoiceCurrencyCode>' . | |
'</Batch>' . | |
'</FileHeader>'; | |
// Add parties | |
$xml .= '<Parties>' . | |
'<SellerParty>' . $this->parties['seller']->getXML($this->version) . '</SellerParty>' . | |
'<BuyerParty>' . $this->parties['buyer']->getXML($this->version) . '</BuyerParty>' . | |
'</Parties>'; | |
// Add invoice data | |
$xml .= '<Invoices>' . | |
'<Invoice>' . | |
'<InvoiceHeader>' . | |
'<InvoiceNumber>' . $this->header['number'] . '</InvoiceNumber>' . | |
'<InvoiceSeriesCode>' . $this->header['serie'] . '</InvoiceSeriesCode>' . | |
'<InvoiceDocumentType>FC</InvoiceDocumentType>' . | |
'<InvoiceClass>OO</InvoiceClass>' . | |
'</InvoiceHeader>' . | |
'<InvoiceIssueData>' . | |
'<IssueDate>' . date('Y-m-d', $this->header['issueDate']) . '</IssueDate>'; | |
if (!is_null($this->header['startDate'])) { | |
$xml .= '<InvoicingPeriod>' . | |
'<StartDate>' . date('Y-m-d', $this->header['startDate']) . '</StartDate>' . | |
'<EndDate>' . date('Y-m-d', $this->header['endDate']) . '</EndDate>' . | |
'</InvoicingPeriod>'; | |
} | |
$xml .= '<InvoiceCurrencyCode>' . $this->currency . '</InvoiceCurrencyCode>' . | |
'<TaxCurrencyCode>' . $this->currency . '</TaxCurrencyCode>' . | |
'<LanguageName>es</LanguageName>' . | |
'</InvoiceIssueData>'; | |
// Add invoice taxes | |
foreach (["taxesOutputs", "taxesWithheld"] as $i=>$taxesGroup) { | |
if (count($totals[$taxesGroup]) == 0) continue; | |
$xmlTag = ucfirst($taxesGroup); // Just capitalize variable name | |
$xml .= "<$xmlTag>"; | |
foreach ($totals[$taxesGroup] as $type=>$taxRows) { | |
foreach ($taxRows as $rate=>$tax) { | |
$xml .= '<Tax>' . | |
'<TaxTypeCode>' . $type . '</TaxTypeCode>' . | |
'<TaxRate>' . $rate . '</TaxRate>' . | |
'<TaxableBase>' . | |
'<TotalAmount>' . $this->padTotal($tax['base']) . '</TotalAmount>' . | |
'</TaxableBase>' . | |
'<TaxAmount>' . | |
'<TotalAmount>' . $this->padTotal($tax['amount']) . '</TotalAmount>' . | |
'</TaxAmount>' . | |
'</Tax>'; | |
} | |
} | |
$xml .= "</$xmlTag>"; | |
} | |
// Add invoice totals | |
$xml .= '<InvoiceTotals>' . | |
'<TotalGrossAmount>' . $this->padTotal($totals['grossAmount']) . '</TotalGrossAmount>' . | |
'<TotalGeneralDiscounts>0.00</TotalGeneralDiscounts>' . | |
'<TotalGeneralSurcharges>0.00</TotalGeneralSurcharges>' . | |
'<TotalGrossAmountBeforeTaxes>' . $this->padTotal($totals['grossAmountBeforeTaxes']) . '</TotalGrossAmountBeforeTaxes>' . | |
'<TotalTaxOutputs>' . $this->padTotal($totals['totalTaxesOutputs']) . '</TotalTaxOutputs>' . | |
'<TotalTaxesWithheld>' . $this->padTotal($totals['totalTaxesWithheld']) . '</TotalTaxesWithheld>' . | |
'<InvoiceTotal>' . $this->padTotal($totals['invoiceAmount']) . '</InvoiceTotal>' . | |
'<TotalOutstandingAmount>' . $this->padTotal($totals['invoiceAmount']) . '</TotalOutstandingAmount>' . | |
'<TotalExecutableAmount>' . $this->padTotal($totals['invoiceAmount']) . '</TotalExecutableAmount>' . | |
'</InvoiceTotals>'; | |
// Add invoice items | |
$xml .= '<Items>'; | |
foreach ($this->items as $itemObj) { | |
$item = $itemObj->getData(); | |
$xml .= '<InvoiceLine>' . | |
'<ItemDescription>' . $item['name'] . '</ItemDescription>' . | |
'<Quantity>' . $this->padTotal($item['quantity']) . '</Quantity>' . | |
'<UnitOfMeasure>01</UnitOfMeasure>' . | |
'<UnitPriceWithoutTax>' . $this->padItem($item['unitPriceWithoutTax']) . '</UnitPriceWithoutTax>' . | |
'<TotalCost>' . $this->padTotalItem($item['totalAmountWithoutTax']) . '</TotalCost>' . | |
'<GrossAmount>' . $this->padTotalItem($item['grossAmount']) . '</GrossAmount>'; | |
// Add item taxes | |
// NOTE: As you can see here, taxesWithheld is before taxesOutputs. | |
// This is intentional, as most official administrations would mark the | |
// invoice as invalid XML if the order is incorrect. | |
foreach (["taxesWithheld", "taxesOutputs"] as $taxesGroup) { | |
if (count($item[$taxesGroup]) == 0) continue; | |
$xmlTag = ucfirst($taxesGroup); // Just capitalize variable name | |
$xml .= "<$xmlTag>"; | |
foreach ($item[$taxesGroup] as $type=>$tax) { | |
$xml .= '<Tax>' . | |
'<TaxTypeCode>' . $type . '</TaxTypeCode>' . | |
'<TaxRate>' . $tax['rate'] . '</TaxRate>' . | |
'<TaxableBase>' . | |
'<TotalAmount>' . $this->padTotal($item['totalAmountWithoutTax']) . '</TotalAmount>' . | |
'</TaxableBase>' . | |
'<TaxAmount>' . | |
'<TotalAmount>' . $this->padTotal($tax['amount']) . '</TotalAmount>' . | |
'</TaxAmount>' . | |
'</Tax>'; | |
} | |
$xml .= "</$xmlTag>"; | |
} | |
// Add item additional information | |
if (!is_null($item['description'])) { | |
$xml .= '<AdditionalLineItemInformation>' . $item['description'] . '</AdditionalLineItemInformation>'; | |
} | |
$xml .= '</InvoiceLine>'; | |
} | |
$xml .= '</Items>'; | |
// Add payment details | |
if (!is_null($this->header['paymentMethod'])) { | |
$dueDate = is_null($this->header['dueDate']) ? | |
$this->header['issueDate'] : | |
$this->header['dueDate']; | |
$xml .= '<PaymentDetails>' . | |
'<Installment>' . | |
'<InstallmentDueDate>' . date('Y-m-d', $dueDate) . '</InstallmentDueDate>' . | |
'<InstallmentAmount>' . $this->padTotal($totals['invoiceAmount']) . '</InstallmentAmount>' . | |
'<PaymentMeans>' . $this->header['paymentMethod'] . '</PaymentMeans>'; | |
if ($this->header['paymentMethod'] == self::PAYMENT_TRANSFER) { | |
$xml .= '<AccountToBeCredited>' . | |
'<IBAN>' . $this->header['paymentIBAN'] . '</IBAN>' . | |
'</AccountToBeCredited>'; | |
} | |
$xml .= '</Installment>' . | |
'</PaymentDetails>'; | |
} | |
// Add legal literals | |
if (count($this->legalLiterals) > 0) { | |
$xml .= '<LegalLiterals>'; | |
foreach ($this->legalLiterals as $reference) { | |
$xml .= '<LegalReference>' . $reference . '</LegalReference>'; | |
} | |
$xml .= '</LegalLiterals>'; | |
} | |
// Close invoice and document | |
$xml .= '</Invoice></Invoices></fe:Facturae>'; | |
// Add signature | |
$xml = $this->injectSignature($xml); | |
// Prepend content type | |
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $xml; | |
// Save document | |
if (!is_null($filePath)) return file_put_contents($filePath, $xml); | |
return $xml; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment