Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions references/schemas/nfse/1.01/NFSe_v1.01.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
targetNamespace="http://www.sped.fazenda.gov.br/nfse"
xmlns="http://www.sped.fazenda.gov.br/nfse"
attributeFormDefault="unqualified"
elementFormDefault="qualified">
<xs:import namespace="http://www.w3.org/2000/09/xmldsig#" schemaLocation="xmldsig-core-schema.xsd"/>
<xs:include schemaLocation="tiposComplexos_v1.01.xsd"/>
<xs:element name="NFSe" type="TCNFSe">
<xs:annotation>
<xs:documentation>Schema XML da Nota Fiscal de Serviços Eletrônica - NFS-e</xs:documentation>
</xs:annotation>
</xs:element>
</xs:schema>
1 change: 1 addition & 0 deletions references/schemas/nfse/1.01/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 22 additions & 0 deletions src/Dto/DecisionIssuerAddressData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Dto;

final readonly class DecisionIssuerAddressData
{
public function __construct(
public string $logradouro,
public string $numero,
public string $bairro,
public string $municipioIbge,
public string $uf,
public string $cep,
public string $complemento = '',
) {
}
}
47 changes: 47 additions & 0 deletions src/Dto/DecisionNfseData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Dto;

/**
* Complete NFS-e fields supplied by the contributor in the
* administrative/judicial-decision issuance flow.
*
* Municipal authorization for this bypass is a legal/platform prerequisite;
* the library deliberately does not infer eligibility.
*/
final readonly class DecisionNfseData
{
public function __construct(
public DpsData $dps,
public string $numeroNfse,
public string $codigoNumerico,
public string $localEmissao,
public string $localPrestacao,
public string $descricaoTributacaoNacional,
public string $emitenteNome,
public DecisionIssuerAddressData $emitenteEndereco,
public string $valorLiquido,
public string $numeroDfse = '0',
public ?string $codigoLocalIncidencia = null,
public ?string $localIncidencia = null,
public ?string $descricaoTributacaoMunicipal = null,
public ?string $descricaoNbs = null,
public string $versaoAplicativo = 'nfse-php',
public ?int $processoEmissao = null,
public ?string $emitenteNomeFantasia = null,
public ?string $emitenteInscricaoMunicipal = null,
public ?string $emitenteTelefone = null,
public ?string $emitenteEmail = null,
public ?string $baseCalculo = null,
public ?string $aliquotaAplicada = null,
public ?string $valorIssqn = null,
public ?string $valorTotalRetido = null,
public ?string $outrasInformacoes = null,
) {
}
}
205 changes: 205 additions & 0 deletions src/Xml/DecisionNfseBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Xml;

use LibreCodeCoop\NfsePHP\Dto\DecisionIssuerAddressData;
use LibreCodeCoop\NfsePHP\Dto\DecisionNfseData;

/**
* Builds the complete unsigned NFS-e required by the decision bypass.
*/
final class DecisionNfseBuilder
{
private const NS = 'http://www.sped.fazenda.gov.br/nfse';

public function build(DecisionNfseData $data): string
{
$this->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);
$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');
$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);
}
}
}
64 changes: 64 additions & 0 deletions src/Xml/NfseSchemaValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Xml;

final class NfseSchemaValidator
{
public const SCHEMA_VERSION = '1.01';

public function __construct(
private readonly ?string $schemaPath = null,
private readonly string $schemaVersion = self::SCHEMA_VERSION,
) {
if ($schemaVersion !== self::SCHEMA_VERSION) {
throw new \InvalidArgumentException('Unsupported complete NFS-e schema version: ' . $schemaVersion);
}
}

/** @return list<string> */
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<string> */
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;
}
}
Loading
Loading