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
51 changes: 47 additions & 4 deletions src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -146,12 +158,15 @@ private function get(string $path): array
/**
* @return array{int, array<string, mixed>}
*/
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,
Expand All @@ -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<string, bool|string>
*/
Expand Down
39 changes: 30 additions & 9 deletions tests/Unit/Http/NfseClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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('<pedRegEvento', $eventoXml);
self::assertStringContainsString('<chNFSe>abc-123</chNFSe>', $eventoXml);
self::assertStringContainsString('<e101101>', $eventoXml);
self::assertStringContainsString('<xDesc>Cancelamento de NFS-e</xDesc>', $eventoXml);
self::assertStringContainsString('<cMotivo>1</cMotivo>', $eventoXml);
self::assertStringContainsString('<xMotivo>Cancelamento a pedido do tomador</xMotivo>', $eventoXml);
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
Loading