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 @@ +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(