diff --git a/src/CfdiUtils/Validate/Cfdi33/Standard/ConceptoImpuestos.php b/src/CfdiUtils/Validate/Cfdi33/Standard/ConceptoImpuestos.php index ba199ec8..3c22aa7d 100644 --- a/src/CfdiUtils/Validate/Cfdi33/Standard/ConceptoImpuestos.php +++ b/src/CfdiUtils/Validate/Cfdi33/Standard/ConceptoImpuestos.php @@ -10,32 +10,142 @@ * ConceptoImpuestos.php * * Valida que: - * - CONCEPIMPC01: Si se utiliza el nodo impuestos en un concepto entonces se deben incluir traslados o retenciones + * - CONCEPIMPC01: El nodo impuestos de un concepto debe incluir traslados y/o retenciones (CFDI33152) + * - CONCEPIMPC02: Los traslados de los impuestos de un concepto deben tener una base y ser mayor a cero (CFDI33154) + * - CONCEPIMPC03: No se debe registrar la tasa o cuota ni el importe cuando + * el tipo de factor de traslado es exento (CFDI33157) + * - CONCEPIMPC04: Se debe registrar la tasa o cuota y el importe cuando + * el tipo de factor de traslado es tasa o cuota (CFDI33158) + * - CONCEPIMPC05: Las retenciones de los impuestos de un concepto deben tener una base y ser mayor a cero (CFDI33154) + * - CONCEPIMPC06: Las retenciones de los impuestos de un concepto deben tener + * un tipo de factor diferente de exento (CFDI33166) */ class ConceptoImpuestos extends AbstractDiscoverableVersion33 { - public function validate(NodeInterface $comprobante, Asserts $asserts) + private function registerAsserts(Asserts $asserts) { - $asserts->put( - 'CONCEPIMPC01', - 'Si se utiliza el nodo impuestos en un concepto entonces se deben incluir traslados y retenciones', - Status::when($this->allConceptosImpuestosHasTrasladosOrRetenciones($comprobante)) - ); + $assertDescriptions = [ + 'CONCEPIMPC01' => 'El nodo impuestos de un concepto debe incluir traslados y/o retenciones (CFDI33152)', + 'CONCEPIMPC02' => 'Los traslados de los impuestos de un concepto deben tener una base y ser mayor a cero' + . ' (CFDI33154)', + 'CONCEPIMPC03' => 'No se debe registrar la tasa o cuota ni el importe cuando el tipo de factor de traslado' + . ' es exento (CFDI33157)', + 'CONCEPIMPC04' => 'Se debe registrar la tasa o cuota y el importe cuando el tipo de factor de traslado' + . ' es tasa o cuota (CFDI33158)', + 'CONCEPIMPC05' => 'Las retenciones de los impuestos de un concepto deben tener una base y ser mayor a cero' + . '(CFDI33154)', + 'CONCEPIMPC06' => ' Las retenciones de los impuestos de un concepto deben tener un tipo de factor diferente' + . ' de exento (CFDI33166)', + ]; + foreach ($assertDescriptions as $code => $title) { + $asserts->put($code, $title); + } } - public function allConceptosImpuestosHasTrasladosOrRetenciones(NodeInterface $comprobante): bool + public function validate(NodeInterface $comprobante, Asserts $asserts) { - foreach ($comprobante->searchNodes('cfdi:Conceptos', 'cfdi:Concepto') as $concepto) { - $impuestos = $concepto->searchNode('cfdi:Impuestos'); - if (null === $impuestos) { - continue; + $this->registerAsserts($asserts); + + $status01 = Status::ok(); + $status02 = Status::ok(); + $status03 = Status::ok(); + $status04 = Status::ok(); + $status05 = Status::ok(); + $status06 = Status::ok(); + + foreach ($comprobante->searchNodes('cfdi:Conceptos', 'cfdi:Concepto') as $i => $concepto) { + if ($status01->isOk() && ! $this->conceptoImpuestosHasTrasladosOrRetenciones($concepto)) { + $status01 = Status::error(); + $asserts->get('CONCEPIMPC01')->setExplanation(sprintf('Concepto #%d', $i)); } - if ($impuestos->searchNodes('cfdi:Traslados', 'cfdi:Traslado')->count() - || $impuestos->searchNodes('cfdi:Retenciones', 'cfdi:Retencion')->count()) { - continue; + + $traslados = $concepto->searchNodes('cfdi:Impuestos', 'cfdi:Traslados', 'cfdi:Traslado'); + foreach ($traslados as $k => $traslado) { + if ($status02->isOk() && ! $this->impuestoHasBaseGreaterThanZero($traslado)) { + $status02 = Status::error(); + $asserts->get('CONCEPIMPC02')->setExplanation(sprintf('Concepto #%d, Traslado #%d', $i, $k)); + } + if ($status03->isOk() && ! $this->trasladoHasTipoFactorExento($traslado)) { + $status03 = Status::error(); + $asserts->get('CONCEPIMPC03')->setExplanation(sprintf('Concepto #%d, Traslado #%d', $i, $k)); + } + if ($status04->isOk() && ! $this->trasladoHasTipoFactorTasaOCuota($traslado)) { + $status04 = Status::error(); + $asserts->get('CONCEPIMPC04')->setExplanation(sprintf('Concepto #%d, Traslado #%d', $i, $k)); + } + } + + $retenciones = $concepto->searchNodes('cfdi:Impuestos', 'cfdi:Retenciones', 'cfdi:Retencion'); + foreach ($retenciones as $k => $retencion) { + if ($status05->isOk() && ! $this->impuestoHasBaseGreaterThanZero($retencion)) { + $status05 = Status::error(); + $asserts->get('CONCEPIMPC05')->setExplanation(sprintf('Concepto #%d, RetenciĆ³n #%d', $i, $k)); + } + if ($status06->isOk() && 'Exento' === $retencion['TipoFactor']) { + $status06 = Status::error(); + $asserts->get('CONCEPIMPC06')->setExplanation(sprintf('Concepto #%d, RetenciĆ³n #%d', $i, $k)); + } } + } + + $asserts->putStatus('CONCEPIMPC01', $status01); + $asserts->putStatus('CONCEPIMPC02', $status02); + $asserts->putStatus('CONCEPIMPC03', $status03); + $asserts->putStatus('CONCEPIMPC04', $status04); + $asserts->putStatus('CONCEPIMPC05', $status05); + $asserts->putStatus('CONCEPIMPC06', $status06); + } + + private function conceptoImpuestosHasTrasladosOrRetenciones(NodeInterface $concepto): bool + { + $impuestos = $concepto->searchNode('cfdi:Impuestos'); + if (null === $impuestos) { + return true; + } + if ($impuestos->searchNodes('cfdi:Traslados', 'cfdi:Traslado')->count() + || $impuestos->searchNodes('cfdi:Retenciones', 'cfdi:Retencion')->count()) { + return true; + } + return false; + } + + private function impuestoHasBaseGreaterThanZero(NodeInterface $impuesto): bool + { + if (! isset($impuesto['Base'])) { return false; } + if (! is_numeric($impuesto['Base'])) { + return false; + } + if ((float) $impuesto['Base'] < 0.000001) { + return false; + } + return true; + } + + private function trasladoHasTipoFactorExento(NodeInterface $traslado): bool + { + if ('Exento' === $traslado['TipoFactor']) { + if (isset($traslado['TasaOCuota'])) { + return false; + } + if (isset($traslado['Importe'])) { + return false; + } + } + return true; + } + + private function trasladoHasTipoFactorTasaOCuota(NodeInterface $traslado): bool + { + if ('Tasa' === $traslado['TipoFactor'] || 'Cuota' === $traslado['TipoFactor']) { + if ('' === $traslado['TasaOCuota']) { + return false; + } + if ('' === $traslado['Importe']) { + return false; + } + } return true; } } diff --git a/tests/CfdiUtilsTests/Validate/Cfdi33/Standard/ConceptoImpuestosTest.php b/tests/CfdiUtilsTests/Validate/Cfdi33/Standard/ConceptoImpuestosTest.php index 297f0443..44db5421 100644 --- a/tests/CfdiUtilsTests/Validate/Cfdi33/Standard/ConceptoImpuestosTest.php +++ b/tests/CfdiUtilsTests/Validate/Cfdi33/Standard/ConceptoImpuestosTest.php @@ -1,7 +1,7 @@ validator = new ConceptoImpuestos(); } - public function testValidCase() - { - $this->comprobante->addChild( - new Node('cfdi:Conceptos', [], [ - new Node('cfdi:Concepto'), - new Node('cfdi:Concepto', [], [ - new Node('cfdi:Impuestos', [], [ - new Node('cfdi:Traslados', [], [ - new Node('cfdi:Traslado'), - ]), - new Node('cfdi:Retenciones', [], [ - new Node('cfdi:Retencion'), - ]), - ]), - ]), - ]) - ); + public function testInvalidCaseNoRetencionOrTraslado() + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->getImpuestos(); $this->runValidate(); - $this->assertStatusEqualsCode(Status::ok(), 'CONCEPIMPC01'); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC01'); } - public function testInvalidCaseNoRetencionOrTraslado() + public function providerInvalidBaseTraslado() { - $this->comprobante->addChild( - new Node('cfdi:Conceptos', [], [ - new Node('cfdi:Concepto'), - new Node('cfdi:Concepto', [], [ - new Node('cfdi:Impuestos', [], [ - new Node('cfdi:Traslados', [], []), - new Node('cfdi:Retenciones', [], []), - ]), - ]), - ]) + return[ + ['0'], + ['0.0000001'], + ['-1'], + ['foo'], + ['0.0.0.0'], + ]; + } + + /** + * @param $base + * @dataProvider providerInvalidBaseTraslado + */ + public function testTrasladoHasBaseGreaterThanZeroInvalidCase($base) + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->addTraslado(['Base' => $base]); + $this->runValidate(); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC02'); + } + + public function providerTrasladoTipoFactorExento() + { + return[ + ['1', '1'], + [null, '1'], + ['1', null], + ]; + } + + /** + * @param $tasaOCuota + * @param $importe + * @dataProvider providerTrasladoTipoFactorExento + */ + public function testTrasladosTipoFactorInvalidCase($tasaOCuota, $importe) + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->addTraslado([ + 'TipoFactor' => 'Exento', + 'TasaOCuota' => $tasaOCuota, + 'Importe' => $importe, + ]); + $this->runValidate(); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC03'); + } + + public function providerTrasladosTipoFactorTasaOCuotaInvalidCase() + { + return $this->providerFullJoin( + [['Tasa'], ['Cuota']], + [[''], [null]], + [[''], [null]] ); + } + + /** + * @param $tipoFactor + * @param $tasaOCuota + * @param $importe + * @dataProvider providerTrasladosTipoFactorTasaOCuotaInvalidCase + */ + public function testTrasladosTipoFactorTasaOCuotaInvalidCase($tipoFactor, $tasaOCuota, $importe) + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->addTraslado([ + 'TipoFactor' => $tipoFactor, + 'TasaOCuota' => $tasaOCuota, + 'Importe' => $importe, + ]); $this->runValidate(); - $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC01'); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC04'); + } + + public function providerInvalidBaseRetencion() + { + return[ + ['0'], + ['0.0000001'], + ['-1'], + ['foo'], + ['0.0.0.0'], + ]; + } + + /** + * @param $base + * @dataProvider providerInvalidBaseTraslado + */ + public function testRetencionesHasBaseGreaterThanZeroInvalidCase($base) + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->addRetencion(['Base' => $base]); + $this->runValidate(); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC05'); + } + + public function testInvalidCaseRetencionTipoFactorExento() + { + $comprobante = $this->validComprobante(); + $comprobante->addConcepto()->addRetencion(['TipoFactor' => 'Exento']); + $this->runValidate(); + $this->assertStatusEqualsCode(Status::error(), 'CONCEPIMPC06'); + } + + public function testValidComprobante() + { + $this->validComprobante(); + $this->runValidate(); + $this->assertFalse($this->asserts->hasErrors()); + } + + private function validComprobante(): Comprobante + { + /** @var Comprobante $comprobante */ + $comprobante = $this->comprobante; + $comprobante->addConcepto(); + $comprobante->addConcepto()->multiTraslado([ + 'TipoFactor' => 'Exento', + 'Base' => '123.45', + ], [ + 'Base' => '123.45', + 'TipoFactor' => 'Tasa', + 'TasaOCuota' => '0.160000', + 'Importe' => '19.75', + ])->multiRetencion([ + 'Base' => '0.000001', + 'TipoFactor' => 'Tasa', + 'TasaOCuota' => '0.02', + 'Importe' => '1.23', + ], [ + 'Base' => '123.45', + 'TipoFactor' => 'Cuota', + ]); + return $comprobante; } }