From 4e3a9a964818502d4c8bf1cd5594c22149ec00cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:11:17 +0000 Subject: [PATCH 1/3] refactor: expose structured cancellation contract Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Contracts/CancellationClientInterface.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/Contracts/CancellationClientInterface.php diff --git a/src/Contracts/CancellationClientInterface.php b/src/Contracts/CancellationClientInterface.php new file mode 100644 index 0000000..8a7df9d --- /dev/null +++ b/src/Contracts/CancellationClientInterface.php @@ -0,0 +1,19 @@ + Date: Thu, 8 Oct 2026 01:12:14 +0000 Subject: [PATCH 2/3] fix: validate and encode official cancellation reasons Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Http/NfseClient.php | 35 ++++++++++-- tests/Unit/Http/NfseClientTest.php | 56 +++++++++++++++++++ .../Xml/OfficialEventSchemaValidationTest.php | 29 ++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 6e90928..953d2bb 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -10,6 +10,7 @@ use LibreCodeCoop\NfsePHP\Config\CertConfig; use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig; use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface; +use LibreCodeCoop\NfsePHP\Contracts\CancellationClientInterface; use LibreCodeCoop\NfsePHP\Contracts\DpsLookupInterface; use LibreCodeCoop\NfsePHP\Contracts\EventLookupInterface; use LibreCodeCoop\NfsePHP\Contracts\EventRegistrationInterface; @@ -42,7 +43,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, DecisionNfseIssuerInterface, DpsLookupInterface, EventLookupInterface, EventRegistrationInterface +class NfseClient implements NfseClientInterface, CancellationClientInterface, DecisionNfseIssuerInterface, DpsLookupInterface, EventLookupInterface, EventRegistrationInterface { private readonly string $baseUrl; private readonly XmlSignerInterface $signer; @@ -240,7 +241,17 @@ public function registerEventXml(string $chaveAcesso, string $eventXml): EventRe public function cancel(string $chaveAcesso, string $motivo): bool { - $eventoXml = $this->buildCancelEventXml($chaveAcesso, $motivo); + return $this->cancelWithReason($chaveAcesso, '1', $motivo); + } + + #[\Override] + public function cancelWithReason(string $chaveAcesso, string $codigoMotivo, string $motivo): bool + { + $codigoMotivo = trim($codigoMotivo); + $motivo = trim($motivo); + $this->assertCancellationReason($codigoMotivo, $motivo); + + $eventoXml = $this->buildCancelEventXml($chaveAcesso, $codigoMotivo, $motivo); try { $this->registerEventXml($chaveAcesso, $eventoXml); @@ -409,7 +420,7 @@ private function assertEventXmlMatchesAccessKey(string $eventXml, string $chaveA } } - private function buildCancelEventXml(string $chaveAcesso, string $motivo): string + private function buildCancelEventXml(string $chaveAcesso, string $codigoMotivo, string $motivo): string { $doc = new \DOMDocument('1.0', 'UTF-8'); $doc->formatOutput = false; @@ -430,13 +441,29 @@ private function buildCancelEventXml(string $chaveAcesso, string $motivo): strin $e101101 = $doc->createElement('e101101'); $e101101->appendChild($doc->createElement('xDesc', 'Cancelamento de NFS-e')); - $e101101->appendChild($doc->createElement('cMotivo', '1')); + $e101101->appendChild($doc->createElement('cMotivo', $codigoMotivo)); $e101101->appendChild($doc->createElement('xMotivo', $motivo)); $infPedReg->appendChild($e101101); return $doc->saveXML($doc->documentElement) ?: ''; } + private function assertCancellationReason(string $codigoMotivo, string $motivo): void + { + if (!in_array($codigoMotivo, ['1', '2', '9'], true)) { + throw new \InvalidArgumentException('Cancellation reason code must be one of: 1, 2, 9.'); + } + + $length = preg_match_all('/./us', $motivo, $characters); + if ($length === false) { + throw new \InvalidArgumentException('Cancellation reason description must be valid UTF-8.'); + } + + if ($length < 15 || $length > 255) { + throw new \InvalidArgumentException('Cancellation reason description must contain between 15 and 255 characters.'); + } + } + /** * @param array $response */ diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index 31c180a..191e70d 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -380,6 +380,62 @@ public function testCancelReturnsTrueOnSuccess(): void self::assertStringContainsString('Cancelamento a pedido do tomador', $eventoXml); } + public function testCancelWithReasonUsesRequestedOfficialReasonCode(): void + { + self::$server->setResponseOfPath( + '/SefinNacional/nfse/abc-123/eventos', + new Response('{}', ['Content-Type' => 'application/json'], 200) + ); + + $client = $this->makeClient($this->signer); + self::assertTrue($client->cancelWithReason('abc-123', '9', 'Outro motivo válido')); + + $request = self::$server->getLastRequest(); + self::assertNotNull($request); + $payload = json_decode($request->getInput(), true, 512, JSON_THROW_ON_ERROR); + $compressedXml = base64_decode((string) $payload['pedidoRegistroEventoXmlGZipB64'], true); + self::assertNotFalse($compressedXml); + $eventoXml = gzdecode($compressedXml); + self::assertNotFalse($eventoXml); + self::assertStringContainsString('9', $eventoXml); + self::assertStringContainsString('Outro motivo válido', $eventoXml); + } + + public function testCancelWithReasonAcceptsOfficialDescriptionBoundaries(): void + { + self::$server->setResponseOfPath( + '/SefinNacional/nfse/abc-123/eventos', + new Response('{}', ['Content-Type' => 'application/json'], 200) + ); + + $client = $this->makeClient($this->signer); + self::assertTrue($client->cancelWithReason('abc-123', '1', str_repeat('a', 15))); + self::assertTrue($client->cancelWithReason('abc-123', '2', str_repeat('b', 255))); + } + + /** + * @dataProvider invalidCancellationReasons + */ + public function testCancelWithReasonRejectsInvalidContractBeforeNetwork(string $code, string $description): void + { + $client = $this->makeClient($this->signer); + + $this->expectException(\InvalidArgumentException::class); + $client->cancelWithReason('abc-123', $code, $description); + } + + /** + * @return array + */ + public static function invalidCancellationReasons(): array + { + return [ + 'unknown reason code' => ['3', str_repeat('a', 15)], + 'description too short' => ['1', str_repeat('a', 14)], + 'description too long' => ['1', str_repeat('a', 256)], + ]; + } + public function testEmitRejectsMalformedBase64NfseXmlWithoutPhpWarning(): void { $payload = json_encode([ diff --git a/tests/Unit/Xml/OfficialEventSchemaValidationTest.php b/tests/Unit/Xml/OfficialEventSchemaValidationTest.php index d9cfd58..99abbad 100644 --- a/tests/Unit/Xml/OfficialEventSchemaValidationTest.php +++ b/tests/Unit/Xml/OfficialEventSchemaValidationTest.php @@ -46,6 +46,35 @@ public function testValidatorRejectsUnknownEventElement(): void self::assertNotSame([], $this->validator->validate($invalidXml)); } + /** + * @dataProvider cancellationReasonLengthCases + */ + public function testCancellationReasonLengthMatchesOfficialSchema(int $length, bool $valid): void + { + $xml = str_replace( + 'Erro de emissao confirmado pelo prestador', + str_repeat('a', $length), + $this->cancellationXml(), + ); + + $errors = $this->validator->validate($xml); + + $valid ? self::assertSame([], $errors) : self::assertNotSame([], $errors); + } + + /** + * @return array + */ + public static function cancellationReasonLengthCases(): array + { + return [ + '14 rejected' => [14, false], + '15 accepted' => [15, true], + '255 accepted' => [255, true], + '256 rejected' => [256, false], + ]; + } + public function testValidatorRejectsWrongEventElementOrder(): void { $invalidXml = str_replace( From 10d6e8489c7e9ef4368ed0c693d26bc9aaef528a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:29:35 +0000 Subject: [PATCH 3/3] style: sort cancellation contract import Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Http/NfseClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 953d2bb..4e8afe4 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -9,8 +9,8 @@ use LibreCodeCoop\NfsePHP\Config\CertConfig; use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig; -use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface; use LibreCodeCoop\NfsePHP\Contracts\CancellationClientInterface; +use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface; use LibreCodeCoop\NfsePHP\Contracts\DpsLookupInterface; use LibreCodeCoop\NfsePHP\Contracts\EventLookupInterface; use LibreCodeCoop\NfsePHP\Contracts\EventRegistrationInterface;