From fd8b4498dabb6d6ad41c511a8e6759cf8f3b816a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:41:56 -0300 Subject: [PATCH 1/4] feat(dto): add totalTributosPercentual fields to DpsData Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/DpsData.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Dto/DpsData.php b/src/Dto/DpsData.php index bd51b83..bfb281c 100644 --- a/src/Dto/DpsData.php +++ b/src/Dto/DpsData.php @@ -72,6 +72,15 @@ public function __construct( /** Indicador de tributação total. */ public int $indicadorTributacao = 0, + /** Percentual total estimado de tributos federais. */ + public string $totalTributosPercentualFederal = '', + + /** Percentual total estimado de tributos estaduais. */ + public string $totalTributosPercentualEstadual = '', + + /** Percentual total estimado de tributos municipais. */ + public string $totalTributosPercentualMunicipal = '', + /** Whether ISS is retained at source. */ public bool $issRetido = false, From 2b15d3d4f1929f4c247909d25993d75511a6fab6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:42:08 -0300 Subject: [PATCH 2/4] feat(xml): fix element ordering and extract buildServico/buildValores methods Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/XmlBuilder.php | 166 +++++++++++++++++++++++++++++------------ 1 file changed, 119 insertions(+), 47 deletions(-) diff --git a/src/Xml/XmlBuilder.php b/src/Xml/XmlBuilder.php index 61a37ee..dba82d1 100644 --- a/src/Xml/XmlBuilder.php +++ b/src/Xml/XmlBuilder.php @@ -25,6 +25,9 @@ public function buildDps(DpsData $dps): string $doc->preserveWhiteSpace = false; $doc->formatOutput = true; + $emissionDateTime = $this->formattedEmissionDateTime(); + $competenceDate = $dps->dataCompetencia ?: substr($emissionDateTime, 0, 10); + $root = $doc->createElementNS(self::XSD_NAMESPACE, 'DPS'); $root->setAttribute('versao', self::DPS_VERSION); $root->setAttribute('xsi:schemaLocation', self::XSD_SCHEMA); @@ -35,42 +38,27 @@ public function buildDps(DpsData $dps): string $infDps->setAttribute('Id', $this->buildIdentifier($dps)); $root->appendChild($infDps); - // Municipality - $cMun = $doc->createElement('cMun', $dps->municipioIbge); - $infDps->appendChild($cMun); + $infDps->appendChild($doc->createElement('tpAmb', (string) $dps->tipoAmbiente)); + $infDps->appendChild($doc->createElement('dhEmi', $emissionDateTime)); + $infDps->appendChild($doc->createElement('verAplic', $dps->versaoAplicativo)); + $infDps->appendChild($doc->createElement('serie', str_pad($dps->serie, 5, '0', STR_PAD_LEFT))); + $infDps->appendChild($doc->createElement('nDPS', $dps->numeroDps)); + $infDps->appendChild($doc->createElement('dCompet', $competenceDate)); + $infDps->appendChild($doc->createElement('tpEmit', (string) $dps->tipoEmissao)); $infDps->appendChild($doc->createElement('cLocEmi', $dps->municipioIbge)); - // Prestador $prest = $doc->createElement('prest'); $cnpj = $doc->createElement('CNPJ', $dps->cnpjPrestador); $prest->appendChild($cnpj); $prest->appendChild($this->buildRegTrib($doc, $dps)); $infDps->appendChild($prest); - // Service block - $serv = $doc->createElement('serv'); - - $itemListaServico = $doc->createElement('cServ'); - $itemListaServico->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional)); - $serv->appendChild($itemListaServico); - - $serv->appendChild($doc->createElement('xDescServ', htmlspecialchars($dps->discriminacao, ENT_XML1))); - $infDps->appendChild($serv); - - // Tomador (optional — absent for foreign buyers with no document) if ($dps->documentoTomador !== '') { $infDps->appendChild($this->buildToma($doc, $dps)); } - // Values - $valores = $doc->createElement('valores'); - - $vServPrest = $doc->createElement('vServPrest'); - $vServPrest->appendChild($doc->createElement('vServ', $dps->valorServico)); - $valores->appendChild($vServPrest); - - $valores->appendChild($this->buildTotTrib($doc, $dps)); - $infDps->appendChild($valores); + $infDps->appendChild($this->buildServico($doc, $dps)); + $infDps->appendChild($this->buildValores($doc, $dps)); return $doc->saveXML() ?: ''; } @@ -85,35 +73,90 @@ private function buildIdentifier(DpsData $dps): string . str_pad($dps->numeroDps, 15, '0', STR_PAD_LEFT); } - private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement + private function buildValores(\DOMDocument $doc, DpsData $dps): \DOMElement { - $totTrib = $doc->createElement('totTrib'); + $valores = $doc->createElement('valores'); - // tribMun contains ISS and conditional pAliq + $vServPrest = $doc->createElement('vServPrest'); + $vServPrest->appendChild($doc->createElement('vServ', $dps->valorServico)); + $valores->appendChild($vServPrest); + + $trib = $doc->createElement('trib'); + $trib->appendChild($this->buildTribMun($doc, $dps)); + + if ($this->hasFederalTaxationData($dps)) { + $trib->appendChild($this->buildTribFederal($doc, $dps)); + } + + if ($this->hasTotalTributosPercentuais($dps)) { + $trib->appendChild($this->buildTotTrib($doc, $dps)); + } + + $valores->appendChild($trib); + + return $valores; + } + + private function buildTribMun(\DOMDocument $doc, DpsData $dps): \DOMElement + { $tribMun = $doc->createElement('tribMun'); $tribMun->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1')); $tribMun->appendChild($doc->createElement('tpRetISSQN', (string) $dps->tipoRetencaoIss)); - // E0617: For não optante (opSimpNac=1), pAliq must NOT be present if ($dps->opcaoSimplesNacional !== 1) { $tribMun->appendChild($doc->createElement('pAliq', $dps->aliquota)); } - $totTrib->appendChild($tribMun); + return $tribMun; + } - if ($this->hasFederalTaxationData($dps)) { - $totTrib->appendChild($this->buildTribFederal($doc, $dps)); + private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement + { + $totTrib = $doc->createElement('totTrib'); + $percentuais = $doc->createElement('pTotTrib'); + + if ($dps->totalTributosPercentualFederal !== '') { + $percentuais->appendChild($doc->createElement('pTotTribFed', $dps->totalTributosPercentualFederal)); + } + + if ($dps->totalTributosPercentualEstadual !== '') { + $percentuais->appendChild($doc->createElement('pTotTribEst', $dps->totalTributosPercentualEstadual)); + } + + if ($dps->totalTributosPercentualMunicipal !== '') { + $percentuais->appendChild($doc->createElement('pTotTribMun', $dps->totalTributosPercentualMunicipal)); } - // E0715: indTotTrib is ALWAYS included to avoid schema validation errors - $totTrib->appendChild($doc->createElement('indTotTrib', (string) $dps->indicadorTributacao)); + $totTrib->appendChild($percentuais); return $totTrib; } + private function buildServico(\DOMDocument $doc, DpsData $dps): \DOMElement + { + $serv = $doc->createElement('serv'); + + $locPrest = $doc->createElement('locPrest'); + $locPrest->appendChild($doc->createElement('cLocPrestacao', $dps->municipioIbge)); + $serv->appendChild($locPrest); + + $cServ = $doc->createElement('cServ'); + $cServ->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional)); + + if ($dps->itemListaServico !== '') { + $cServ->appendChild($doc->createElement('cTribMun', $dps->itemListaServico)); + } + + $cServ->appendChild($doc->createElement('xDescServ', htmlspecialchars($dps->discriminacao, ENT_XML1))); + $serv->appendChild($cServ); + + return $serv; + } + private function buildRegTrib(\DOMDocument $doc, DpsData $dps): \DOMElement { $regTrib = $doc->createElement('regTrib'); + $regTrib->appendChild($doc->createElement('opSimpNac', (string) $dps->opcaoSimplesNacional)); $regTrib->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao)); return $regTrib; @@ -141,26 +184,38 @@ private function buildToma(\DOMDocument $doc, DpsData $dps): \DOMElement private function buildTribFederal(\DOMDocument $doc, DpsData $dps): \DOMElement { $tribFed = $doc->createElement('tribFed'); + $piscofins = $doc->createElement('piscofins'); if ($dps->federalPiscofinsSituacaoTributaria !== '') { - $tribFed->appendChild($doc->createElement('sitTribPISCOFINS', $dps->federalPiscofinsSituacaoTributaria)); + $piscofins->appendChild($doc->createElement('CST', str_pad($dps->federalPiscofinsSituacaoTributaria, 2, '0', STR_PAD_LEFT))); + } + + foreach ([ + 'vBCPisCofins' => $dps->federalPiscofinsBaseCalculo, + 'pAliqPis' => $dps->federalPiscofinsAliquotaPis, + 'pAliqCofins' => $dps->federalPiscofinsAliquotaCofins, + 'vPis' => $dps->federalPiscofinsValorPis, + 'vCofins' => $dps->federalPiscofinsValorCofins, + ] as $tag => $value) { + if ($value !== '') { + $piscofins->appendChild($doc->createElement($tag, $value)); + } } if ($dps->federalPiscofinsTipoRetencao !== '') { - $tribFed->appendChild($doc->createElement('tpRetPISCOFINSCSLL', $dps->federalPiscofinsTipoRetencao)); + $piscofins->appendChild($doc->createElement('tpRetPisCofins', $dps->federalPiscofinsTipoRetencao)); + } + + if ($piscofins->hasChildNodes()) { + $tribFed->appendChild($piscofins); } foreach ([ - 'vBCPISCOFINS' => $dps->federalPiscofinsBaseCalculo, - 'pAliqPIS' => $dps->federalPiscofinsAliquotaPis, - 'vPIS' => $dps->federalPiscofinsValorPis, - 'pAliqCOFINS' => $dps->federalPiscofinsAliquotaCofins, - 'vCOFINS' => $dps->federalPiscofinsValorCofins, - 'vIRRF' => $dps->federalValorIrrf, - 'vCSLL' => $dps->federalValorCsll, - 'vCP' => $dps->federalValorCp, + 'vRetIRRF' => $dps->federalValorIrrf, + 'vRetCSLL' => $dps->federalValorCsll, + 'vRetCP' => $dps->federalValorCp, ] as $tag => $value) { - if ($value !== '') { + if ($this->hasNonZeroDecimalValue($value)) { $tribFed->appendChild($doc->createElement($tag, $value)); } } @@ -168,6 +223,18 @@ private function buildTribFederal(\DOMDocument $doc, DpsData $dps): \DOMElement return $tribFed; } + private function formattedEmissionDateTime(): string + { + return (new \DateTimeImmutable())->format('Y-m-d\\TH:i:sP'); + } + + private function hasTotalTributosPercentuais(DpsData $dps): bool + { + return $dps->totalTributosPercentualFederal !== '' + || $dps->totalTributosPercentualEstadual !== '' + || $dps->totalTributosPercentualMunicipal !== ''; + } + private function hasFederalTaxationData(DpsData $dps): bool { return $dps->federalPiscofinsSituacaoTributaria !== '' @@ -177,8 +244,13 @@ private function hasFederalTaxationData(DpsData $dps): bool || $dps->federalPiscofinsValorPis !== '' || $dps->federalPiscofinsAliquotaCofins !== '' || $dps->federalPiscofinsValorCofins !== '' - || $dps->federalValorIrrf !== '' - || $dps->federalValorCsll !== '' - || $dps->federalValorCp !== ''; + || $this->hasNonZeroDecimalValue($dps->federalValorIrrf) + || $this->hasNonZeroDecimalValue($dps->federalValorCsll) + || $this->hasNonZeroDecimalValue($dps->federalValorCp); + } + + private function hasNonZeroDecimalValue(string $value): bool + { + return $value !== '' && (float) $value !== 0.0; } } From b80f6fec17e2c954ea853484f9daee1ddbb41c38 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:42:23 -0300 Subject: [PATCH 3/4] test(xml): update and extend XmlBuilder tests for new structure Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/XmlBuilderTest.php | 111 ++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/tests/Unit/Xml/XmlBuilderTest.php b/tests/Unit/Xml/XmlBuilderTest.php index ee22abe..fd8a0ae 100644 --- a/tests/Unit/Xml/XmlBuilderTest.php +++ b/tests/Unit/Xml/XmlBuilderTest.php @@ -72,6 +72,35 @@ public function testBuildDpsContainsMunicipioIbge(): void self::assertStringContainsString('3303302', $xml); self::assertStringContainsString('3303302', $xml); + self::assertStringContainsString('3303302', $xml); + } + + public function testBuildDpsStartsInfDpsWithTpAmbBeforeMunicipalityFields(): void + { + $xml = $this->builder->buildDps($this->makeDps(municipioIbge: '3303302')); + + $doc = new \DOMDocument(); + $doc->loadXML($xml); + $xpath = new \DOMXPath($doc); + $xpath->registerNamespace('n', 'http://www.sped.fazenda.gov.br/nfse'); + + $infDps = $xpath->query('//n:infDPS')->item(0); + self::assertNotNull($infDps); + + $headerElements = []; + + foreach ($infDps?->childNodes ?? [] as $childNode) { + if ($childNode instanceof \DOMElement) { + $headerElements[] = $childNode->localName; + } + } + + self::assertSame( + ['tpAmb', 'dhEmi', 'verAplic', 'serie', 'nDPS', 'dCompet', 'tpEmit', 'cLocEmi'], + array_slice($headerElements, 0, 8), + ); + self::assertSame('2', $xpath->query('//n:infDPS/n:tpAmb')->item(0)?->textContent); + self::assertSame(0, $xpath->query('//n:infDPS/n:cMun')->length); } public function testBuildDpsUsesNationalTaxCodeInCtribnac(): void @@ -79,7 +108,10 @@ public function testBuildDpsUsesNationalTaxCodeInCtribnac(): void $dps = $this->makeDps(itemListaServico: '0107', codigoTributacaoNacional: '101011'); $xml = $this->builder->buildDps($dps); - self::assertStringContainsString('101011', str_replace(["\n", ' '], '', $xml)); + self::assertStringContainsString( + '1010110107Consultoria em TI', + str_replace(["\n", ' '], '', $xml), + ); } public function testBuildDpsContainsValorServico(): void @@ -193,6 +225,10 @@ public function testBuildDpsAlwaysIncludesProviderTaxRegime(): void $nodes = $xpath->query('//n:prest/n:regTrib/n:regEspTrib'); self::assertSame(1, $nodes->length, ' must always be present'); self::assertSame('0', $nodes->item(0)->textContent); + + $simplesNodes = $xpath->query('//n:prest/n:regTrib/n:opSimpNac'); + self::assertSame(1, $simplesNodes->length, ' must always be present'); + self::assertSame('1', $simplesNodes->item(0)->textContent); } public function testBuildDpsIncludesFederalTaxationBlockWhenConfigured(): void @@ -213,16 +249,37 @@ public function testBuildDpsIncludesFederalTaxationBlockWhenConfigured(): void $xml = $this->builder->buildDps($dps); self::assertStringContainsString('', $xml); - self::assertStringContainsString('1', $xml); - self::assertStringContainsString('3', $xml); - self::assertStringContainsString('1000.00', $xml); - self::assertStringContainsString('1.65', $xml); - self::assertStringContainsString('16.50', $xml); - self::assertStringContainsString('7.60', $xml); - self::assertStringContainsString('76.00', $xml); - self::assertStringContainsString('15.00', $xml); - self::assertStringContainsString('10.00', $xml); - self::assertStringContainsString('5.00', $xml); + self::assertStringContainsString('', $xml); + self::assertStringContainsString('01', $xml); + self::assertStringContainsString('3', $xml); + self::assertStringContainsString('1000.00', $xml); + self::assertStringContainsString('1.65', $xml); + self::assertStringContainsString('16.50', $xml); + self::assertStringContainsString('7.60', $xml); + self::assertStringContainsString('76.00', $xml); + self::assertStringContainsString('15.00', $xml); + self::assertStringContainsString('10.00', $xml); + self::assertStringContainsString('5.00', $xml); + } + + public function testBuildDpsOmitsZeroValuedOptionalFederalRetentions(): void + { + $xml = $this->builder->buildDps($this->makeDps( + federalPiscofinsSituacaoTributaria: '1', + federalPiscofinsTipoRetencao: '4', + federalPiscofinsBaseCalculo: '14227.50', + federalPiscofinsAliquotaPis: '0.65', + federalPiscofinsValorPis: '92.48', + federalPiscofinsAliquotaCofins: '3.00', + federalPiscofinsValorCofins: '426.83', + federalValorIrrf: '472.50', + federalValorCsll: '0.00', + federalValorCp: '0.00', + )); + + self::assertStringContainsString('472.50', $xml); + self::assertStringNotContainsString('', $xml); + self::assertStringNotContainsString('', $xml); } // ------------------------------------------------------------------------- @@ -240,15 +297,10 @@ public function testNonSimplesnacionalMustNotIncludeIndtottribAndPaliq(): void ); $xml = $this->builder->buildDps($dpsNaoOptante); - // For "não optante" (opSimpNac = 1), pAliq must NOT be present - // but indTotTrib is now ALWAYS included (even if 0) to avoid schema validation errors + // For "não optante" (opSimpNac = 1), pAliq must NOT be present. self::assertStringNotContainsString('', $xml); - self::assertStringContainsString('', $xml); - - // totTrib container must exist with content - self::assertStringContainsString('', $xml); - // tribMun and tribISSQN must still exist self::assertStringContainsString('', $xml); self::assertStringContainsString('', $xml); } @@ -263,13 +315,24 @@ public function testOptiontSimplesnacionalIncludesIndtottribAndPaliq(): void valorServico: '1000.00', aliquota: '5.00', opcaoSimplesNacional: 2, // optante - indicadorTributacao: 1, ); $xml = $this->builder->buildDps($dpsOptante); - // For "optante" (opSimpNac = 2), indTotTrib and pAliq MUST be present - self::assertStringContainsString('', $xml); + // For "optante" (opSimpNac = 2), pAliq MUST be present. self::assertStringContainsString('', $xml); + self::assertStringNotContainsString('', $xml); + } + + public function testBuildDpsIncludesTotalTributosPercentuaisWhenConfigured(): void + { + $xml = $this->builder->buildDps($this->makeDps( + indicadorTributacao: 2, + totalTributosPercentualFederal: '3.65', + totalTributosPercentualEstadual: '0.00', + totalTributosPercentualMunicipal: '2.00', + )); + + self::assertStringContainsString('3.650.002.00', str_replace(["\n", ' '], '', $xml)); } // ------------------------------------------------------------------------- @@ -291,6 +354,9 @@ private function makeDps( int $tipoRetencaoIss = 1, int $opcaoSimplesNacional = 1, int $indicadorTributacao = 0, + string $totalTributosPercentualFederal = '', + string $totalTributosPercentualEstadual = '', + string $totalTributosPercentualMunicipal = '', string $federalPiscofinsSituacaoTributaria = '', string $federalPiscofinsTipoRetencao = '', string $federalPiscofinsBaseCalculo = '', @@ -319,6 +385,9 @@ private function makeDps( issRetido: $issRetido, opcaoSimplesNacional: $opcaoSimplesNacional, indicadorTributacao: $indicadorTributacao, + totalTributosPercentualFederal: $totalTributosPercentualFederal, + totalTributosPercentualEstadual: $totalTributosPercentualEstadual, + totalTributosPercentualMunicipal: $totalTributosPercentualMunicipal, federalPiscofinsSituacaoTributaria: $federalPiscofinsSituacaoTributaria, federalPiscofinsTipoRetencao: $federalPiscofinsTipoRetencao, federalPiscofinsBaseCalculo: $federalPiscofinsBaseCalculo, From 7905c2b59f3f90f921a40275a1c68e94f2d2f04d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:42:23 -0300 Subject: [PATCH 4/4] test(client): fix cMun->cLocEmi assertion and extend emit coverage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Http/NfseClientTest.php | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index f566c6f..a7aaa2f 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -79,6 +79,50 @@ public function testEmitReturnsReceiptDataOnSuccess(): void self::assertSame('ok', $receipt->rawXml); } + public function testEmitBuildsXmlWithTpAmbBeforeMunicipalityFields(): void + { + $payload = json_encode([ + 'nNFSe' => '100', + 'chaveAcesso' => 'tpamb-order-ok', + 'dataHoraProcessamento' => '2026-01-02T10:00:00', + ], JSON_THROW_ON_ERROR); + + self::$server->setResponseOfPath( + '/SefinNacional/nfse', + new Response($payload, ['Content-Type' => 'application/json'], 201) + ); + + $holder = new class () { + public string $capturedXml = ''; + }; + + $capturingSigner = new class ($holder) implements XmlSignerInterface { + public function __construct(private object $holder) + { + } + + public function sign(string $xml, string $cnpj): string + { + $this->holder->capturedXml = $xml; + + return $xml; + } + }; + + $client = $this->makeClient($capturingSigner); + $client->emit($this->makeDps()); + + self::assertNotSame('', $holder->capturedXml); + + $normalizedXml = str_replace(["\n", ' '], '', $holder->capturedXml); + $tpAmbIndex = strpos($normalizedXml, '2'); + $cLocEmiIndex = strpos($normalizedXml, '3303302'); + + self::assertNotFalse($tpAmbIndex); + self::assertNotFalse($cLocEmiIndex); + self::assertLessThan($cLocEmiIndex, $tpAmbIndex); + } + public function testQueryReturnsReceiptDataOnSuccess(): void { $payload = json_encode([