diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 9a333f3..11ec0c0 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -80,7 +80,19 @@ public function query(string $chaveAcesso): ReceiptData public function cancel(string $chaveAcesso, string $motivo): bool { - [$httpStatus, $body] = $this->delete('/dps/' . $chaveAcesso, $motivo); + $eventoXml = $this->buildCancelEventXml($chaveAcesso, $motivo); + $signedEventoXml = $this->signer->sign($eventoXml, $this->cert->cnpj); + + $compressedEventoXml = gzencode($signedEventoXml); + + if ($compressedEventoXml === false) { + throw new NetworkException('Failed to compress cancellation event XML payload before transmission.'); + } + + [$httpStatus, $body] = $this->postEvento( + '/nfse/' . $chaveAcesso . '/eventos', + base64_encode($compressedEventoXml), + ); if ($httpStatus >= 400) { throw new CancellationException( @@ -146,12 +158,15 @@ private function get(string $path): array /** * @return array{int, array} */ - private function delete(string $path, string $motivo): array + private function postEvento(string $path, string $eventoXmlGZipB64): array { - $payload = json_encode(['motivo' => $motivo], JSON_THROW_ON_ERROR); + $payload = json_encode([ + 'pedidoRegistroEventoXmlGZipB64' => $eventoXmlGZipB64, + ], JSON_THROW_ON_ERROR); + $context = stream_context_create([ 'http' => [ - 'method' => 'DELETE', + 'method' => 'POST', 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n", 'content' => $payload, 'ignore_errors' => true, @@ -162,6 +177,34 @@ private function delete(string $path, string $motivo): array return $this->fetchAndDecode($path, $context); } + private function buildCancelEventXml(string $chaveAcesso, string $motivo): string + { + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->formatOutput = false; + + $root = $doc->createElementNS('http://www.sped.fazenda.gov.br/nfse', 'pedRegEvento'); + $root->setAttribute('versao', '1.01'); + $doc->appendChild($root); + + $infPedReg = $doc->createElement('infPedReg'); + $infPedReg->setAttribute('Id', 'PRE' . $chaveAcesso . '101101'); + $root->appendChild($infPedReg); + + $infPedReg->appendChild($doc->createElement('tpAmb', $this->environment->sandboxMode ? '2' : '1')); + $infPedReg->appendChild($doc->createElement('verAplic', 'akaunting-nfse')); + $infPedReg->appendChild($doc->createElement('dhEvento', (new \DateTimeImmutable())->format('Y-m-d\\TH:i:sP'))); + $infPedReg->appendChild($doc->createElement('CNPJAutor', $this->cert->cnpj)); + $infPedReg->appendChild($doc->createElement('chNFSe', $chaveAcesso)); + + $e101101 = $doc->createElement('e101101'); + $e101101->appendChild($doc->createElement('xDesc', 'Cancelamento de NFS-e')); + $e101101->appendChild($doc->createElement('cMotivo', '1')); + $e101101->appendChild($doc->createElement('xMotivo', $motivo)); + $infPedReg->appendChild($e101101); + + return $doc->saveXML($doc->documentElement) ?: ''; + } + /** * @return array */ diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index a7aaa2f..f3437d9 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -136,7 +136,7 @@ public function testQueryReturnsReceiptDataOnSuccess(): void new Response($payload, ['Content-Type' => 'application/json'], 200) ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); $receipt = $client->query('xyz-456'); @@ -146,13 +146,34 @@ public function testQueryReturnsReceiptDataOnSuccess(): void public function testCancelReturnsTrueOnSuccess(): void { self::$server->setResponseOfPath( - '/SefinNacional/dps/abc-123', + '/SefinNacional/nfse/abc-123/eventos', new Response('{}', ['Content-Type' => 'application/json'], 200) ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); self::assertTrue($client->cancel('abc-123', 'Cancelamento a pedido do tomador')); + + $request = self::$server->getLastRequest(); + self::assertNotNull($request); + self::assertSame('POST', $request->getRequestMethod()); + self::assertSame('/SefinNacional/nfse/abc-123/eventos', $request->getRequestUri()); + + $payload = json_decode($request->getInput(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($payload); + self::assertArrayHasKey('pedidoRegistroEventoXmlGZipB64', $payload); + + $compressedXml = base64_decode((string) $payload['pedidoRegistroEventoXmlGZipB64'], true); + self::assertNotFalse($compressedXml); + + $eventoXml = gzdecode($compressedXml); + self::assertNotFalse($eventoXml); + self::assertStringContainsString('abc-123', $eventoXml); + self::assertStringContainsString('', $eventoXml); + self::assertStringContainsString('Cancelamento de NFS-e', $eventoXml); + self::assertStringContainsString('1', $eventoXml); + self::assertStringContainsString('Cancelamento a pedido do tomador', $eventoXml); } // ------------------------------------------------------------------------- @@ -202,7 +223,7 @@ public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); $this->expectException(QueryException::class); $client->query('missing-key'); @@ -215,7 +236,7 @@ public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); try { $client->query('missing-key'); @@ -229,11 +250,11 @@ public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError(): void { self::$server->setResponseOfPath( - '/SefinNacional/dps/blocked-key', + '/SefinNacional/nfse/blocked-key/eventos', new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); $this->expectException(CancellationException::class); $client->cancel('blocked-key', 'a pedido do tomador'); @@ -242,11 +263,11 @@ public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError(): public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void { self::$server->setResponseOfPath( - '/SefinNacional/dps/blocked-key', + '/SefinNacional/nfse/blocked-key/eventos', new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), ); - $client = $this->makeClient(); + $client = $this->makeClient($this->signer); try { $client->cancel('blocked-key', 'a pedido do tomador');