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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp

## [Unreleased]

### Corrigido

- **Domínio de documentos de entrada inteiro** (`fix-inbound-routes`): os 20 métodos
de `InboundProductInvoicesResource` (13) e `TransportationInvoicesResource` (7)
usavam rotas (`/productinvoices/received/…`, `/productinvoices/inbound`, `/cte/…`)
que **nunca existiram na API** — desde a v3.0, todo o domínio retornava 404 de rota
(ou colidia com `/productinvoices/{id}`). Sondado ao vivo em 2026-07-29/30. Todas as
rotas migraram para o contrato real `/inbound/…` de
`openapi/consulta-dfe-distribuicao-v2.yaml`, **mantendo nomes e assinaturas dos 20
métodos** (pinado por teste de reflexão):
- Configuração: `POST|GET|DELETE /v2/companies/{id}/inbound/productinvoices`
(NF-e) e `…/inbound/transportationinvoices` (CT-e) — habilitar agora é `POST`
(era `PUT`).
- Consultas por chave: rotas genéricas `…/inbound/{accessKey}[/xml|/pdf|/events/…]`
e específicas `…/inbound/productinvoices/{accessKey}[/json|/events/…]`.
- `manifest()`: `POST …/inbound/{accessKey}/manifest?tpEvent={código}` — a API só
aceita o código numérico SEFAZ (sondado); o SDK aceita o código direto ou os
literais legados (`Confirmation`→210200, `Acknowledgement`→210210,
`Unknown`→210220, `Refused`→210240).
- `reprocessWebhook()`: `POST …/inbound/productinvoices/{key_or_nsu}/processwebhook`
— aceita chave de 44 dígitos **ou** NSU (1–15 dígitos).

### Adicionado

- `IdValidator::accessKeyOrNsu()` — normaliza chave de acesso (44 dígitos) ou NSU
(1–15 dígitos) para o reprocessamento de webhook.
- Teste de alinhamento `InboundSpecAlignmentTest`: amarra as 20 rotas à spec DF-e v2
(verbo+path), pina a ausência dos esquemas de rota mortos e o tipo `integer` de
`tpEvent`; unit tests com verbo+path pinados por método. Rotas validadas ao vivo
com o SDK corrigido em 2026-07-30 (erros de domínio — settings/chave falsa/NSU —
nunca 404 de rota).

## [3.3.1] — 2026-07-30

### Corrigido
Expand Down
35 changes: 28 additions & 7 deletions docs/recursos/inbound-product-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ dados — sempre usa a chave principal.
| `getEventXml($companyId, $accessKey, $eventKey)` | XML de um evento. | `string` |
| `getPdf($companyId, $accessKey)` | DANFE em PDF. | `string` |
| `getJson($companyId, $accessKey)` | Representação JSON do documento. | `array` |
| `manifest($companyId, $accessKey, $manifestType, $data = [])` | Manifestação do destinatário. | `array` |
| `reprocessWebhook($companyId, $accessKey)` | Reenvia o webhook do documento. | `array` |
| `manifest($companyId, $accessKey, $manifestType, $data = [])` | Manifestação do destinatário (`$manifestType` = código SEFAZ ou literal, ver abaixo). | `array` |
| `reprocessWebhook($companyId, $accessKey)` | Reenvia o webhook do documento; aceita a chave de 44 dígitos **ou** o NSU (1–15 dígitos). | `array` |

:::warning Corrigido na v3.4.0
Até a v3.3.x este recurso apontava para rotas que **não existem** na API — todos
os métodos retornavam 404/erro. A v3.4.0 migrou tudo para o contrato real
`/inbound/…` (`consulta-dfe-distribuicao-v2`), mantendo nomes e assinaturas.
:::

## Ativar a busca automática

Expand All @@ -61,22 +67,37 @@ file_put_contents('entrada.pdf', $nfe->inboundProductInvoices->getPdf($companyId

## Manifestação do destinatário

A API espera o **código numérico SEFAZ** do evento (query `tpEvent`). O SDK
aceita o código direto ou os literais equivalentes:

| Código | Literal aceito | Evento |
|---|---|---|
| `210200` | `Confirmation` | Confirmação da Operação |
| `210210` | `Acknowledgement` | Ciência da Operação |
| `210220` | `Unknown` | Desconhecimento da Operação |
| `210240` | `Refused` | Operação não Realizada |

```php
// Ex.: confirmar a operação
$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Confirmation');
// Ex.: ciência da operação (código direto)
$nfe->inboundProductInvoices->manifest($companyId, $accessKey, '210210');

// Ex.: desconhecer a operação, com justificativa no corpo
$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Ignorance', [
'reason' => 'Operação não reconhecida',
$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Unknown', [
'justification' => 'Operação não reconhecida',
]);
```

Qualquer outro valor lança `Nfe\Exception\InvalidRequestException` antes de
qualquer HTTP.

## Reprocessar o webhook

Se a sua aplicação perdeu uma entrega, peça o reenvio do evento do documento:
Se a sua aplicação perdeu uma entrega, peça o reenvio do evento do documento —
pela chave de acesso ou pelo NSU:

```php
$nfe->inboundProductInvoices->reprocessWebhook($companyId, $accessKey);
$nfe->inboundProductInvoices->reprocessWebhook($companyId, '123456789'); // NSU
```

## Próximos passos
Expand Down
7 changes: 7 additions & 0 deletions docs/recursos/transportation-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ principal (não é família de dados).
| `getEvent($companyId, $accessKey, $eventKey)` | Consulta um evento do CT-e. | `array` |
| `downloadEventXml($companyId, $accessKey, $eventKey)` | XML de um evento. | `string` |

:::warning Corrigido na v3.4.0
Até a v3.3.x este recurso apontava para rotas `/cte/…` que **não existem** na
API — todos os métodos retornavam 404/erro. A v3.4.0 migrou tudo para o
contrato real `/inbound/…` (`consulta-dfe-distribuicao-v2`), mantendo nomes e
assinaturas.
:::

## Habilitar o recebimento

```php
Expand Down
4 changes: 4 additions & 0 deletions skills/nfeio-php-sdk/references/product-invoices-and-taxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ downloadEventXml(string $companyId, string $accessKey, string $eventKey, ?Reques
```

- **Does not use `dataApiKey`** — always the main `apiKey`.
- Routes live under `/v2/companies/{id}/inbound/transportationinvoices` (config) and the generic `/inbound/{accessKey}…` (queries); `enable` is a POST. Fixed in v3.4.0 — before that, the resource hit dead `/cte/…` routes and every method 404'd.

## `$nfe->inboundProductInvoices` (Inbound NF-e / DFe distribution — Company-scoped)

Expand All @@ -115,3 +116,6 @@ reprocessWebhook(string $companyId, string $accessKey, ?RequestOptions $options
```

- `getXml`/`getEventXml`/`getPdf` return raw byte `string`. **Does not use `dataApiKey`** — always the main `apiKey`.
- Routes live under `/v2/companies/{id}/inbound/…` (fixed in v3.4.0; the old `/productinvoices/received/…` and `/productinvoices/inbound` routes never existed on the API — every method 404'd until then). `enableAutoFetch` is a POST.
- `manifest()` sends the SEFAZ numeric event code as query `tpEvent` (probed: the API rejects literals). Pass the code (`'210210'`) or a legacy literal, mapped as `Confirmation`→210200, `Acknowledgement`→210210, `Unknown`→210220, `Refused`→210240; anything else throws `InvalidRequestException` locally.
- `reprocessWebhook()` accepts a 44-digit access key **or** a 1–15 digit NSU (`IdValidator::accessKeyOrNsu`), hitting `POST …/inbound/productinvoices/{key_or_nsu}/processwebhook`.
56 changes: 37 additions & 19 deletions src/Resource/InboundProductInvoicesResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,23 @@ public function enableAutoFetch(
?RequestOptions $options = null,
): InboundSettings {
$companyId = IdValidator::companyId($companyId);
$response = $this->httpPut("/companies/{$companyId}/productinvoices/inbound", $data, $options);
$response = $this->httpPost("/companies/{$companyId}/inbound/productinvoices", $data, $options);

return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body));
}

public function disableAutoFetch(string $companyId, ?RequestOptions $options = null): InboundSettings
{
$companyId = IdValidator::companyId($companyId);
$response = $this->httpDelete("/companies/{$companyId}/productinvoices/inbound", $options);
$response = $this->httpDelete("/companies/{$companyId}/inbound/productinvoices", $options);

return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body));
}

public function getSettings(string $companyId, ?RequestOptions $options = null): InboundSettings
{
$companyId = IdValidator::companyId($companyId);
$response = $this->httpGet("/companies/{$companyId}/productinvoices/inbound", options: $options);
$response = $this->httpGet("/companies/{$companyId}/inbound/productinvoices", options: $options);

return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body));
}
Expand All @@ -66,7 +66,7 @@ public function getDetails(string $companyId, string $accessKey, ?RequestOptions
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
$response = $this->httpGet(
"/companies/{$companyId}/productinvoices/received/{$accessKey}",
"/companies/{$companyId}/inbound/{$accessKey}",
options: $options,
);

Expand All @@ -84,7 +84,7 @@ public function getProductInvoiceDetails(
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
$response = $this->httpGet(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/productinvoice",
"/companies/{$companyId}/inbound/productinvoices/{$accessKey}",
options: $options,
);

Expand All @@ -104,7 +104,7 @@ public function getEventDetails(
$accessKey = IdValidator::accessKey($accessKey);
$eventKey = IdValidator::eventKey($eventKey);
$response = $this->httpGet(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}",
"/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}",
options: $options,
);

Expand All @@ -124,7 +124,7 @@ public function getProductInvoiceEventDetails(
$accessKey = IdValidator::accessKey($accessKey);
$eventKey = IdValidator::eventKey($eventKey);
$response = $this->httpGet(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}/productinvoice",
"/companies/{$companyId}/inbound/productinvoices/{$accessKey}/events/{$eventKey}",
options: $options,
);

Expand All @@ -137,7 +137,7 @@ public function getXml(string $companyId, string $accessKey, ?RequestOptions $op
$accessKey = IdValidator::accessKey($accessKey);

return $this->download(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/xml",
"/companies/{$companyId}/inbound/{$accessKey}/xml",
options: $options,
);
}
Expand All @@ -153,7 +153,7 @@ public function getEventXml(
$eventKey = IdValidator::eventKey($eventKey);

return $this->download(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}/xml",
"/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}/xml",
options: $options,
);
}
Expand All @@ -164,7 +164,7 @@ public function getPdf(string $companyId, string $accessKey, ?RequestOptions $op
$accessKey = IdValidator::accessKey($accessKey);

return $this->download(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/pdf",
"/companies/{$companyId}/inbound/{$accessKey}/pdf",
options: $options,
);
}
Expand All @@ -177,15 +177,21 @@ public function getJson(string $companyId, string $accessKey, ?RequestOptions $o
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
$response = $this->httpGet(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/json",
"/companies/{$companyId}/inbound/productinvoices/{$accessKey}/json",
options: $options,
);

return $this->decodeBody($response->body);
}

/**
* Manifestar o destinatário (ciência, confirmação, desconhecimento, refutação).
* Manifestar o destinatário (ciência, confirmação, desconhecimento, operação não realizada).
*
* A API espera o código numérico SEFAZ no query param `tpEvent` (sondado
* 2026-07-30: literais são rejeitados pelo binder). Aceita o código direto
* (`'210210'`) ou os literais legados deste SDK, mapeados assim:
* `Confirmation`→210200, `Acknowledgement`→210210, `Unknown`→210220,
* `Refused`→210240.
*
* @param array<string, mixed> $data Pode conter justification, etc. dependendo do tipo.
* @return array<string, mixed>
Expand All @@ -199,11 +205,14 @@ public function manifest(
): array {
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
if (trim($manifestType) === '') {
throw new \Nfe\Exception\InvalidRequestException('manifestType é obrigatório (Confirmation/Acknowledgement/Unknown/Refused).');
$tpEvent = self::MANIFEST_EVENT_CODES[trim($manifestType)] ?? trim($manifestType);
if (preg_match('/^\d{6}$/', $tpEvent) !== 1) {
throw new \Nfe\Exception\InvalidRequestException(
'manifestType inválido: use o código SEFAZ de 6 dígitos (210200/210210/210220/210240) ou Confirmation/Acknowledgement/Unknown/Refused.',
);
}
$response = $this->httpPut(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/manifest/{$manifestType}",
$response = $this->httpPost(
"/companies/{$companyId}/inbound/{$accessKey}/manifest?" . http_build_query(['tpEvent' => $tpEvent]),
$data,
$options,
);
Expand All @@ -212,7 +221,8 @@ public function manifest(
}

/**
* Reenvia o webhook para uma NF-e recebida.
* Reenvia o webhook para uma NF-e recebida, identificada pela chave de
* acesso (44 dígitos) OU pelo NSU (1–15 dígitos).
*
* @return array<string, mixed>
*/
Expand All @@ -222,12 +232,20 @@ public function reprocessWebhook(
?RequestOptions $options = null,
): array {
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
$accessKey = IdValidator::accessKeyOrNsu($accessKey);
$response = $this->httpPost(
"/companies/{$companyId}/productinvoices/received/{$accessKey}/webhook/reprocess",
"/companies/{$companyId}/inbound/productinvoices/{$accessKey}/processwebhook",
options: $options,
);

return $this->decodeBody($response->body);
}

/** Literais legados do SDK → código numérico SEFAZ do evento de manifestação. */
private const MANIFEST_EVENT_CODES = [
'Confirmation' => '210200',
'Acknowledgement' => '210210',
'Unknown' => '210220',
'Refused' => '210240',
];
}
14 changes: 7 additions & 7 deletions src/Resource/TransportationInvoicesResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function enable(
?RequestOptions $options = null,
): InboundSettings {
$companyId = IdValidator::companyId($companyId);
$response = $this->httpPut("/companies/{$companyId}/cte/inbound", $data, $options);
$response = $this->httpPost("/companies/{$companyId}/inbound/transportationinvoices", $data, $options);
$payload = $this->decodeBody($response->body);

return $this->hydrate(InboundSettings::class, $payload);
Expand All @@ -50,7 +50,7 @@ public function enable(
public function disable(string $companyId, ?RequestOptions $options = null): InboundSettings
{
$companyId = IdValidator::companyId($companyId);
$response = $this->httpDelete("/companies/{$companyId}/cte/inbound", $options);
$response = $this->httpDelete("/companies/{$companyId}/inbound/transportationinvoices", $options);
$payload = $this->decodeBody($response->body);

return $this->hydrate(InboundSettings::class, $payload);
Expand All @@ -59,7 +59,7 @@ public function disable(string $companyId, ?RequestOptions $options = null): Inb
public function getSettings(string $companyId, ?RequestOptions $options = null): InboundSettings
{
$companyId = IdValidator::companyId($companyId);
$response = $this->httpGet("/companies/{$companyId}/cte/inbound", options: $options);
$response = $this->httpGet("/companies/{$companyId}/inbound/transportationinvoices", options: $options);
$payload = $this->decodeBody($response->body);

return $this->hydrate(InboundSettings::class, $payload);
Expand All @@ -77,7 +77,7 @@ public function retrieve(
): array {
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);
$response = $this->httpGet("/companies/{$companyId}/cte/{$accessKey}", options: $options);
$response = $this->httpGet("/companies/{$companyId}/inbound/{$accessKey}", options: $options);

return $this->decodeBody($response->body);
}
Expand All @@ -90,7 +90,7 @@ public function downloadXml(
$companyId = IdValidator::companyId($companyId);
$accessKey = IdValidator::accessKey($accessKey);

return $this->download("/companies/{$companyId}/cte/{$accessKey}/xml", options: $options);
return $this->download("/companies/{$companyId}/inbound/{$accessKey}/xml", options: $options);
}

/**
Expand All @@ -106,7 +106,7 @@ public function getEvent(
$accessKey = IdValidator::accessKey($accessKey);
$eventKey = IdValidator::eventKey($eventKey);
$response = $this->httpGet(
"/companies/{$companyId}/cte/{$accessKey}/events/{$eventKey}",
"/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}",
options: $options,
);

Expand All @@ -124,7 +124,7 @@ public function downloadEventXml(
$eventKey = IdValidator::eventKey($eventKey);

return $this->download(
"/companies/{$companyId}/cte/{$accessKey}/events/{$eventKey}/xml",
"/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}/xml",
options: $options,
);
}
Expand Down
23 changes: 23 additions & 0 deletions src/Util/IdValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ public static function accessKey(string $value): string
return $digits;
}

/**
* Normalise a 44-digit access key OR an NSU (1–15 digits).
*
* O endpoint de reprocessamento de webhook aceita `{access_key_or_nsu}`
* (spec consulta-dfe-distribuicao-v2): 44 dígitos são tratados como chave
* de acesso; qualquer outra contagem de 1 a 15 dígitos é aceita como NSU.
*/
public static function accessKeyOrNsu(string $value): string
{
$digits = preg_replace('/\D+/', '', $value) ?? '';
$len = strlen($digits);
if ($len === 44 || ($len >= 1 && $len <= 15)) {
return $digits;
}
throw new InvalidRequestException(
sprintf(
'Chave de acesso ou NSU inválido: esperado 44 dígitos (chave) ou 1–15 dígitos (NSU), recebido %d (input: "%s").',
$len,
$value,
),
);
}

/**
* Normalise a CNPJ (14 digits). Strips punctuation; does not validate check digits.
*/
Expand Down
Loading