From c2993fe3980102a1e4dbfa98d826fe917151aa03 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:50:56 +0000 Subject: [PATCH] test: validate SEFIN event XML against official schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/sefin-events.md | 31 + .../schemas/nfse/1.01/pedRegEvento_v1.01.xsd | 15 + .../schemas/nfse/1.01/tiposEventos_v1.01.xsd | 780 ++++++++++++++++++ src/Xml/EventSchemaValidator.php | 95 +++ .../Xml/OfficialEventSchemaValidationTest.php | 87 ++ 5 files changed, 1008 insertions(+) create mode 100644 docs/sefin-events.md create mode 100644 references/schemas/nfse/1.01/pedRegEvento_v1.01.xsd create mode 100644 references/schemas/nfse/1.01/tiposEventos_v1.01.xsd create mode 100644 src/Xml/EventSchemaValidator.php create mode 100644 tests/Unit/Xml/OfficialEventSchemaValidationTest.php diff --git a/docs/sefin-events.md b/docs/sefin-events.md new file mode 100644 index 0000000..bb85a72 --- /dev/null +++ b/docs/sefin-events.md @@ -0,0 +1,31 @@ + + +# SEFIN event endpoint contract + +The contributor manual currently documents these event endpoints: + +| Operation | Endpoint | nfse-php status | +| --- | --- | --- | +| Register signed event | `POST /nfse/{chaveAcesso}/eventos` | Supported by `EventRegistrationInterface::registerEventXml()`; cancellation uses this primitive. | +| List all events | `GET /nfse/{chaveAcesso}/eventos` | Not enabled by default. | +| List events by type | `GET /nfse/{chaveAcesso}/eventos/{tipoEvento}` | Not enabled by default. | +| Query one event | `GET /nfse/{chaveAcesso}/eventos/{tipoEvento}/{numSeqEvento}` | Supported by `EventLookupInterface::queryEvent()`. | + +Source: Sistema Nacional NFS-e, current production contributor API manual. + +The production/restricted Swagger snapshot reviewed during implementation exposed the POST +operation and the fully-qualified GET query, but did not expose the base or type-only GET +operations. Because mutating or querying a fiscal API from an undocumented executable +contract is an interoperability risk, nfse-php intentionally does not guess those two routes. + +When an authoritative executable contract exposes them, they can be added without changing +the existing SEFIN/ADN separation. + +## Event XML + +Event registration uses the official `pedRegEvento_v1.01.xsd` contract and +`tiposEventos_v1.01.xsd`. The repository validates cancellation/event-registration XML +locally with `EventSchemaValidator`; schema validation never downloads external resources. diff --git a/references/schemas/nfse/1.01/pedRegEvento_v1.01.xsd b/references/schemas/nfse/1.01/pedRegEvento_v1.01.xsd new file mode 100644 index 0000000..d8d3db9 --- /dev/null +++ b/references/schemas/nfse/1.01/pedRegEvento_v1.01.xsd @@ -0,0 +1,15 @@ + + + + + + + Schema XML do Pedido de Registro de Eventos + + + \ No newline at end of file diff --git a/references/schemas/nfse/1.01/tiposEventos_v1.01.xsd b/references/schemas/nfse/1.01/tiposEventos_v1.01.xsd new file mode 100644 index 0000000..1a56868 --- /dev/null +++ b/references/schemas/nfse/1.01/tiposEventos_v1.01.xsd @@ -0,0 +1,780 @@ + + + + + + + + + + + + + + + + + + + Versão do aplicativo que gerou o evento + + + + + + Ambiente gerador do evento: + 1 - Sistema próprio do município; + 2 - Sefin Nacional NFS-e; + 3 - ADN NFS-e; + + + + + + + Número sequencial do evento para o mesmo tipo de evento. + Para os eventos que ocorrem somente uma vez, como é o caso do cancelamento, o nSeqEvento = 001. + Para os eventos que possam existir mais de um evento do mesmo tipo o ambiente gerador deverá numerar de forma sequencial. + + + + + + + Data/Hora do registro do evento. + Data e hora no formato UTC (Universal Coordinated Time): AAAA-MM-DDThh:mm:ssTZD + + + + + + Número sequencial do documento gerado por ambiente gerador de DFSe do município + + + + + Leiaute do pedido de registro do evento gerado pelo autor do evento + + + + + + + + + + + + + + + + + + + + + + Tipo de ambiente: + 1 - Produção; + 2 - Homologação; + + + + + + Versão do aplicativo que gerou o pedido de registro de evento + + + + + + Data e hora do evento no formato AAAA-MM-DDThh:mm:ssTZD (UTC - Universal Coordinated Time, onde TZD pode ser -02:00 (Fernando de Noronha), -03:00 (Brasília) ou -04:00 (Manaus), no horário de verão serão -01:00, -02:00 e -03:00. Ex.: 2010-08-19T13:00:15-03:00. + + + + + + Identificação do Autor do Pedido de Evento + + + + + Número de inscrição federal (CNPJ) do autor do evento. + CNPJ do autor do evento (parte interessada ou pessoa que figure na NFS-e. + O autor do evento não é o procurador) + + + + + + + Número de inscrição federal (CPF) do autor do evento. + CPF do autor do evento (parte interessada ou pessoa que figure na NFS-e como prestador, tomador, intermediário. + O autor do evento poderá ser o procurador) + + + + + + + Identificador da NFS-e à qual o evento será vinculado + + + + + + Evento de cancelamento + + + + + Evento de cancelamento por substituição + + + + + Solicitação de Análise Fiscal para Cancelamento de NFS-e + + + + + Cancelamento de NFS-e Deferido por Análise Fiscal + + + + + Cancelamento de NFS-e Indeferido por Análise Fiscal + + + + + + Confirmação do Prestador + + + + + Confirmação do Tomador + + + + + Confirmação do Intermediário + + + + + Confirmação Tácita + + + + + Rejeição do Prestador + + + + + Rejeição do Tomador + + + + + Rejeição do Intermediário + + + + + Anulação da Rejeição + + + + + + Cancelamento de NFS-e por Ofício + + + + + Bloqueio de NFS-e por Ofício + + + + + Desbloqueio de NFS-e por Ofício + + + + + + + + + + + + + + Descrição do Evento: Descrição do evento: "Cancelamento de NFS-e". + + + + + + + + + + + + + Código de justificativa de cancelamento: + 1 - Erro na Emissão; + 2 - Serviço não Prestado; + 9 - Outros; + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + Descrição do Evento: Descrição do evento: "Cancelamento de NFS-e por Substituição". + + + + + + + + + + + + + Código de justificativa de cancelamento substituição: + 01 - Desenquadramento de NFS-e do Simples Nacional; + 02 - Enquadramento de NFS-e no Simples Nacional; + 03 - Inclusão Retroativa de Imunidade/Isenção para NFS-e; + 04 - Exclusão Retroativa de Imunidade/Isenção para NFS-e; + 05 - Rejeição de NFS-e pelo tomador ou pelo intermediário se responsável pelo recolhimento do tributo; + 99 - Outros; + Obtido do campo da DPS "DPS/infDPS/subst/cMotivo" + + + + + + + Descrição para explicitar o motivo indicado neste evento. + Obtido do campo da DPS "DPS/infDPS/subst/xMotivo". + + + + + + Chave de Acesso da NFS-e substituta + + + + + + + + + + + + Descrição do evento: "Solicitação de Análise Fiscal para Cancelamento de NFS-e" + + + + + + + + + + + + + Código do motivo da solicitação de análise fiscal para cancelamento de NFS-e: + 1 - Erro na Emissão; + 2 - Serviço não Prestado; + 9 - Outros; + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Cancelamento de NFS-e Deferido por Análise Fiscal" + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o deferimento da solicitação de análise fiscal para cancelamento de NFS-e. + + + + + + + Número do processo administrativo municipal vinculado à solicitação de análise fiscal para cancelamento de NFS-e. + + + + + + + Resposta da solicitação de análise fiscal para cancelamento de NFS-e: + 1 - Cancelamento de NFS-e Deferido. + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Cancelamento de NFS-e Indeferido por Análise Fiscal". + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o indeferimento da solicitação de análise fiscal para cancelamento de NFS-e. + + + + + + + Número do processo administrativo municipal vinculado à solicitação de análise fiscal para cancelamento de NFS-e. + + + + + + + Resposta da solicitação de análise fiscal para cancelamento de NFS-e: + 1 - Cancelamento de NFS-e Indeferido; + 2 - Cancelamento de NFS-e Indeferido Sem Análise de Mérito. + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Confirmação do Prestador". + + + + + + + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Confirmação do Tomador". + + + + + + + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Confirmação do Intermediário". + + + + + + + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Confirmação Tácita". + + + + + + + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Rejeição do Prestador". + + + + + + + + + + + + + Motivo da Rejeição da NFS-e: + 1 - NFS-e em duplicidade; + 2 - NFS-e já emitida pelo tomador; + 3 - Não ocorrência do fato gerador; + 4 - Erro quanto a responsabilidade tributária; + 5 - Erro quanto ao valor do serviço, valor das deduções ou serviço prestado ou data do fato gerador; + 9 - Outros; + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Rejeição do Tomador". + + + + + + + + + + + + + Motivo da Rejeição da NFS-e: + 1 - NFS-e em duplicidade; + 2 - NFS-e já emitida pelo tomador; + 3 - Não ocorrência do fato gerador; + 4 - Erro quanto a responsabilidade tributária; + 5 - Erro quanto ao valor do serviço, valor das deduções ou serviço prestado ou data do fato gerador; + 9 - Outros; + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Rejeição do Intermediário". + + + + + + + + + + + + + Motivo da Rejeição da NFS-e: + 1 - NFS-e em duplicidade; + 2 - NFS-e já emitida pelo tomador; + 3 - Não ocorrência do fato gerador; + 4 - Erro quanto a responsabilidade tributária; + 5 - Erro quanto ao valor do serviço, valor das deduções ou serviço prestado ou data do fato gerador; + 9 - Outros; + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Manifestação de NFS-e - Anulação da Rejeição". + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o anulação da manifestação de rejeição da NFS-e + + + + + + + Referência ao "id" do Evento de Manifestação de NFS-e - Rejeição, que originou o presente evento de anulação + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Cancelamento de NFS-e por Ofício" + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o cancelamento por ofício de NFS-e + + + + + + + Número do processo administrativo municipal vinculado ao cancelamento de NFS-e por ofício + + + + + + + Descrição para explicitar o motivo do processo administrativo municipal indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Bloqueio de NFS-e por Ofício". + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o bloqueio de NFS-e por ofício + + + + + + + Eventos que podem ser escolhidos pelo município emissor para serem rejeitados após emissão e vinculação do evento de bloqueio por ofício em uma NFS-e: + e101101 - Cancelamento de NFS-e; + e105102 - Cancelamento de NFS-e por Substituição; + e105104 - Cancelamento de NFS-e Deferido por Análise Fiscal; + e105105 - Cancelamento de NFS-e Indeferido por Análise Fiscal; + e305101 - Cancelamento de NFS-e por Ofício; + + + + + + + Descrição para explicitar o motivo indicado neste evento + + + + + + + + + + + + + Descrição do evento: "Desbloqueio de NFS-e por Ofício". + + + + + + + + + + + + + CPF do agente da administração tributária municipal que efetuou o desbloqueio de NFS-e por ofício + + + + + + + Referência ao "id" do "Bloqueio de ofício" que originou o presente evento de desbloqueio + + + + + + \ No newline at end of file diff --git a/src/Xml/EventSchemaValidator.php b/src/Xml/EventSchemaValidator.php new file mode 100644 index 0000000..fe3c0f6 --- /dev/null +++ b/src/Xml/EventSchemaValidator.php @@ -0,0 +1,95 @@ + */ + public const SUPPORTED_SCHEMA_VERSIONS = [ + self::SCHEMA_VERSION, + ]; + + public function __construct( + private readonly ?string $schemaPath = null, + private readonly string $schemaVersion = self::SCHEMA_VERSION, + ) { + if (!in_array($this->schemaVersion, self::SUPPORTED_SCHEMA_VERSIONS, true)) { + throw new \InvalidArgumentException( + 'Unsupported NFS-e event schema version: ' . $this->schemaVersion + . '. Supported versions: ' . implode(', ', self::SUPPORTED_SCHEMA_VERSIONS), + ); + } + } + + /** + * @return list Validation errors. An empty list means valid XML. + */ + public function validate(string $xml): array + { + $previousUseErrors = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $document = new \DOMDocument(); + + if (!$document->loadXML($xml, LIBXML_NONET)) { + return $this->collectErrors(); + } + + if ($document->schemaValidate($this->resolvedSchemaPath())) { + return []; + } + + return $this->collectErrors(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previousUseErrors); + } + } + + public function isValid(string $xml): bool + { + return $this->validate($xml) === []; + } + + private function resolvedSchemaPath(): string + { + return $this->schemaPath + ?? dirname(__DIR__, 2) + . '/references/schemas/nfse/' + . $this->schemaVersion + . '/pedRegEvento_v' + . $this->schemaVersion + . '.xsd'; + } + + /** + * @return list + */ + private function collectErrors(): array + { + $errors = []; + + foreach (libxml_get_errors() as $error) { + $message = trim($error->message); + + if ($error->line > 0) { + $message .= ' (line ' . $error->line . ')'; + } + + $errors[] = $message; + } + + return $errors; + } +} diff --git a/tests/Unit/Xml/OfficialEventSchemaValidationTest.php b/tests/Unit/Xml/OfficialEventSchemaValidationTest.php new file mode 100644 index 0000000..d9cfd58 --- /dev/null +++ b/tests/Unit/Xml/OfficialEventSchemaValidationTest.php @@ -0,0 +1,87 @@ +validator = new EventSchemaValidator(); + } + + public function testCancellationRequestMatchesOfficialSchema(): void + { + self::assertSame([], $this->validator->validate($this->cancellationXml())); + } + + public function testValidatorRejectsUnsupportedSchemaVersionExplicitly(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported NFS-e event schema version: 9.99'); + + new EventSchemaValidator(schemaVersion: '9.99'); + } + + public function testValidatorRejectsUnknownEventElement(): void + { + $invalidXml = str_replace('e101101', 'e999999', $this->cancellationXml()); + + self::assertNotSame([], $this->validator->validate($invalidXml)); + } + + public function testValidatorRejectsWrongEventElementOrder(): void + { + $invalidXml = str_replace( + '1Erro de emissao confirmado pelo prestador', + 'Erro de emissao confirmado pelo prestador1', + $this->cancellationXml(), + ); + + self::assertNotSame([], $this->validator->validate($invalidXml)); + } + + public function testMalformedXmlDoesNotResolveExternalNetworkResources(): void + { + $xml = ''; + + self::assertNotSame([], $this->validator->validate($xml)); + } + + private function cancellationXml(): string + { + $accessKey = str_repeat('1', 50); + + return '' + . '' + . '' + . '2' + . 'nfse-php-test' + . '2026-10-04T12:00:00-03:00' + . '11222333000181' + . '' . $accessKey . '' + . '' + . 'Cancelamento de NFS-e' + . '1' + . 'Erro de emissao confirmado pelo prestador' + . '' + . '' + . ''; + } +}