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
72 changes: 72 additions & 0 deletions docs/decision-issuance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!--
SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->

# 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.
22 changes: 22 additions & 0 deletions src/Contracts/DecisionNfseIssuerInterface.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\Contracts;

use LibreCodeCoop\NfsePHP\Dto\DecisionNfseData;
use LibreCodeCoop\NfsePHP\Dto\ReceiptData;

/**
* Optional capability for administrative/judicial-decision NFS-e issuance.
*
* Kept separate from NfseClientInterface so ordinary NFS-e client
* implementations are not forced to support the legal bypass flow.
*/
interface DecisionNfseIssuerInterface
{
public function emitDecision(DecisionNfseData $nfse): ReceiptData;
}
6 changes: 0 additions & 6 deletions src/Contracts/NfseClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

namespace LibreCodeCoop\NfsePHP\Contracts;

use LibreCodeCoop\NfsePHP\Dto\DecisionNfseData;
use LibreCodeCoop\NfsePHP\Dto\DpsData;
use LibreCodeCoop\NfsePHP\Dto\ReceiptData;

Expand All @@ -18,11 +17,6 @@ interface NfseClientInterface
*/
public function emit(DpsData $dps): ReceiptData;

/**
* Emit a complete NFS-e through the administrative/judicial decision bypass.
*/
public function emitDecision(DecisionNfseData $nfse): ReceiptData;

/**
* Query an existing NFS-e by its access key.
*/
Expand Down
3 changes: 2 additions & 1 deletion src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use LibreCodeCoop\NfsePHP\Config\CertConfig;
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface;
use LibreCodeCoop\NfsePHP\Contracts\DpsLookupInterface;
use LibreCodeCoop\NfsePHP\Contracts\EventLookupInterface;
use LibreCodeCoop\NfsePHP\Contracts\EventRegistrationInterface;
Expand Down Expand Up @@ -41,7 +42,7 @@
* Communicates with the SEFIN gateway to issue, query, and cancel NFS-e.
* All requests carry a signed DPS XML payload.
*/
class NfseClient implements NfseClientInterface, DpsLookupInterface, EventLookupInterface, EventRegistrationInterface
class NfseClient implements NfseClientInterface, DecisionNfseIssuerInterface, DpsLookupInterface, EventLookupInterface, EventRegistrationInterface
{
private readonly string $baseUrl;
private readonly XmlSignerInterface $signer;
Expand Down
29 changes: 25 additions & 4 deletions tests/Unit/Http/InjectableTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,17 @@ public function sign(string $xml, string $cnpj): string

public function testDecisionIssuanceUsesDedicatedBypassEndpointAndPayloadContract(): void
{
$transport = new FakeHttpTransport(new HttpResponseData(
201,
'{"nNFSe":"240","chaveAcesso":"DECISION-240","dataHoraProcessamento":"2026-10-04T12:00:00-03:00"}',
));
$transport = new FakeHttpTransport(
new HttpResponseData(
201,
'{"nNFSe":"240","chaveAcesso":"DECISION-240","dataHoraProcessamento":"2026-10-04T12:00:00-03:00"}',
),
new HttpResponseData(
200,
'{"nNFSe":"240","chaveAcesso":"DECISION-240","dataHoraProcessamento":"2026-10-04T12:01:00-03:00"}',
),
new HttpResponseData(200, '{"sucesso":true}'),
);

$client = new NfseClient(
environment: new EnvironmentConfig(
Expand Down Expand Up @@ -171,6 +178,20 @@ public function sign(string $xml, string $cnpj): string
self::assertNotFalse($xml);
self::assertStringContainsString('<NFSe', $xml);
self::assertStringContainsString('<cStat>102</cStat>', $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
Expand Down
21 changes: 20 additions & 1 deletion tests/Unit/Xml/DecisionNfseBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('<subst>', $xml);
self::assertStringContainsString(
'<chSubstda>' . str_repeat('1', 50) . '</chSubstda>',
$xml,
);
self::assertStringContainsString('<cMotivo>01</cMotivo>', $xml);
}

public function testIbsCbsIsRejectedUntilCompleteNfseCalculatedValuesAreModeled(): void
{
$dps = $this->dps(ibs: true);
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -144,6 +162,7 @@ private function dps(bool $ibs = false): DpsData
ibsCbsIndDest: $ibs ? 0 : null,
ibsCbsCst: $ibs ? '000' : '',
ibsCbsClassificacaoTributaria: $ibs ? '000001' : '',
substituicao: $substitution,
);
}

Expand Down
Loading