From 00858bc08fbc5f40162c422464dce4dfc854da57 Mon Sep 17 00:00:00 2001 From: Carlos C Soto Date: Wed, 10 Aug 2022 23:45:04 -0500 Subject: [PATCH 1/2] Fix SumasConceptos with Exentos - Add exentos property to SumasConceptos - Add writeExentos to SumasConceptosWriter - Test CFDI 3.3 and CFDI 4.0 behavior on when to write or not exentos node --- docs/CHANGELOG.md | 8 ++ .../SumasConceptos/SumasConceptos.php | 35 +++++- .../SumasConceptos/SumasConceptosWriter.php | 37 ++++-- ...eateComprobanteWithOnlyExentosCaseTest.php | 111 ++++++++++++++++++ .../SumasConceptos/SumasConceptosTest.php | 69 +++++++++-- .../SumasConceptosWriterTestTrait.php | 80 +++++++++---- 6 files changed, 297 insertions(+), 43 deletions(-) create mode 100644 tests/CfdiUtilsTests/CreateComprobanteWithOnlyExentosCaseTest.php diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80fc83bd..c33d0bf5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,14 @@ - Merge methods from `\CfdiUtils\Nodes\NodeHasValueInterface` into `\CfdiUtils\Nodes\NodeInterface`. - Remove deprecated constant `CfdiUtils\Retenciones\Retenciones::RET_NAMESPACE`. +## Version 2.23.3 2022-08-11 + +Fix CFDI 4.0, must include `Comprobante/Impuestos/Traslados/Traslado@TipoFactor=Exento` when exists at least one +node `Comprobante/Conceptos/Concepto/Impuestos/Traslados/Traslado@TipoFactor=Exento`. +The node must contain attribute `TipoFactor=Exento` and the rounded sum of the attributes `Base` +grouped by attribute `Impuesto`. +Thanks `BrodyAG` for noticing this issue, and `@yairtestas` for your guidance to find the solution. + ## Version 2.23.2 2022-06-29 Use `Symfony/Process` instead of `ShellExec`. diff --git a/src/CfdiUtils/SumasConceptos/SumasConceptos.php b/src/CfdiUtils/SumasConceptos/SumasConceptos.php index d05b0749..cd501183 100644 --- a/src/CfdiUtils/SumasConceptos/SumasConceptos.php +++ b/src/CfdiUtils/SumasConceptos/SumasConceptos.php @@ -26,6 +26,11 @@ class SumasConceptos */ private $traslados = []; + /** + * @var array + */ + private $exentos = []; + /** * @var array */ @@ -109,7 +114,9 @@ private function addConcepto(NodeInterface $concepto) $traslados = $concepto->searchNodes('cfdi:Impuestos', 'cfdi:Traslados', 'cfdi:Traslado'); foreach ($traslados as $traslado) { - if ('Exento' !== $traslado['TipoFactor']) { + if ('Exento' === $traslado['TipoFactor']) { + $this->addExento($traslado); + } else { $this->addTraslado($traslado); } } @@ -168,6 +175,19 @@ private function addTraslado(NodeInterface $traslado) $this->traslados[$key]['Base'] += (float) $traslado['Base']; } + private function addExento(NodeInterface $exento) + { + $key = $this->impuestoKey($exento['Impuesto'], $exento['TipoFactor'], ''); + if (! array_key_exists($key, $this->exentos)) { + $this->exentos[$key] = [ + 'TipoFactor' => $exento['TipoFactor'], + 'Impuesto' => $exento['Impuesto'], + 'Base' => 0.0, + ]; + } + $this->exentos[$key]['Base'] += (float) $exento['Base']; + } + private function addRetencion(NodeInterface $retencion) { $key = $this->impuestoKey($retencion['Impuesto']); @@ -212,6 +232,14 @@ public function getTraslados(): array return $this->traslados; } + /** + * @return array + */ + public function getExentos(): array + { + return $this->exentos; + } + /** * @return array */ @@ -225,6 +253,11 @@ public function hasTraslados(): bool return ([] !== $this->traslados); } + public function hasExentos(): bool + { + return ([] !== $this->exentos); + } + public function hasRetenciones(): bool { return ([] !== $this->retenciones); diff --git a/src/CfdiUtils/SumasConceptos/SumasConceptosWriter.php b/src/CfdiUtils/SumasConceptos/SumasConceptosWriter.php index 182cc1bb..b6137801 100644 --- a/src/CfdiUtils/SumasConceptos/SumasConceptosWriter.php +++ b/src/CfdiUtils/SumasConceptos/SumasConceptosWriter.php @@ -21,6 +21,9 @@ class SumasConceptosWriter /** @var bool */ private $writeImpuestoBase; + /** @var bool */ + private $writeExentos; + /** * Writer constructor. * @param Comprobante33|Comprobante40 $comprobante @@ -34,8 +37,10 @@ public function __construct( ) { if ($comprobante instanceof Comprobante33) { $this->writeImpuestoBase = false; + $this->writeExentos = false; } elseif ($comprobante instanceof Comprobante40) { $this->writeImpuestoBase = true; + $this->writeExentos = true; } else { throw new InvalidArgumentException( 'The argument $comprobante must be a Comprobante (CFDI 3.3 or CFDI 4.0) element' @@ -69,7 +74,10 @@ private function putImpuestosNode(): void // obtain node reference $impuestos = $this->comprobante->getImpuestos(); // if there is nothing to write then remove the children and exit - if (! $this->sumas->hasTraslados() && ! $this->sumas->hasRetenciones()) { + if (! $this->sumas->hasTraslados() + && ! $this->sumas->hasRetenciones() + && ! ($this->writeExentos && $this->sumas->hasExentos()) + ) { $this->comprobante->children()->remove($impuestos); return; } @@ -79,14 +87,19 @@ private function putImpuestosNode(): void if ($this->sumas->hasTraslados()) { $impuestos['TotalImpuestosTrasladados'] = $this->format($this->sumas->getImpuestosTrasladados()); $impuestos->getTraslados()->multiTraslado( - ...$this->getImpuestosContents($this->sumas->getTraslados(), $this->writeImpuestoBase) + ...$this->getImpuestosContents($this->sumas->getTraslados(), $this->writeImpuestoBase, true) + ); + } + if ($this->writeExentos && $this->sumas->hasExentos()) { + $impuestos->getTraslados()->multiTraslado( + ...$this->getImpuestosContents($this->sumas->getExentos(), $this->writeImpuestoBase, false) ); } // add retenciones when needed if ($this->sumas->hasRetenciones()) { $impuestos['TotalImpuestosRetenidos'] = $this->format($this->sumas->getImpuestosRetenidos()); $impuestos->getRetenciones()->multiRetencion( - ...$this->getImpuestosContents($this->sumas->getRetenciones(), false) + ...$this->getImpuestosContents($this->sumas->getRetenciones(), false, true) ); } } @@ -110,16 +123,15 @@ private function putComplementoImpuestoLocalSumas(): void $impLocal->attributes()->set('TotaldeTraslados', $this->format($this->sumas->getLocalesImpuestosTrasladados())); } - private function getImpuestosContents(array $impuestos, bool $hasBase): array + private function getImpuestosContents(array $impuestos, bool $hasBase, bool $hasImporte): array { $return = []; foreach ($impuestos as $impuesto) { - $impuesto['Base'] = $this->format($impuesto['Base'] ?? 0); - $impuesto['Importe'] = $this->format($impuesto['Importe']); - if (! $hasBase) { - unset($impuesto['Base']); - } - $return[] = $impuesto; + $impuesto['Base'] = ($hasBase) ? $this->format($impuesto['Base'] ?? 0) : null; + $impuesto['Importe'] = ($hasImporte) ? $this->format($impuesto['Importe']) : null; + $return[] = array_filter($impuesto, function ($value): bool { + return null !== $value; + }); } return $return; } @@ -154,4 +166,9 @@ public function hasWriteImpuestoBase(): bool { return $this->writeImpuestoBase; } + + public function hasWriteExentos(): bool + { + return $this->writeExentos; + } } diff --git a/tests/CfdiUtilsTests/CreateComprobanteWithOnlyExentosCaseTest.php b/tests/CfdiUtilsTests/CreateComprobanteWithOnlyExentosCaseTest.php new file mode 100644 index 00000000..f38c5bfe --- /dev/null +++ b/tests/CfdiUtilsTests/CreateComprobanteWithOnlyExentosCaseTest.php @@ -0,0 +1,111 @@ +comprobante(); + $comprobante->addConcepto([ + 'ClaveProdServ' => '01010101', + 'NoIdentificacion' => 'FOO', + 'Cantidad' => '1', + 'ClaveUnidad' => 'E48', + 'Descripcion' => 'HONORARIOS MEDICOS', + 'ValorUnitario' => '617.000000', + 'Importe' => '617.000000', + 'Descuento' => '144.271240', + 'ObjetoImp' => '02', + ])->addTraslado([ + 'Impuesto' => '002', + 'TipoFactor' => 'Exento', + ]); + + // test sumasConceptos + + $precision = 2; + $sumasConceptos = $creator->buildSumasConceptos($precision); + $this->assertTrue($sumasConceptos->hasExentos()); + $this->assertFalse($sumasConceptos->hasTraslados()); + $this->assertFalse($sumasConceptos->hasRetenciones()); + + $expectedExentos = [ + '002:Exento:' => [ + 'TipoFactor' => 'Exento', + 'Impuesto' => '002', + 'Base' => 0, + ], + ]; + + $this->assertEquals($expectedExentos, $sumasConceptos->getExentos()); + + // test cfdi:Impuestos XML does not exists + + $creator->addSumasConceptos($sumasConceptos, $precision); + + $this->assertNull($comprobante->searchNode('cfdi:Impuestos')); + } + + public function testCreateCcomprobante40WithOnlyExentosWriteImpuestos(): void + { + $creator = new CfdiCreator40([]); + + $comprobante = $creator->comprobante(); + $comprobante->addConcepto([ + 'ClaveProdServ' => '01010101', + 'NoIdentificacion' => 'FOO', + 'Cantidad' => '1', + 'ClaveUnidad' => 'E48', + 'Descripcion' => 'HONORARIOS MEDICOS', + 'ValorUnitario' => '617.000000', + 'Importe' => '617.000000', + 'Descuento' => '144.271240', + 'ObjetoImp' => '02', + ])->addTraslado([ + 'Base' => '472.728760', + 'Impuesto' => '002', + 'TipoFactor' => 'Exento', + ]); + + // test sumasConceptos + + $precision = 2; + $sumasConceptos = $creator->buildSumasConceptos($precision); + $this->assertTrue($sumasConceptos->hasExentos()); + $this->assertFalse($sumasConceptos->hasTraslados()); + $this->assertFalse($sumasConceptos->hasRetenciones()); + + $expectedExentos = [ + '002:Exento:' => [ + 'TipoFactor' => 'Exento', + 'Impuesto' => '002', + 'Base' => 472.72876, + ], + ]; + + $this->assertEquals($expectedExentos, $sumasConceptos->getExentos()); + + // test cfdi:Impuestos XML + + $creator->addSumasConceptos($sumasConceptos, $precision); + + $expectedImpuestosNode = new Node('cfdi:Impuestos', [], [ + new Node('cfdi:Traslados', [], [ + new Node('cfdi:Traslado', ['TipoFactor' => 'Exento', 'Impuesto' => '002', 'Base' => '472.73']), + ]), + ]); + + $this->assertXmlStringEqualsXmlString( + XmlNodeUtils::nodeToXmlString($expectedImpuestosNode), + XmlNodeUtils::nodeToXmlString($comprobante->searchNode('cfdi:Impuestos')) + ); + } +} diff --git a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php index 0c20301e..c50728b0 100644 --- a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php +++ b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php @@ -24,10 +24,12 @@ public function testConstructor() $this->assertEqualsWithDelta(0, $sc->getLocalesImpuestosTrasladados(), $maxDiff); $this->assertCount(0, $sc->getRetenciones()); $this->assertCount(0, $sc->getTraslados()); + $this->assertCount(0, $sc->getExentos()); $this->assertCount(0, $sc->getLocalesRetenciones()); $this->assertCount(0, $sc->getLocalesTraslados()); $this->assertFalse($sc->hasRetenciones()); $this->assertFalse($sc->hasTraslados()); + $this->assertFalse($sc->hasExentos()); $this->assertFalse($sc->hasLocalesRetenciones()); $this->assertFalse($sc->hasLocalesTraslados()); } @@ -77,10 +79,11 @@ public function testWithConceptsDecimals(int $taxDecimals, float $subtotal, floa $this->assertEqualsWithDelta($subtotal, $sc->getSubTotal(), $maxDiff); $this->assertEqualsWithDelta($traslados, $sc->getImpuestosTrasladados(), $maxDiff); $this->assertEqualsWithDelta($total, $sc->getTotal(), $maxDiff); - // this are zero + // these are zero $this->assertEqualsWithDelta(0, $sc->getDescuento(), $maxDiff); $this->assertEqualsWithDelta(0, $sc->getImpuestosRetenidos(), $maxDiff); $this->assertCount(0, $sc->getRetenciones()); + $this->assertCount(0, $sc->getExentos()); } public function testWithImpuestosLocales() @@ -123,7 +126,7 @@ public function testWithImpuestosLocales() $this->assertEqualsWithDelta(53.33, $sc->getImpuestosTrasladados(), $maxDiff); $this->assertEqualsWithDelta(8.33, $sc->getLocalesImpuestosTrasladados(), $maxDiff); $this->assertEqualsWithDelta(333.33 + 53.33 + 8.33, $sc->getTotal(), $maxDiff); - // this are zero + // these are zero $this->assertEqualsWithDelta(0, $sc->getDescuento(), $maxDiff); $this->assertEqualsWithDelta(0, $sc->getImpuestosRetenidos(), $maxDiff); $this->assertCount(0, $sc->getRetenciones()); @@ -172,33 +175,44 @@ public function testImpuestoImporteWithMoreDecimalsThanThePrecisionIsRounded() public function testImpuestoWithTrasladosTasaAndExento() { $comprobante = new Comprobante(); - $comprobante->addConcepto()->multiTraslado(...[ - ['Impuesto' => '002', 'TipoFactor' => 'Exento'], + $comprobante->addConcepto()->multiTraslado( [ 'Impuesto' => '002', - 'TipoFactor' => 'Tasa', - 'TasaOCuota' => '0.160000', + 'TipoFactor' => 'Exento', 'Base' => '1000', - 'Importe' => '160', ], - ]); - $comprobante->addConcepto()->multiTraslado(...[ [ 'Impuesto' => '002', 'TipoFactor' => 'Tasa', 'TasaOCuota' => '0.160000', 'Base' => '1000', 'Importe' => '160', - ], + ] + ); + $comprobante->addConcepto()->addTraslado([ + 'Impuesto' => '002', + 'TipoFactor' => 'Tasa', + 'TasaOCuota' => '0.160000', + 'Base' => '1000', + 'Importe' => '160', + ]); + $comprobante->addConcepto()->addTraslado([ + 'Impuesto' => '002', + 'TipoFactor' => 'Exento', + 'Base' => '234.56', ]); $sumas = new SumasConceptos($comprobante, 2); $this->assertTrue($sumas->hasTraslados()); $this->assertEqualsWithDelta(320.0, $sumas->getImpuestosTrasladados(), 0.001); $this->assertCount(1, $sumas->getTraslados()); + + $this->assertTrue($sumas->hasExentos()); + $this->assertCount(1, $sumas->getExentos()); + $this->assertEqualsWithDelta(1234.56, array_sum(array_column($sumas->getExentos(), 'Base')), 0.001); } - public function testImpuestoWithTrasladosAndOnlyExento() + public function testImpuestoWithTrasladosAndOnlyExentosWithoutBase() { $comprobante = new Comprobante(); $comprobante->addConcepto()->multiTraslado( @@ -212,5 +226,38 @@ public function testImpuestoWithTrasladosAndOnlyExento() $this->assertFalse($sumas->hasTraslados()); $this->assertEqualsWithDelta(0, $sumas->getImpuestosTrasladados(), 0.001); $this->assertCount(0, $sumas->getTraslados()); + + $this->assertTrue($sumas->hasExentos()); + $this->assertEqualsWithDelta(0, array_sum(array_column($sumas->getExentos(), 'Base')), 0.001); + } + + public function testImpuestoWithTrasladosAndOnlyExentosWithBase() + { + $comprobante = new Comprobante(); + $comprobante->addConcepto()->multiTraslado( + ['Impuesto' => '002', 'TipoFactor' => 'Exento', 'Base' => '123.45'], + ); + $comprobante->addConcepto()->multiTraslado( + ['Impuesto' => '002', 'TipoFactor' => 'Exento', 'Base' => '543.21'], + ['Impuesto' => '001', 'TipoFactor' => 'Exento', 'Base' => '100'], + ); + $comprobante->addConcepto()->multiTraslado( + ['Impuesto' => '001', 'TipoFactor' => 'Exento', 'Base' => '150'], + ); + + $sumas = new SumasConceptos($comprobante, 2); + $this->assertFalse($sumas->hasTraslados()); + $this->assertEqualsWithDelta(0, $sumas->getImpuestosTrasladados(), 0.001); + $this->assertCount(0, $sumas->getTraslados()); + + $this->assertTrue($sumas->hasExentos()); + $exentos001 = array_filter($sumas->getExentos(), function (array $values): bool { + return '001' === strval($values['Impuesto'] ?? ''); + }); + $exentos002 = array_filter($sumas->getExentos(), function (array $values): bool { + return '002' === strval($values['Impuesto'] ?? ''); + }); + $this->assertEqualsWithDelta(250.00, array_sum(array_column($exentos001, 'Base')), 0.001); + $this->assertEqualsWithDelta(666.66, array_sum(array_column($exentos002, 'Base')), 0.001); } } diff --git a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosWriterTestTrait.php b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosWriterTestTrait.php index 216e8f48..db0aac4a 100644 --- a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosWriterTestTrait.php +++ b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosWriterTestTrait.php @@ -169,20 +169,34 @@ public function testDescuentoNotSetIfAllConceptosDoesNotHaveDescuento() public function testOnComplementoImpuestosImporteSumIsRoundedCfdi() { $comprobante = $this->createComprobante(); - $comprobante->addConcepto()->addTraslado([ - 'Base' => '48.611106', - 'Importe' => '7.777777', - 'Impuesto' => '002', - 'TipoFactor' => 'Tasa', - 'TasaOCuota' => '0.160000', - ]); - $comprobante->addConcepto()->addTraslado([ - 'Base' => '13.888888', - 'Importe' => '2.222222', - 'Impuesto' => '002', - 'TipoFactor' => 'Tasa', - 'TasaOCuota' => '0.160000', - ]); + $comprobante->addConcepto()->multiTraslado( + [ + 'Base' => '48.611106', + 'Importe' => '7.777777', + 'Impuesto' => '002', + 'TipoFactor' => 'Tasa', + 'TasaOCuota' => '0.160000', + ], + [ + 'Base' => '48.611106', + 'Impuesto' => '002', + 'TipoFactor' => 'Exento', + ], + ); + $comprobante->addConcepto()->multiTraslado( + [ + 'Base' => '13.888888', + 'Importe' => '2.222222', + 'Impuesto' => '002', + 'TipoFactor' => 'Tasa', + 'TasaOCuota' => '0.160000', + ], + [ + 'Base' => '13.888888', + 'Impuesto' => '002', + 'TipoFactor' => 'Exento', + ], + ); $precision = 3; $sumasConceptos = new SumasConceptos($comprobante, $precision); @@ -197,6 +211,11 @@ public function testOnComplementoImpuestosImporteSumIsRoundedCfdi() } else { $this->assertFalse(isset($traslado['Base'])); } + + if ($writer->hasWriteExentos()) { + $exento = $comprobante->searchNodes('cfdi:Impuestos', 'cfdi:Traslados', 'cfdi:Traslado')->get(1); + $this->assertSame('62.500', $exento['Base']); + } } public function testConceptosOnlyWithTrasladosExentosDoesNotWriteTraslados() @@ -218,13 +237,32 @@ public function testConceptosOnlyWithTrasladosExentosDoesNotWriteTraslados() $writer = new SumasConceptosWriter($comprobante, $sumasConceptos, $precision); $writer->put(); - $expected = << - - - - - EOT; + $this->assertSame( + $writer->hasWriteExentos(), + $writer->hasWriteImpuestoBase(), + 'When has to write "exentos" also has to write "impuesto base" and vice versa' + ); + + if ($writer->hasWriteExentos()) { + $expected = << + + + + + + + + EOT; + } else { + $expected = << + + + + + EOT; + } $this->assertXmlStringEqualsXmlString($expected, XmlNodeUtils::nodeToXmlString($comprobante->getImpuestos())); } From 44ad84d6319afe98e89c779857b451b14a2f2675 Mon Sep 17 00:00:00 2001 From: Carlos C Soto Date: Thu, 11 Aug 2022 01:23:58 -0500 Subject: [PATCH 2/2] Fix PHPStan issue --- .../CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php index c50728b0..8d5b2194 100644 --- a/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php +++ b/tests/CfdiUtilsTests/SumasConceptos/SumasConceptosTest.php @@ -251,13 +251,15 @@ public function testImpuestoWithTrasladosAndOnlyExentosWithBase() $this->assertCount(0, $sumas->getTraslados()); $this->assertTrue($sumas->hasExentos()); + $exentos001 = array_filter($sumas->getExentos(), function (array $values): bool { - return '001' === strval($values['Impuesto'] ?? ''); + return '001' === $values['Impuesto']; }); + $this->assertEqualsWithDelta(250.00, array_sum(array_column($exentos001, 'Base')), 0.001); + $exentos002 = array_filter($sumas->getExentos(), function (array $values): bool { - return '002' === strval($values['Impuesto'] ?? ''); + return '002' === $values['Impuesto']; }); - $this->assertEqualsWithDelta(250.00, array_sum(array_column($exentos001, 'Base')), 0.001); $this->assertEqualsWithDelta(666.66, array_sum(array_column($exentos002, 'Base')), 0.001); } }