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');