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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file.

### Fixed

- **`consultar()->eventos()` exigia um campo que a rota não devolve — nenhuma consulta de evento existente podia ter sucesso.** O método pedia `eventoXmlGZipB64` no topo da resposta, que é o que `EventosPostResponseSucesso` (SefinNacional-swagger.json) declara e o que o próprio swagger aponta como resposta 200 do `GET /nfse/{chave}/eventos/{tipoEvento}/{nSequencial}`. O `SefinNacional_1.6.0` devolve outra coisa: uma lista `eventos[]`, com o XML em `eventos[].arquivoXml` e uma segunda camada de base64 sobre o gzip. Só o caminho da ausência funcionava — evento inexistente responde 404 e vira `EVENT_NOT_FOUND`; evento presente responde 200, não trazia o campo exigido e virava `IndeterminateResultException`. Quanto mais bem-sucedida a operação no SEFIN, pior a leitura do SDK: a reconciliação de um cancelamento registrado concluía "não foi possível confirmar", e uma nota cancelada por análise fiscal seguia autorizada no banco do consumidor. A suíte não via nada porque os fixtures foram construídos a partir do schema do swagger, não de tráfego real. As duas formas passam a ser aceitas — `eventos[]` primeiro, `eventoXmlGZipB64` de topo depois —, porque `storage/prefeituras.json` tem prefeituras com implementação própria que podem cumprir o contrato declarado. `EventsResponse` ganha `eventos`, uma lista de `EventoConsultado` com `chaveAcesso`, `tipoEvento`, `numeroPedidoRegistroEvento`, `dataHoraRecebimento`, `xml` e `parseError`; `xml` continua sendo o XML do primeiro item. Um evento ilegível não derruba a lista: os campos afetados vêm `null` e `parseError` diz o que faltou, como o `DocumentoFiscal` já fazia no lote de distribuição.

- **`IndeterminateResultException` descartava a resposta que a motivou — a recusa era correta e indiagnosticável.** O `statusCode` chegava às fábricas, entrava na mensagem formatada e não virava dado; o corpo se perdia por completo. Do lado do consumidor a exceção rendia duas linhas de log sem nada sobre o que a SEFIN mandou, e diagnosticar o defeito acima exigiu escrever um script, subir no servidor de produção e refazer a chamada com o certificado — por fora do SDK. A exceção passa a carregar `statusCode`, `body` (truncado em 8 KiB) e `raw` (o JSON decodificado, sem truncar), preenchidos com o que cada ponto de falha tem em escopo. `NfseFailed` ganha `throwable`, para que o listener alcance a mesma evidência sem depender do `catch` do chamador — antes ele recebia `$throwable->getMessage()`, a mesma string da exceção.

### Changed

- **`GzipCompressor::decompressB64()` descasca uma segunda camada de base64 quando ela existe.** Condicionado ao magic gzip e limitado a duas camadas: se o primeiro decode não revelar `1f 8b`, tenta mais um e fica com o resultado apenas se ele for gzip. É o formato de `eventos[].arquivoXml` no `SefinNacional_1.6.0`, e cobre o `ArquivoXml` da distribuição caso o ADN faça o mesmo — hipótese ainda não medida.

- **`ExecutesNfseRequests::executeRaw()` aceita campos obrigatórios alternativos**, declarados pela forma que a operação lê: `$requiredStrings` (texto não-vazio) e `$requiredLists` (lista não-vazia). Campo presente é o que veio **na forma exigida** — um `chaveAcesso` que chegue como lista continua sendo resposta fora do contrato, e não um campo presente que estouraria `TypeError` no construtor tipado logo adiante. Porta interna, marcada `@internal`.

- **Rejeição cujo `erro` singular vem como lista chegava ao consumidor sem código nem mensagem.** `ResponseErro` (SefinNacional-swagger.json) declara `erro` como uma `MensagemProcessamento`, mas a SEFIN também o envia como lista de mensagens — confirmado em produção com `SefinNacional_1.6.0`, numa rejeição `E0822` ("O prazo para o cancelamento da NFS-e expirou") de cancelamento. `extractErrors()` embrulhava o valor sem distinguir as duas formas, então a lista virava uma mensagem cuja única chave é o inteiro `0`; `fromArray()` procurava `codigo`/`mensagem`/`descricao`, não achava nada, e o evento `NfseRejected` saía com `UNKNOWN` e `null`. A classificação nunca esteve errada — `hasApiError()` devolvia `true`, a operação foi corretamente tratada como rejeitada —, o que se perdia era justamente o conteúdo que decide o próximo passo do consumidor. Mapa e lista passam pelo mesmo filtro de `onlyMessages()`, então as tolerâncias seguem valendo: `{"erro": "Bad Gateway"}` de proxy continua fora da classificação, e item não-mensagem no meio da lista continua sendo descartado sem levar os erros reais junto.

- **`{"erros":[{}]}` classificava como rejeição sem conteúdo, enquanto `{"erro":{}}` não.** A assimetria vinha de `extractErrors()`: o singular descartava mensagem vazia, o plural só filtrava o que não fosse array. O consumidor recebia uma rejeição definitiva sem código nem texto — indistinguível, para ele, do defeito acima. Item vazio agora sai nos dois caminhos; sem mensagem alguma o status HTTP volta a ser a informação definitiva, e ele chega por `IndeterminateResultException` (POST 5xx) ou `HttpException`, ambas com o corpo íntegro.
Expand Down
62 changes: 53 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,23 @@ $response = $client->consultar()->eventos(
// BloqueioPorOficio — 305102 DesbloqueioPorOficio — 305103
// InclusaoNfseDan — 467201 TributosNfseRecolhidos — 907201
//
// No sucesso, $response->eventos traz um EventoConsultado por item devolvido e
// $response->xml repete o XML do primeiro:
foreach ($response->eventos as $evento) {
$evento->tipoEvento; // TipoEvento|null
$evento->numeroPedidoRegistroEvento; // int|null
$evento->dataHoraRecebimento; // string|null
$evento->xml; // string|null, já descomprimido
$evento->parseError; // string|null — por que o item não pôde ser lido
}
//
// Quando a SEFIN responde 404 (evento inexistente), a resposta traz um erro
// dedicado: $response->erros[0]->codigo === EventsResponse::EVENT_NOT_FOUND.
// Sinal inequívoco de "não existe" — qualquer outro sucesso: false é
// inconclusivo. Um 2xx sem `eventoXmlGZipB64` não ocorre em operação normal e
// lança IndeterminateResultException (nunca vira sucesso com xml: null).
// inconclusivo. Um 2xx que não traga nem a lista `eventos` nem o
// `eventoXmlGZipB64` de topo lança IndeterminateResultException (nunca vira
// sucesso silencioso). Já um evento cujo XML não pôde ser lido mantém
// sucesso: true, com xml: null e o motivo em $evento->parseError.

// Situação do cancelamento por análise fiscal (uma chamada ao ADN, resume os
// eventos 101103/105104/105105 da nota)
Expand Down Expand Up @@ -507,7 +519,7 @@ O pacote dispara eventos Laravel que podem ser escutados na sua aplicação:
| `NfseQueried` | `operacao` | Consulta realizada |
| `NfseRequested` | `operacao`, `metadata` | Operação iniciada |
| `NfseRejected` | `operacao`, `codigoErro`, `mensagemErro`, `correcao` | Operação rejeitada pela API (`mensagemErro` e `correcao` ficam `null` quando a API não retorna os campos correspondentes ou no fallback `SEM_CHAVE`) |
| `NfseFailed` | `operacao`, `mensagem` | Falha na operação |
| `NfseFailed` | `operacao`, `mensagem`, `throwable` | Falha na operação |

**Substituição:** como `substituir` delega ao `emitir` internamente, a sequência de eventos disparados é:
`NfseRequested('emitir')` → `NfseEmitted` → `NfseSubstituted`
Expand Down Expand Up @@ -562,17 +574,34 @@ Retornado por `consultar()->eventos()`.
| Propriedade | Tipo | Descricao |
|-------------|------|-----------|
| `sucesso` | `bool` | Se a consulta teve sucesso |
| `xml` | `?string` | XML do evento (nunca `null` quando `sucesso` é `true`) |
| `xml` | `?string` | XML do primeiro evento de `eventos`, já descomprimido |
| `erros` | `list<ProcessingMessage>` | Erros de processamento |
| `tipoAmbiente` | `?int` | 1 = Produção, 2 = Homologação |
| `versaoAplicativo` | `?string` | Versão do aplicativo da SEFIN |
| `dataHoraProcessamento` | `?string` | Data/hora do processamento |
| `raw` | `?array` | Corpo JSON decodificado, como a SEFIN o devolveu — ver [Corpo bruto](#corpo-bruto) |
| `eventos` | `list<EventoConsultado>` | Os eventos devolvidos para o par `(tipoEvento, nSequencial)` |

Constante `EventsResponse::EVENT_NOT_FOUND`: presente em `erros[0]->codigo`
quando a SEFIN responde 404 — o evento comprovadamente não existe (distinto de
erro transitório, que permanece `sucesso: false` sem esse código).

### `EventoConsultado`

Um item de `EventsResponse::$eventos`.

| Propriedade | Tipo | Descricao |
|-------------|------|-----------|
| `chaveAcesso` | `?string` | Chave de acesso da NFS-e a que o evento se vincula |
| `tipoEvento` | `?TipoEvento` | `null` quando o código não consta no enum |
| `numeroPedidoRegistroEvento` | `?int` | Sequencial do pedido de registro |
| `dataHoraRecebimento` | `?string` | Quando a SEFIN recebeu o evento |
| `xml` | `?string` | XML do evento, já descomprimido |
| `parseError` | `?string` | Por que o evento não pôde ser interpretado por completo; `null` quando íntegro |

Um item que não pôde ser lido por completo não interrompe a resposta: os campos
afetados vêm `null` e `parseError` diz o que faltou.

### `DistribuicaoResponse`

Retornado por `distribuicao()->documentos()`, `distribuicao()->documento()` e `distribuicao()->eventos()`.
Expand Down Expand Up @@ -692,11 +721,11 @@ cobre cinco situações:
confirmou o processamento, mas o resultado não pôde ser interpretado;
4. **Resposta com JSON válido porém sem o campo obrigatório da operação** —
um 2xx de `consultar()->nfse()` sem `nfseXmlGZipB64`, de
`consultar()->eventos()` sem `eventoXmlGZipB64` ou de `consultar()->dps()`
sem `chaveAcesso`, ou a resposta ao POST do evento em `cancelar()` sem
rejeição estruturada nem o recibo `eventoXmlGZipB64`, qualquer que seja o
status — shape que não ocorre em operação normal; ausência comprovada é
sinalizada por HTTP 404, nunca por corpo vazio;
`consultar()->eventos()` sem `eventos` nem `eventoXmlGZipB64`, ou de
`consultar()->dps()` sem `chaveAcesso`, ou a resposta ao POST do evento em
`cancelar()` sem rejeição estruturada nem o recibo `eventoXmlGZipB64`,
qualquer que seja o status — shape que não ocorre em operação normal;
ausência comprovada é sinalizada por HTTP 404, nunca por corpo vazio;
5. **Resposta 5xx a uma operação que altera estado** (`emitir`,
`cancelar`, `substituir`) **sem rejeição estruturada
da SEFIN no corpo** — o erro pode ter vindo de um proxy antes da SEFIN, ou da
Expand All @@ -709,6 +738,21 @@ cobre cinco situações:
> de JSON ali é a resposta correta e não estado indeterminado — `distribuicao()`
> devolve `sucesso: false` com o código `EMPTY_RESPONSE`.

**A exceção carrega a resposta que a motivou**, para que o diagnóstico saia do log
em vez de exigir reproduzir a chamada:

| Propriedade | Tipo | Descricao |
|-------------|------|-----------|
| `phase` | `?string` | Fase da falha de transporte (`dns`, `connect`, `tls`, `transfer`, `read`, `body`), quando detectável |
| `statusCode` | `?int` | Status HTTP, quando a resposta chegou |
| `body` | `?string` | Corpo cru, truncado em 8 KiB |
| `raw` | `?array` | Corpo JSON decodificado, sem truncar |

Preenche-se o que o ponto da falha tinha em mãos: `body`/`statusCode` ficam `null`
nas rotas em que o SDK só recebe o JSON já decodificado, e `raw` fica `null` quando
não houve JSON legível (é justamente a causa) ou quando a falha antecedeu a
resposta. O mesmo objeto chega ao listener em `NfseFailed::$throwable`.

Nos cinco casos a ação é a mesma: **nunca faça retry cego de emissão** (a NFS-e
pode já existir e um retry causaria dupla emissão). Calcule o ID com
`DpsId::generate()` e consulte `consultar()->dps($id)`: se encontrou, a nota
Expand Down
Loading
Loading