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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(inbound): migra os 20 métodos de inbound/CT-e para as rotas reais /inbound/… (v3.4.0) by andrenfe · Pull Request #30 · nfe/client-php · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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