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
19 changes: 19 additions & 0 deletions src/Contracts/CancellationClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Contracts;

/**
* Explicit cancellation contract for the official e101101 reason code and description.
*/
interface CancellationClientInterface
{
/**
* @param string $codigoMotivo Official TSCodJustCanc value: 1, 2 or 9.
*/
public function cancelWithReason(string $chaveAcesso, string $codigoMotivo, string $motivo): bool;
}
35 changes: 31 additions & 4 deletions 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\CancellationClientInterface;
use LibreCodeCoop\NfsePHP\Contracts\DecisionNfseIssuerInterface;
use LibreCodeCoop\NfsePHP\Contracts\DpsLookupInterface;
use LibreCodeCoop\NfsePHP\Contracts\EventLookupInterface;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, mixed> $response
*/
Expand Down
56 changes: 56 additions & 0 deletions tests/Unit/Http/NfseClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,62 @@ public function testCancelReturnsTrueOnSuccess(): void
self::assertStringContainsString('<xMotivo>Cancelamento a pedido do tomador</xMotivo>', $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('<cMotivo>9</cMotivo>', $eventoXml);
self::assertStringContainsString('<xMotivo>Outro motivo válido</xMotivo>', $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<string, array{string, string}>
*/
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([
Expand Down
29 changes: 29 additions & 0 deletions tests/Unit/Xml/OfficialEventSchemaValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{int, bool}>
*/
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(
Expand Down
Loading