diff --git a/doc/ejemplos/envio-faceb2b.md b/doc/ejemplos/envio-faceb2b.md index cd76604..ffd0c94 100644 --- a/doc/ejemplos/envio-faceb2b.md +++ b/doc/ejemplos/envio-faceb2b.md @@ -13,7 +13,7 @@ require_once 'ruta/hacia/vendor/autoload.php'; use josemmo\Facturae\Facturae; use josemmo\Facturae\FacturaeFile; -use josemmo\Facturae\Face\FaceB2bClient; +use josemmo\Facturae\Face\Faceb2bClient; // Creamos una factura válida (ver ejemplo simple) $fac = new Facturae(); @@ -24,7 +24,7 @@ $invoice = new FacturaeFile(); $invoice->loadData($fac->export(), "test-invoice.xsig"); // Creamos una conexión con FACe -$faceb2b = new FaceB2bClient("path_to_certificate.pfx", null, "passphrase"); +$faceb2b = new Faceb2bClient("path_to_certificate.pfx", null, "passphrase"); //$faceb2b->setProduction(false); // Descomenta esta línea para entorno de desarrollo // Subimos la factura a FACeB2B diff --git a/src/Facturae.php b/src/Facturae.php index 2ef97e4..d292831 100644 --- a/src/Facturae.php +++ b/src/Facturae.php @@ -10,7 +10,7 @@ * Class for creating electronic invoices that comply with the Spanish FacturaE format. */ class Facturae { - const VERSION = "1.7.7"; + const VERSION = "1.7.8"; const USER_AGENT = "FacturaePHP/" . self::VERSION; const SCHEMA_3_2 = "3.2"; diff --git a/src/FacturaeItem.php b/src/FacturaeItem.php index 4739d75..18077c1 100644 --- a/src/FacturaeItem.php +++ b/src/FacturaeItem.php @@ -55,6 +55,7 @@ public function __construct($properties=array()) { // Catalog taxes property (backward compatibility) if (isset($properties['taxes'])) { foreach ($properties['taxes'] as $r=>$tax) { + if (empty($r)) continue; if (!is_array($tax)) $tax = array("rate"=>$tax, "amount"=>0); if (!isset($tax['isWithheld'])) { // Get value by default $tax['isWithheld'] = Facturae::isWithheldTax($r); @@ -115,18 +116,26 @@ public function getData($fac) { $unitPriceWithoutTax = $this->unitPriceWithoutTax; $totalAmountWithoutTax = $quantity * $unitPriceWithoutTax; + // NOTE: Special case for Schema v3.2 + // In this schema, an item's total cost () has 6 decimals but taxable bases only have 2. + // We round the first property when using line precision mode to prevent the pair from having different values. + if ($fac->getSchemaVersion() === Facturae::SCHEMA_3_2) { + $totalAmountWithoutTax = $fac->pad($totalAmountWithoutTax, 'Tax/TaxableBase', Facturae::PRECISION_LINE); + } + // Process charges and discounts $grossAmount = $totalAmountWithoutTax; foreach (['discounts', 'charges'] as $i=>$groupTag) { $factor = ($i == 0) ? -1 : 1; foreach ($this->{$groupTag} as $group) { if (isset($group['rate'])) { - $rate = $group['rate']; + $rate = $fac->pad($group['rate'], 'DiscountCharge/Rate', Facturae::PRECISION_LINE); $amount = $totalAmountWithoutTax * ($rate / 100); } else { $rate = null; $amount = $group['amount']; } + $amount = $fac->pad($amount, 'DiscountCharge/Amount', Facturae::PRECISION_LINE); $addProps[$groupTag][] = array( "reason" => $group['reason'], "rate" => $rate, @@ -141,12 +150,13 @@ public function getData($fac) { $totalTaxesWithheld = 0; foreach (['taxesOutputs', 'taxesWithheld'] as $i=>$taxesGroup) { foreach ($this->{$taxesGroup} as $type=>$tax) { - $taxRate = $tax['rate']; - $surcharge = $tax['surcharge']; - $taxAmount = $grossAmount * ($taxRate / 100); - $surchargeAmount = $grossAmount * ($surcharge / 100); + $taxRate = $fac->pad($tax['rate'], 'Tax/TaxRate', Facturae::PRECISION_LINE); + $surcharge = $fac->pad($tax['surcharge'], 'Tax/EquivalenceSurcharge', Facturae::PRECISION_LINE); + $taxableBase = $fac->pad($grossAmount, 'Tax/TaxableBase', Facturae::PRECISION_LINE); + $taxAmount = $fac->pad($taxableBase*($taxRate/100), 'Tax/TaxAmount', Facturae::PRECISION_LINE); + $surchargeAmount = $fac->pad($taxableBase*($surcharge/100), 'Tax/EquivalenceSurchargeAmount', Facturae::PRECISION_LINE); $addProps[$taxesGroup][$type] = array( - "base" => $grossAmount, + "base" => $taxableBase, "rate" => $taxRate, "surcharge" => $surcharge, "amount" => $taxAmount, diff --git a/src/FacturaeTraits/PropertiesTrait.php b/src/FacturaeTraits/PropertiesTrait.php index d1f566b..f62a1f2 100644 --- a/src/FacturaeTraits/PropertiesTrait.php +++ b/src/FacturaeTraits/PropertiesTrait.php @@ -832,7 +832,7 @@ public function getTotals() { if (!isset($totals[$taxGroup][$type])) { $totals[$taxGroup][$type] = array(); } - $taxKey = $tax['rate'] . ":" . $tax['surcharge']; + $taxKey = floatval($tax['rate']) . ":" . floatval($tax['surcharge']); if (!isset($totals[$taxGroup][$type][$taxKey])) { $totals[$taxGroup][$type][$taxKey] = array( "base" => 0, diff --git a/tests/PrecisionTest.php b/tests/PrecisionTest.php index 344c480..19e5014 100644 --- a/tests/PrecisionTest.php +++ b/tests/PrecisionTest.php @@ -5,18 +5,37 @@ use josemmo\Facturae\FacturaeItem; final class PrecisionTest extends AbstractTest { - private function _runTest($schema, $precision) { + /** + * @param string $schema Invoice schema + * @param string $precision Rounding precision mode + */ + private function runTestWithParams($schema, $precision) { $fac = $this->getBaseInvoice($schema); $fac->setPrecision($precision); // Add items - $amounts = [37.76, 26.8, 5.5]; - foreach ($amounts as $i=>$amount) { + $items = [ + ['unitPriceWithoutTax'=>16.90, 'quantity'=>3.40, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>1.20, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.00, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.75, 'tax'=>10], + ['unitPriceWithoutTax'=>6.90, 'quantity'=>2.65, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>1.80, 'tax'=>10], + ['unitPriceWithoutTax'=>8.90, 'quantity'=>1.95, 'tax'=>10], + ['unitPriceWithoutTax'=>3.00, 'quantity'=>11.30, 'tax'=>10], + ['unitPriceWithoutTax'=>5.90, 'quantity'=>46.13, 'tax'=>10], + ['unitPriceWithoutTax'=>37.76, 'quantity'=>1, 'tax'=>21], + ['unitPriceWithoutTax'=>13.40, 'quantity'=>2, 'tax'=>21], + ['unitPriceWithoutTax'=>5.50, 'quantity'=>1, 'tax'=>21] + ]; + foreach ($items as $i=>$item) { $fac->addItem(new FacturaeItem([ "name" => "Línea de producto #$i", - "quantity" => 1, - "unitPriceWithoutTax" => $amount, - "taxes" => [Facturae::TAX_IVA => 21] + "unitPriceWithoutTax" => $item['unitPriceWithoutTax'], + "quantity" => $item['quantity'], + "taxes" => [ + Facturae::TAX_IVA => $item['tax'] + ] ])); } @@ -32,15 +51,45 @@ private function _runTest($schema, $precision) { $actualTotal = floatval($beforeTaxes + $taxOutputs - $taxesWithheld); $this->assertEqualsWithDelta($actualTotal, $invoiceTotal, 0.000000001, 'Incorrect invoice totals element'); - // Validate total invoice amount - if ($precision === Facturae::PRECISION_INVOICE) { - $expectedTotal = round(array_sum($amounts)*1.21, 2); - } else { - $expectedTotal = array_sum(array_map(function($amount) { - return round($amount*1.21, 2); - }, $amounts)); + // Calculate expected invoice totals + $expectedTotal = 0; + $expectedTaxes = []; + $decimals = ($precision === Facturae::PRECISION_INVOICE) ? 15 : 2; + foreach ($items as $item) { + if (!isset($expectedTaxes[$item['tax']])) { + $expectedTaxes[$item['tax']] = [ + "base" => 0, + "amount" => 0 + ]; + } + $taxableBase = round($item['unitPriceWithoutTax'] * $item['quantity'], $decimals); + $taxAmount = round($taxableBase * ($item['tax']/100), $decimals); + $expectedTotal += $taxableBase + $taxAmount; + $expectedTaxes[$item['tax']]['base'] += $taxableBase; + $expectedTaxes[$item['tax']]['amount'] += $taxAmount; + } + foreach ($expectedTaxes as $key=>$value) { + $expectedTaxes[$key]['base'] = round($value['base'], 2); + $expectedTaxes[$key]['amount'] = round($value['amount'], 2); + } + $expectedTotal = round($expectedTotal, 2); + + // Validate invoice total + // NOTE: When in invoice precision mode, we use a 1 cent tolerance as this mode prioritizes accurate invoice total + // over invoice lines totals. This is the maximum tolerance allowed by the FacturaE specification. + $tolerance = ($precision === Facturae::PRECISION_INVOICE) ? 0.01 : 0.000000001; + $this->assertEqualsWithDelta($expectedTotal, $invoiceTotal, $tolerance, 'Incorrect total invoice amount'); + + // Validate tax totals + foreach ($invoiceXml->TaxesOutputs->Tax as $taxNode) { + $rate = (float) $taxNode->TaxRate; + $actualBase = (float) $taxNode->TaxableBase->TotalAmount; + $actualAmount = (float) $taxNode->TaxAmount->TotalAmount; + $expectedBase = $expectedTaxes[$rate]['base']; + $expectedAmount = $expectedTaxes[$rate]['amount']; + $this->assertEqualsWithDelta($expectedBase, $actualBase, 0.000000001, "Incorrect taxable base for $rate% rate"); + $this->assertEqualsWithDelta($expectedAmount, $actualAmount, 0.000000001, "Incorrect tax amount for $rate% rate"); } - $this->assertEqualsWithDelta($expectedTotal, $invoiceTotal, 0.000000001, 'Incorrect total invoice amount'); } @@ -49,7 +98,7 @@ private function _runTest($schema, $precision) { */ public function testLinePrecision() { foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { - $this->_runTest($schema, Facturae::PRECISION_LINE); + $this->runTestWithParams($schema, Facturae::PRECISION_LINE); } } @@ -58,8 +107,8 @@ public function testLinePrecision() { * Test invoice precision */ public function testInvoicePrecision() { - foreach ([Facturae::SCHEMA_3_2, Facturae::SCHEMA_3_2_1] as $schema) { - $this->_runTest($schema, Facturae::PRECISION_INVOICE); + foreach ([Facturae::SCHEMA_3_2_1] as $schema) { + $this->runTestWithParams($schema, Facturae::PRECISION_INVOICE); } } }