diff --git a/src/Dto/DeductionReductionData.php b/src/Dto/DeductionReductionData.php new file mode 100644 index 0000000..433959f --- /dev/null +++ b/src/Dto/DeductionReductionData.php @@ -0,0 +1,23 @@ +appendChild($doc->createElement('vServ', $dps->valorServico)); $valores->appendChild($vServPrest); + if ($dps->deducaoReducao !== null) { + $valores->appendChild($this->buildDeductionReduction($doc, $dps)); + } + $trib = $doc->createElement('trib'); $trib->appendChild($this->buildTribMun($doc, $dps)); @@ -244,6 +248,10 @@ private function buildTribMun(\DOMDocument $doc, DpsData $dps): \DOMElement $tribMun->appendChild($exigSusp); } + if ($dps->beneficioMunicipal !== null) { + $tribMun->appendChild($this->buildMunicipalBenefit($doc, $dps)); + } + $tribMun->appendChild($doc->createElement('tpRetISSQN', (string) $dps->tipoRetencaoIss)); if ($dps->opcaoSimplesNacional !== 1) { @@ -253,6 +261,85 @@ private function buildTribMun(\DOMDocument $doc, DpsData $dps): \DOMElement return $tribMun; } + private function buildDeductionReduction(\DOMDocument $doc, DpsData $dps): \DOMElement + { + $deduction = $dps->deducaoReducao; + + if ($deduction === null) { + throw new \LogicException('Deduction/reduction data is required.'); + } + + $hasPercentage = $deduction->percentual !== ''; + $hasValue = $deduction->valor !== ''; + + if ($hasPercentage === $hasValue) { + throw new \InvalidArgumentException( + 'Deduction/reduction must provide exactly one of percentual (pDR) or valor (vDR).', + ); + } + + $vDedRed = $doc->createElement('vDedRed'); + + if ($hasPercentage) { + $this->assertDecimal($deduction->percentual, 3, 'Deduction/reduction percentage'); + $vDedRed->appendChild($doc->createElement('pDR', $deduction->percentual)); + } else { + $this->assertDecimal($deduction->valor, 15, 'Deduction/reduction value'); + $vDedRed->appendChild($doc->createElement('vDR', $deduction->valor)); + } + + return $vDedRed; + } + + private function buildMunicipalBenefit(\DOMDocument $doc, DpsData $dps): \DOMElement + { + $benefit = $dps->beneficioMunicipal; + + if ($benefit === null) { + throw new \LogicException('Municipal benefit data is required.'); + } + + if (preg_match('/^\d{14}$/', $benefit->identificador) !== 1) { + throw new \InvalidArgumentException('Municipal benefit identifier must contain exactly 14 digits.'); + } + + $hasValue = $benefit->valorReducaoBaseCalculo !== ''; + $hasPercentage = $benefit->percentualReducaoBaseCalculo !== ''; + + if ($hasValue && $hasPercentage) { + throw new \InvalidArgumentException( + 'Municipal benefit may provide either value or percentage base reduction, not both.', + ); + } + + $bm = $doc->createElement('BM'); + $bm->appendChild($doc->createElement('nBM', $benefit->identificador)); + + if ($hasValue) { + $this->assertDecimal($benefit->valorReducaoBaseCalculo, 15, 'Municipal benefit value reduction'); + $bm->appendChild($doc->createElement('vRedBCBM', $benefit->valorReducaoBaseCalculo)); + } + + if ($hasPercentage) { + $this->assertDecimal($benefit->percentualReducaoBaseCalculo, 3, 'Municipal benefit percentage reduction'); + $bm->appendChild($doc->createElement('pRedBCBM', $benefit->percentualReducaoBaseCalculo)); + } + + return $bm; + } + + private function assertDecimal(string $value, int $integerDigits, string $label): void + { + $pattern = '/^(?:0|0\.\d{2}|[1-9]\d{0,' . ($integerDigits - 1) . '}(?:\.\d{2})?)$/'; + + if (preg_match($pattern, $value) !== 1) { + throw new \InvalidArgumentException( + $label . ' must follow the official decimal format with up to ' + . $integerDigits . ' integer digits and optional two decimal places.', + ); + } + } + private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement { $totTrib = $doc->createElement('totTrib'); diff --git a/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php b/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php index 6f588f1..b72e665 100644 --- a/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php +++ b/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php @@ -7,7 +7,9 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Dto\MunicipalBenefitData; use LibreCodeCoop\NfsePHP\Dto\SubstitutionData; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\DpsSchemaValidator; @@ -38,6 +40,19 @@ public function testRepresentativeDpsMatchesOfficialSchema(): void self::assertSame([], $this->validator->validate($xml)); } + public function testStandardDeductionAndMunicipalBenefitMatchOfficialSchema(): void + { + $xml = $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(percentual: '10.00'), + beneficioMunicipal: new MunicipalBenefitData( + identificador: '33033020400001', + valorReducaoBaseCalculo: '100.00', + ), + )); + + self::assertSame([], $this->validator->validate($xml)); + } + public function testValidatorRejectsUnsupportedSchemaVersionExplicitly(): void { $this->expectException(\InvalidArgumentException::class); @@ -223,6 +238,8 @@ private function makeDps( string $issqnNumeroProcessoSuspensao = '', int $tipoRetencaoIss = 1, ?SubstitutionData $substituicao = null, + ?DeductionReductionData $deducaoReducao = null, + ?MunicipalBenefitData $beneficioMunicipal = null, ): DpsData { return new DpsData( cnpjPrestador: $cnpjPrestador, @@ -255,6 +272,8 @@ private function makeDps( ibsCbsCst: $ibsCbsCst, ibsCbsClassificacaoTributaria: $ibsCbsClassificacaoTributaria, substituicao: $substituicao, + deducaoReducao: $deducaoReducao, + beneficioMunicipal: $beneficioMunicipal, ); } } diff --git a/tests/Unit/Xml/XmlBuilderTest.php b/tests/Unit/Xml/XmlBuilderTest.php index 2db1bcb..f7a7e85 100644 --- a/tests/Unit/Xml/XmlBuilderTest.php +++ b/tests/Unit/Xml/XmlBuilderTest.php @@ -7,7 +7,9 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Dto\MunicipalBenefitData; use LibreCodeCoop\NfsePHP\Dto\SubstitutionData; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\XmlBuilder; @@ -739,6 +741,82 @@ public function testIssqnSuspensionRequiresThirtyDigitProceedingNumber(): void // ------------------------------------------------------------------------- + public function testBuildDpsEmitsPercentageDeductionBeforeTaxGroup(): void + { + $xml = $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(percentual: '12.50'), + )); + + $compactXml = preg_replace('/>\s+<', $xml) ?? $xml; + + self::assertMatchesRegularExpression( + '/.*<\/vServPrest>12\.50<\/pDR><\/vDedRed>/s', + $compactXml, + ); + } + + public function testBuildDpsEmitsValueDeductionBeforeTaxGroup(): void + { + $xml = $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(valor: '125.00'), + )); + + $compactXml = preg_replace('/>\s+<', $xml) ?? $xml; + + self::assertStringContainsString('125.00', $compactXml); + } + + public function testBuildDpsRejectsAmbiguousStandardDeduction(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one'); + + $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(percentual: '10.00', valor: '100.00'), + )); + } + + public function testBuildDpsEmitsMunicipalBenefitBeforeRetention(): void + { + $xml = $this->builder->buildDps($this->makeDps( + beneficioMunicipal: new MunicipalBenefitData( + identificador: '33033020400001', + percentualReducaoBaseCalculo: '20.00', + ), + )); + + $compactXml = preg_replace('/>\s+<', $xml) ?? $xml; + + self::assertMatchesRegularExpression( + '/33033020400001<\/nBM>20\.00<\/pRedBCBM><\/BM>/', + $compactXml, + ); + } + + public function testBuildDpsRejectsAmbiguousMunicipalBenefitReduction(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('not both'); + + $this->builder->buildDps($this->makeDps( + beneficioMunicipal: new MunicipalBenefitData( + identificador: '33033020400001', + valorReducaoBaseCalculo: '100.00', + percentualReducaoBaseCalculo: '20.00', + ), + )); + } + + public function testBuildDpsRejectsInvalidMunicipalBenefitIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('14 digits'); + + $this->builder->buildDps($this->makeDps( + beneficioMunicipal: new MunicipalBenefitData(identificador: '3303302'), + )); + } + private function makeDps( string $cnpjPrestador = '11222333000181', string $municipioIbge = '3303302', @@ -792,6 +870,8 @@ private function makeDps( string $ibsCbsClassificacaoTributaria = '', ?string $codigoTributacaoMunicipal = null, ?SubstitutionData $substituicao = null, + ?DeductionReductionData $deducaoReducao = null, + ?MunicipalBenefitData $beneficioMunicipal = null, ): DpsData { return new DpsData( cnpjPrestador: $cnpjPrestador, @@ -846,6 +926,8 @@ private function makeDps( ibsCbsClassificacaoTributaria: $ibsCbsClassificacaoTributaria, codigoTributacaoMunicipal: $codigoTributacaoMunicipal, substituicao: $substituicao, + deducaoReducao: $deducaoReducao, + beneficioMunicipal: $beneficioMunicipal, ); } }