diff --git a/docs/decision-issuance.md b/docs/decision-issuance.md new file mode 100644 index 0000000..ea963b5 --- /dev/null +++ b/docs/decision-issuance.md @@ -0,0 +1,72 @@ + + +# Administrative or judicial decision issuance + +The Sistema Nacional NFS-e provides a dedicated contributor bypass for NFS-e +authorized under an administrative or judicial decision. This is an opt-in +protocol capability and is not part of ordinary DPS issuance. + +## Official contract + +The current production contributor manual documents: + +- endpoint: `POST /decisao-judicial/nfse`; +- request field: `xmlGZipB64`, containing the complete signed NFS-e XML + compressed with GZip and encoded as Base64; +- the contributor is responsible for every mandatory NFS-e field normally + generated/calculated by the national platform; +- the municipality must previously register the applicable decision and + authorize the contributor for this flow; +- after authorization, normal NFS-e query and cancellation APIs apply; +- substitution may be represented through the normal substitution relationship + embedded in the DPS contained by the complete NFS-e. + +Production documentation: +https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual + +## PHP API + +Use the optional `DecisionNfseIssuerInterface` capability. Ordinary +`NfseClientInterface` implementations do not need to implement decision-flow +issuance. + +```php +use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface; +use LibreCodeCoop\NfsePHP\Dto\DecisionNfseData; + +if ($client instanceof DecisionNfseIssuerInterface) { + $receipt = $client->emitDecision($decisionNfse); +} +``` + +`DecisionNfseData` contains the complete-NFS-e fields owned by the contributor +and embeds the ordinary `DpsData`. The library does not determine legal +eligibility, decision classification, numbering, rates, incidence municipality, +or values. + +## Validation and known schema contradiction + +Generated decision-flow documents should be validated with +`NfseSchemaValidator` against the vendored official schema version before live +submission. + +The January 2026 decision manual prescribes `nDFSe=0`, while the official +NFS-e v1.01 XSD requires a positive 1-to-13-digit value. The library keeps this +contradiction explicit rather than silently changing either contract. Callers +must use the value required by the environment they are integrating with and +keep the decision/legal source auditable. + +## IBS/CBS + +Decision-flow DPS data containing IBS/CBS currently raises a `LogicException` +because the bypass requires complete NFS-e-level calculated IBS/CBS values. +Those values are not silently omitted or inferred. + +## Transport and recovery + +The bypass POST is a mutable request and is never blindly retried. After a +successful authorization, use the returned access key with the normal +`query()`, `cancel()`, event and DANFSe operations. diff --git a/src/Contracts/DecisionNfseIssuerInterface.php b/src/Contracts/DecisionNfseIssuerInterface.php new file mode 100644 index 0000000..a2c04f5 --- /dev/null +++ b/src/Contracts/DecisionNfseIssuerInterface.php @@ -0,0 +1,22 @@ +102', $xml); + + $queried = $client->query($receipt->chaveAcesso); + self::assertSame('DECISION-240', $queried->chaveAcesso); + self::assertTrue($client->cancel($receipt->chaveAcesso, 'Cancelamento de teste')); + + self::assertCount(3, $transport->requests); + self::assertSame( + 'https://sefin.invalid.test/SefinNacional/nfse/DECISION-240', + $transport->requests[1]->url, + ); + self::assertSame( + 'https://sefin.invalid.test/SefinNacional/nfse/DECISION-240/eventos', + $transport->requests[2]->url, + ); } public function testSubstitutionUsesNormalIssuanceEndpointWithoutExtraMutationRequest(): void diff --git a/tests/Unit/Xml/DecisionNfseBuilderTest.php b/tests/Unit/Xml/DecisionNfseBuilderTest.php index 6a40149..26faf5f 100644 --- a/tests/Unit/Xml/DecisionNfseBuilderTest.php +++ b/tests/Unit/Xml/DecisionNfseBuilderTest.php @@ -10,6 +10,7 @@ use LibreCodeCoop\NfsePHP\Dto\DecisionIssuerAddressData; use LibreCodeCoop\NfsePHP\Dto\DecisionNfseData; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Dto\SubstitutionData; use LibreCodeCoop\NfsePHP\SecretStore\NoOpSecretStore; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\DecisionNfseBuilder; @@ -84,6 +85,23 @@ public function testKnownAccessKeyCheckDigit(): void self::assertSame(5, DecisionNfseBuilder::accessKeyCheckDigit(substr($key, 0, 49))); } + public function testDecisionFlowPreservesOfficialSubstitutionRelationship(): void + { + $dps = $this->dps(substitution: new SubstitutionData( + chaveNfseSubstituida: str_repeat('1', 50), + codigoMotivo: '01', + )); + + $xml = (new DecisionNfseBuilder())->build($this->decision(dps: $dps)); + + self::assertStringContainsString('', $xml); + self::assertStringContainsString( + '' . str_repeat('1', 50) . '', + $xml, + ); + self::assertStringContainsString('01', $xml); + } + public function testIbsCbsIsRejectedUntilCompleteNfseCalculatedValuesAreModeled(): void { $dps = $this->dps(ibs: true); @@ -113,7 +131,7 @@ private function decision(string $numeroDfse = '0', ?DpsData $dps = null): Decis ); } - private function dps(bool $ibs = false): DpsData + private function dps(bool $ibs = false, ?SubstitutionData $substitution = null): DpsData { return new DpsData( cnpjPrestador: '11222333000181', @@ -144,6 +162,7 @@ private function dps(bool $ibs = false): DpsData ibsCbsIndDest: $ibs ? 0 : null, ibsCbsCst: $ibs ? '000' : '', ibsCbsClassificacaoTributaria: $ibs ? '000001' : '', + substituicao: $substitution, ); }