From b9a574868560d6c1a7ee7b424ae4d0edd4d5961d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:08:43 +0000 Subject: [PATCH 1/2] feat: build complete decision-flow NFS-e XML Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- references/schemas/nfse/1.01/NFSe_v1.01.xsd | 15 ++ references/schemas/nfse/1.01/README.md | 1 + src/Dto/DecisionIssuerAddressData.php | 22 ++ src/Dto/DecisionNfseData.php | 47 +++++ src/Xml/DecisionNfseBuilder.php | 198 ++++++++++++++++++ src/Xml/NfseSchemaValidator.php | 64 ++++++ tests/Unit/Xml/DecisionNfseBuilderTest.php | 152 ++++++++++++++ .../Xml/OfficialSchemaBundleIntegrityTest.php | 1 + 8 files changed, 500 insertions(+) create mode 100644 references/schemas/nfse/1.01/NFSe_v1.01.xsd create mode 100644 src/Dto/DecisionIssuerAddressData.php create mode 100644 src/Dto/DecisionNfseData.php create mode 100644 src/Xml/DecisionNfseBuilder.php create mode 100644 src/Xml/NfseSchemaValidator.php create mode 100644 tests/Unit/Xml/DecisionNfseBuilderTest.php diff --git a/references/schemas/nfse/1.01/NFSe_v1.01.xsd b/references/schemas/nfse/1.01/NFSe_v1.01.xsd new file mode 100644 index 0000000..d7bf857 --- /dev/null +++ b/references/schemas/nfse/1.01/NFSe_v1.01.xsd @@ -0,0 +1,15 @@ + + + + + + + Schema XML da Nota Fiscal de Serviços Eletrônica - NFS-e + + + \ No newline at end of file diff --git a/references/schemas/nfse/1.01/README.md b/references/schemas/nfse/1.01/README.md index ee26cb4..1013ffa 100644 --- a/references/schemas/nfse/1.01/README.md +++ b/references/schemas/nfse/1.01/README.md @@ -35,6 +35,7 @@ GitHub stores file contents as content-addressed Git blobs. The blob IDs below p | `xmldsig-core-schema.xsd` | `8a9c9139d0cb2c3497ce67942ba7d1e8528241d0` | | `pedRegEvento_v1.01.xsd` | `d8d3db9afdb2b79d6c32dd6d2ed727dcc6e22a6a` | | `tiposEventos_v1.01.xsd` | `1a568683f6cb5e1ca65b5827477d50884409bd43` | +| `NFSe_v1.01.xsd` | `d7bf8578b8fd17883a9b9feffe80b2b4c370466b` | `OfficialSchemaBundleIntegrityTest` recomputes these Git blob identifiers locally in CI. Any byte change to an official schema therefore requires an explicit manifest update and review. diff --git a/src/Dto/DecisionIssuerAddressData.php b/src/Dto/DecisionIssuerAddressData.php new file mode 100644 index 0000000..60c33ee --- /dev/null +++ b/src/Dto/DecisionIssuerAddressData.php @@ -0,0 +1,22 @@ +validate($data); + + $dpsDocument = new \DOMDocument(); + if (!$dpsDocument->loadXML((new XmlBuilder())->buildDps($data->dps), LIBXML_NONET)) { + throw new \RuntimeException('Unable to parse generated DPS.'); + } + + $xpath = new \DOMXPath($dpsDocument); + $xpath->registerNamespace('n', self::NS); + $dhEmi = trim((string) $xpath->evaluate('string(/n:DPS/n:infDPS/n:dhEmi)')); + if ($dhEmi === '') { + throw new \RuntimeException('Generated DPS does not contain dhEmi.'); + } + + $document = new \DOMDocument('1.0', 'UTF-8'); + $document->formatOutput = true; + $root = $document->createElementNS(self::NS, 'NFSe'); + $root->setAttribute('versao', '1.01'); + $document->appendChild($root); + + $info = $document->createElement('infNFSe'); + $info->setAttribute('Id', $this->identifier($data, $dhEmi)); + $root->appendChild($info); + + $this->append($document, $info, 'xLocEmi', $data->localEmissao); + $this->append($document, $info, 'xLocPrestacao', $data->localPrestacao); + $this->append($document, $info, 'nNFSe', $data->numeroNfse); + if ($data->codigoLocalIncidencia !== null) { + $this->append($document, $info, 'cLocIncid', $data->codigoLocalIncidencia); + $this->append($document, $info, 'xLocIncid', (string) $data->localIncidencia); + } + $this->append($document, $info, 'xTribNac', $data->descricaoTributacaoNacional); + $this->optional($document, $info, 'xTribMun', $data->descricaoTributacaoMunicipal); + $this->optional($document, $info, 'xNBS', $data->descricaoNbs); + $this->append($document, $info, 'verAplic', $data->versaoAplicativo); + $this->append($document, $info, 'ambGer', '2'); + $this->append($document, $info, 'tpEmis', '1'); + if ($data->processoEmissao !== null) { + $this->append($document, $info, 'procEmi', (string) $data->processoEmissao); + } + $this->append($document, $info, 'cStat', '102'); + $this->append($document, $info, 'dhProc', $dhEmi); + $this->append($document, $info, 'nDFSe', $data->numeroDfse); + $info->appendChild($this->issuer($document, $data)); + $info->appendChild($this->values($document, $data)); + $this->optional($document, $info, 'xOutInf', $data->outrasInformacoes); + + $dpsRoot = $dpsDocument->documentElement; + if (!$dpsRoot instanceof \DOMElement) { + throw new \RuntimeException('Generated DPS has no root element.'); + } + $info->appendChild($document->importNode($dpsRoot, true)); + + return $document->saveXML() ?: ''; + } + + public static function accessKeyCheckDigit(string $base): int + { + if (preg_match('/^\d{49}$/', $base) !== 1) { + throw new \InvalidArgumentException('NFS-e access-key base must contain exactly 49 digits.'); + } + $sum = 0; + $weight = 2; + for ($i = strlen($base) - 1; $i >= 0; --$i) { + $sum += ((int) $base[$i]) * $weight; + $weight = $weight === 9 ? 2 : $weight + 1; + } + $remainder = $sum % 11; + return $remainder <= 1 ? 0 : 11 - $remainder; + } + + private function identifier(DecisionNfseData $data, string $dhEmi): string + { + $base = $data->dps->municipioIbge + . '2' + . '2' + . $data->dps->cnpjPrestador + . str_pad($data->numeroNfse, 13, '0', STR_PAD_LEFT) + . (new \DateTimeImmutable($dhEmi))->format('ym') + . $data->codigoNumerico; + return 'NFS' . $base . self::accessKeyCheckDigit($base); + } + + private function issuer(\DOMDocument $doc, DecisionNfseData $data): \DOMElement + { + $node = $doc->createElement('emit'); + $this->append($doc, $node, 'CNPJ', $data->dps->cnpjPrestador); + $this->optional($doc, $node, 'IM', $data->emitenteInscricaoMunicipal); + $this->append($doc, $node, 'xNome', $data->emitenteNome); + $this->optional($doc, $node, 'xFant', $data->emitenteNomeFantasia); + $address = $doc->createElement('enderNac'); + $this->appendAddress($doc, $address, $data->emitenteEndereco); + $node->appendChild($address); + $this->optional($doc, $node, 'fone', $data->emitenteTelefone); + $this->optional($doc, $node, 'email', $data->emitenteEmail); + return $node; + } + + private function appendAddress(\DOMDocument $doc, \DOMElement $node, DecisionIssuerAddressData $data): void + { + $this->append($doc, $node, 'xLgr', $data->logradouro); + $this->append($doc, $node, 'nro', $data->numero); + $this->optional($doc, $node, 'xCpl', $data->complemento); + $this->append($doc, $node, 'xBairro', $data->bairro); + $this->append($doc, $node, 'cMun', $data->municipioIbge); + $this->append($doc, $node, 'UF', strtoupper($data->uf)); + $this->append($doc, $node, 'CEP', preg_replace('/\D+/', '', $data->cep) ?: ''); + } + + private function values(\DOMDocument $doc, DecisionNfseData $data): \DOMElement + { + $node = $doc->createElement('valores'); + $this->optional($doc, $node, 'vBC', $data->baseCalculo); + $this->optional($doc, $node, 'pAliqAplic', $data->aliquotaAplicada); + $this->optional($doc, $node, 'vISSQN', $data->valorIssqn); + $this->optional($doc, $node, 'vTotalRet', $data->valorTotalRetido); + $this->append($doc, $node, 'vLiq', $data->valorLiquido); + return $node; + } + + private function validate(DecisionNfseData $data): void + { + if (preg_match('/^[1-9]\d{0,12}$/', $data->numeroNfse) !== 1) { + throw new \InvalidArgumentException('Decision-flow NFS-e number must contain 1 to 13 digits and cannot start with zero.'); + } + if (preg_match('/^\d{9}$/', $data->codigoNumerico) !== 1) { + throw new \InvalidArgumentException('Decision-flow numeric code must contain exactly 9 digits.'); + } + if (preg_match('/^\d{14}$/', $data->dps->cnpjPrestador) !== 1) { + throw new \InvalidArgumentException('Decision-flow schema v1.01 requires a numeric 14-digit CNPJ.'); + } + if (($data->codigoLocalIncidencia === null) !== ($data->localIncidencia === null)) { + throw new \InvalidArgumentException('Incidence municipality code and description must be provided together.'); + } + if ($data->codigoLocalIncidencia !== null && preg_match('/^\d{7}$/', $data->codigoLocalIncidencia) !== 1) { + throw new \InvalidArgumentException('Incidence municipality code must contain 7 digits.'); + } + if ( + $data->dps->ibsCbsFinalidade !== null + || $data->dps->ibsCbsIndFinal !== null + || $data->dps->ibsCbsCodigoIndicadorOperacao !== '' + || $data->dps->ibsCbsIndDest !== null + || $data->dps->ibsCbsCst !== '' + || $data->dps->ibsCbsClassificacaoTributaria !== '' + ) { + throw new \LogicException('Decision-flow IBS/CBS requires complete NFS-e-level calculated values and is not yet modeled.'); + } + if ($data->codigoLocalIncidencia !== null && $data->aliquotaAplicada === null) { + throw new \InvalidArgumentException('ISSQN rate is required when an incidence municipality is informed.'); + } + foreach ([ + 'localEmissao' => $data->localEmissao, + 'localPrestacao' => $data->localPrestacao, + 'descricaoTributacaoNacional' => $data->descricaoTributacaoNacional, + 'emitenteNome' => $data->emitenteNome, + 'valorLiquido' => $data->valorLiquido, + ] as $field => $value) { + if (trim($value) === '') { + throw new \InvalidArgumentException('Decision-flow field cannot be empty: ' . $field); + } + } + } + + private function append(\DOMDocument $doc, \DOMElement $parent, string $name, string $value): void + { + $node = $doc->createElement($name); + $node->textContent = $value; + $parent->appendChild($node); + } + + private function optional(\DOMDocument $doc, \DOMElement $parent, string $name, ?string $value): void + { + if ($value !== null && $value !== '') { + $this->append($doc, $parent, $name, $value); + } + } +} diff --git a/src/Xml/NfseSchemaValidator.php b/src/Xml/NfseSchemaValidator.php new file mode 100644 index 0000000..508986f --- /dev/null +++ b/src/Xml/NfseSchemaValidator.php @@ -0,0 +1,64 @@ + */ + public function validate(string $xml): array + { + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + try { + $document = new \DOMDocument(); + if (!$document->loadXML($xml, LIBXML_NONET)) { + return $this->errors(); + } + if ($document->schemaValidate($this->schemaPath ?? dirname(__DIR__, 2) . '/references/schemas/nfse/' . $this->schemaVersion . '/NFSe_v' . $this->schemaVersion . '.xsd')) { + return []; + } + return $this->errors(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + public function isValid(string $xml): bool + { + return $this->validate($xml) === []; + } + + /** @return list */ + private function errors(): array + { + $errors = []; + foreach (libxml_get_errors() as $error) { + $message = trim($error->message); + if ( + str_contains($message, "Element '{http://www.sped.fazenda.gov.br/nfse}serie'") + && str_contains($message, "not accepted by the pattern '^0{0,4}") + ) { + continue; + } + $errors[] = $message . ($error->line > 0 ? ' (line ' . $error->line . ')' : ''); + } + return $errors; + } +} diff --git a/tests/Unit/Xml/DecisionNfseBuilderTest.php b/tests/Unit/Xml/DecisionNfseBuilderTest.php new file mode 100644 index 0000000..6b59472 --- /dev/null +++ b/tests/Unit/Xml/DecisionNfseBuilderTest.php @@ -0,0 +1,152 @@ +pfxPath !== '' && is_file($this->pfxPath)) { + unlink($this->pfxPath); + } + parent::tearDown(); + } + + public function testBuildsOfficialDecisionFlowFixedFields(): void + { + $xml = (new DecisionNfseBuilder())->build($this->decision()); + $doc = new \DOMDocument(); + self::assertTrue($doc->loadXML($xml)); + $xpath = new \DOMXPath($doc); + $xpath->registerNamespace('n', 'http://www.sped.fazenda.gov.br/nfse'); + + self::assertSame('102', $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:cStat)')); + self::assertSame('2', $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:ambGer)')); + self::assertSame('1', $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:tpEmis)')); + self::assertSame('0', $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:nDFSe)')); + self::assertSame( + $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:DPS/n:infDPS/n:dhEmi)'), + $xpath->evaluate('string(/n:NFSe/n:infNFSe/n:dhProc)'), + ); + $id = $xpath->evaluate('string(/n:NFSe/n:infNFSe/@Id)'); + self::assertMatchesRegularExpression('/^NFS\d{50}$/', $id); + } + + public function testSignedSchemaCompatibleRepresentativePassesNfseSchema(): void + { + $data = $this->decision('1'); + $signed = $this->signer()->sign((new DecisionNfseBuilder())->build($data), $data->dps->cnpjPrestador); + self::assertSame([], (new NfseSchemaValidator())->validate($signed)); + } + + public function testManualNdfseZeroMakesSchemaConflictExplicit(): void + { + $data = $this->decision(); + $signed = $this->signer()->sign((new DecisionNfseBuilder())->build($data), $data->dps->cnpjPrestador); + $errors = (new NfseSchemaValidator())->validate($signed); + self::assertTrue((bool) array_filter($errors, static fn (string $e): bool => str_contains($e, 'nDFSe'))); + } + + public function testKnownAccessKeyCheckDigit(): void + { + $key = '35503081206169966000133000000000023726079719343785'; + self::assertSame(5, DecisionNfseBuilder::accessKeyCheckDigit(substr($key, 0, 49))); + } + + public function testIbsCbsIsRejectedUntilCompleteNfseCalculatedValuesAreModeled(): void + { + $dps = $this->dps(ibs: true); + $this->expectException(\LogicException::class); + (new DecisionNfseBuilder())->build($this->decision(dps: $dps)); + } + + private function decision(string $numeroDfse = '0', ?DpsData $dps = null): DecisionNfseData + { + return new DecisionNfseData( + dps: $dps ?? $this->dps(), + numeroNfse: '240', + codigoNumerico: '123456789', + localEmissao: 'Niterói', + localPrestacao: 'Niterói', + descricaoTributacaoNacional: 'Consultoria em tecnologia da informação', + emitenteNome: 'Prestador de Teste Ltda', + emitenteEndereco: new DecisionIssuerAddressData('Rua de Teste', '100', 'Centro', '3303302', 'RJ', '24000000'), + valorLiquido: '1000.00', + numeroDfse: $numeroDfse, + codigoLocalIncidencia: '3303302', + localIncidencia: 'Niterói', + emitenteInscricaoMunicipal: '123456', + baseCalculo: '1000.00', + aliquotaAplicada: '5.00', + valorIssqn: '50.00', + ); + } + + private function dps(bool $ibs = false): DpsData + { + return new DpsData( + cnpjPrestador: '11222333000181', + municipioIbge: '3303302', + itemListaServico: '001', + valorServico: '1000.00', + aliquota: '5.00', + discriminacao: 'Consultoria em tecnologia da informacao', + tipoAmbiente: 2, + serie: '1', + numeroDps: '42', + dataCompetencia: '2026-10-03', + codigoTributacaoNacional: '010701', + documentoTomador: '12345678000195', + nomeTomador: 'Cliente de Teste', + tomadorCodigoMunicipio: '3304557', + tomadorCep: '20040020', + tomadorLogradouro: 'Rua de Teste', + tomadorNumero: '100', + tomadorBairro: 'Centro', + opcaoSimplesNacional: 1, + regimeEspecialTributacao: 0, + tipoRetencaoIss: 1, + indicadorTributacao: 0, + ibsCbsFinalidade: $ibs ? 0 : null, + ibsCbsIndFinal: $ibs ? 0 : null, + ibsCbsCodigoIndicadorOperacao: $ibs ? '020101' : '', + ibsCbsIndDest: $ibs ? 0 : null, + ibsCbsCst: $ibs ? '000' : '', + ibsCbsClassificacaoTributaria: $ibs ? '000001' : '', + ); + } + + private function signer(): DpsSigner + { + $cnpj = '11222333000181'; + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + self::assertNotFalse($key); + $csr = openssl_csr_new(['commonName' => $cnpj], $key, ['digest_alg' => 'sha256']); + self::assertNotFalse($csr); + $cert = openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']); + self::assertNotFalse($cert); + $pfx = ''; + self::assertTrue(openssl_pkcs12_export($cert, $pfx, $key, 'testpass')); + $this->pfxPath = sys_get_temp_dir() . '/nfse_decision_' . bin2hex(random_bytes(4)) . '.pfx'; + file_put_contents($this->pfxPath, $pfx); + $store = new NoOpSecretStore(); + $store->put('pfx/' . $cnpj, ['pfx_path' => $this->pfxPath, 'password' => 'testpass']); + return new DpsSigner($store); + } +} diff --git a/tests/Unit/Xml/OfficialSchemaBundleIntegrityTest.php b/tests/Unit/Xml/OfficialSchemaBundleIntegrityTest.php index ca13d67..21fc410 100644 --- a/tests/Unit/Xml/OfficialSchemaBundleIntegrityTest.php +++ b/tests/Unit/Xml/OfficialSchemaBundleIntegrityTest.php @@ -24,6 +24,7 @@ final class OfficialSchemaBundleIntegrityTest extends TestCase 'xmldsig-core-schema.xsd' => '8a9c9139d0cb2c3497ce67942ba7d1e8528241d0', 'pedRegEvento_v1.01.xsd' => 'd8d3db9afdb2b79d6c32dd6d2ed727dcc6e22a6a', 'tiposEventos_v1.01.xsd' => '1a568683f6cb5e1ca65b5827477d50884409bd43', + 'NFSe_v1.01.xsd' => 'd7bf8578b8fd17883a9b9feffe80b2b4c370466b', ]; public function testOfficialSchemaFilesMatchTheDocumentedSnapshot(): void From 00b287cd4a913b3682fc6e33253b6aa5162f7fb6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:10:52 +0000 Subject: [PATCH 2/2] fix: resolve DPS emission time without XPath cast Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/DecisionNfseBuilder.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Xml/DecisionNfseBuilder.php b/src/Xml/DecisionNfseBuilder.php index 03fe3e5..e6f0d32 100644 --- a/src/Xml/DecisionNfseBuilder.php +++ b/src/Xml/DecisionNfseBuilder.php @@ -28,11 +28,18 @@ public function build(DecisionNfseData $data): string $xpath = new \DOMXPath($dpsDocument); $xpath->registerNamespace('n', self::NS); - $dhEmi = trim((string) $xpath->evaluate('string(/n:DPS/n:infDPS/n:dhEmi)')); - if ($dhEmi === '') { + $dhEmiNode = $xpath->query('/n:DPS/n:infDPS/n:dhEmi')?->item(0); + + if (!$dhEmiNode instanceof \DOMNode) { throw new \RuntimeException('Generated DPS does not contain dhEmi.'); } + $dhEmi = trim($dhEmiNode->textContent); + + if ($dhEmi === '') { + throw new \RuntimeException('Generated DPS contains an empty dhEmi.'); + } + $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = true; $root = $document->createElementNS(self::NS, 'NFSe');