diff --git a/CHANGELOG.md b/CHANGELOG.md index 782d711..990ec1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +## [3.1.0] — 2026-07-03 + +### Adicionado + +- Métodos account-scoped em `WebhooksResource`, alinhados ao contrato real da + API (`/v2/webhooks`, confirmado por sondas ao vivo em 3 contas, + 2026-07-02/03): `listAccountWebhooks()`, `createAccountWebhook()`, + `retrieveAccountWebhook()`, `updateAccountWebhook()`, + `deleteAccountWebhook()`, `deleteAllAccountWebhooks()` (destrutivo — remove + TODOS os webhooks da conta), `pingAccountWebhook()` e `fetchEventTypes()` + (lista viva de event types). Create/update enviam o envelope obrigatório + `{"webHook": {...}}` e as respostas envelopadas são desembrulhadas + (`fix-account-webhooks-contract`). +- Novo DTO `Nfe\Resource\Dto\Webhooks\AccountWebhook` com o shape real do + recurso: `id`, `uri`, `contentType`, `secret` (32–64 chars; ecoado no + create, omitido nas leituras), `filters`, `insecureSsl`, `headers`, + `properties`, `status`, `createdOn`, `modifiedOn`, `raw`. Nota de contrato: + o spec OpenAPI declara `contentType`/`status` como enum int, mas a API + serializa strings (`"json"`, `"Active"`) — o DTO segue o fio, e um teste de + alinhamento YAML↔DTO pina o desvio. + +### Depreciado + +- Os métodos company-scoped de `WebhooksResource` (`list`, `create`, + `retrieve`, `update`, `delete`, `test`): a rota + `/v1/companies/{id}/webhooks` retorna **404 incondicional** na API atual + (o contrato havia sido herdado de alucinação do SDK Node). Comportamento + inalterado; use os equivalentes `*AccountWebhook*`. Remoção na próxima + major. +- `WebhooksResource::getAvailableEvents()`: os literais `invoice.*` não + existem na API — os ids reais seguem `service_invoice.*` / + `product_invoice.*` / `consumer_invoice.*`. Use `fetchEventTypes()`. +- DTO `Nfe\Resource\Dto\Webhooks\Webhook` (`url`/`events`): shape rejeitado + pela API (`400 "The Uri field is required"`). Use `AccountWebhook`. + +### Documentação + +- Gotchas do contrato real documentados no phpdoc, README e + `docs/(recursos/)webhooks.md`: a NFE.io **pinga a `uri` na criação** e + exige resposta 2xx; `PUT` de update é **substituição integral** (update sem + `status` desativa o webhook — monte o corpo a partir do retrieve). + ## [3.0.0] — 2026-07-01 ### Removido diff --git a/README.md b/README.md index 21e5b90..f5e08b3 100755 --- a/README.md +++ b/README.md @@ -550,23 +550,41 @@ foreach ($operacoes->items as $cod) { ### Configurar um webhook +Webhooks são registrados por **conta** (`/v2/webhooks`) — todas as empresas da +conta disparam para os mesmos alvos. Na criação, a NFE.io **pinga a URL** e +exige resposta 2xx. + ```php -$webhook = $nfe->webhooks->create($companyId, [ - 'url' => 'https://meuapp.com.br/api/webhooks/nfe', - 'events' => ['invoice.issued', 'invoice.cancelled', 'invoice.error'], - 'active' => true, +$webhook = $nfe->webhooks->createAccountWebhook([ + 'uri' => 'https://meuapp.com.br/api/webhooks/nfe', + 'contentType' => 'json', + 'secret' => $_ENV['NFE_WEBHOOK_SECRET'], // 32–64 caracteres + 'filters' => ['service_invoice.issued_successfully'], ]); -// Listar / atualizar / remover / testar -$lista = $nfe->webhooks->list($companyId); -$nfe->webhooks->update($companyId, $webhookId, ['events' => ['invoice.issued']]); -$nfe->webhooks->delete($companyId, $webhookId); -$nfe->webhooks->test($companyId, $webhookId); +// Listar / consultar / remover / testar +$lista = $nfe->webhooks->listAccountWebhooks(); +$hook = $nfe->webhooks->retrieveAccountWebhook($webhook->id); +$nfe->webhooks->pingAccountWebhook($webhook->id); +$nfe->webhooks->deleteAccountWebhook($webhook->id); + +// Atualizar: PUT é substituição INTEGRAL — campos omitidos voltam ao padrão +// (um update sem `status` desativa o webhook). Parta sempre do retrieve: +$atual = $nfe->webhooks->retrieveAccountWebhook($webhookId); +$nfe->webhooks->updateAccountWebhook($webhookId, [ + 'uri' => 'https://meuapp.com.br/api/webhooks/nfe/v2', + 'contentType' => $atual->contentType, + 'status' => $atual->status, + 'filters' => $atual->filters, +]); -// Listar eventos suportados pela API -$eventos = $nfe->webhooks->getAvailableEvents(); +// Listar os event types suportados (lista viva da API): +$eventos = $nfe->webhooks->fetchEventTypes(); ``` +> Os métodos por empresa (`list($companyId)`, `create($companyId, …)`, …) estão +> **deprecated**: a rota `/v1/companies/{id}/webhooks` retorna 404 na API atual. + ### Verificar assinatura no endpoint O SDK fornece um helper estático alinhado ao esquema canônico usado pela NFE.io (HMAC-SHA1 sobre `X-Hub-Signature`): @@ -587,12 +605,13 @@ try { exit; } -// Roteamento por tipo de evento +// Roteamento por tipo de evento (padrão resource.event_action — +// a lista viva vem de $nfe->webhooks->fetchEventTypes()) match ($event->type) { - 'invoice.issued' => emitidaHandler($event->data), - 'invoice.cancelled' => canceladaHandler($event->data), - 'invoice.error' => erroHandler($event->data), - default => null, + 'service_invoice.issued_successfully' => emitidaHandler($event->data), + 'service_invoice.cancelled_successfully' => canceladaHandler($event->data), + 'service_invoice.issued_error' => erroHandler($event->data), + default => null, }; http_response_code(200); diff --git a/docs/recursos/webhooks.md b/docs/recursos/webhooks.md index 50421d6..8cc323b 100644 --- a/docs/recursos/webhooks.md +++ b/docs/recursos/webhooks.md @@ -3,15 +3,16 @@ title: Webhooks (CRUD) no SDK PHP da NFE.io sidebar_label: Webhooks (CRUD) sidebar_position: 9 slug: webhooks -description: Crie, liste, atualize, teste e exclua alvos de entrega de webhook por empresa com $nfe->webhooks — a verificação de assinatura fica na classe estática Nfe\Webhook. +description: Crie, liste, atualize, pingue e exclua alvos de entrega de webhook da conta com $nfe->webhooks — a verificação de assinatura fica na classe estática Nfe\Webhook. --- # Webhooks (`webhooks`) -`$nfe->webhooks` gerencia os **alvos de entrega** de webhook por empresa — -URLs que a NFE.io chama quando eventos acontecem (nota emitida, cancelada, -documento de entrada recebido…). Escopo de **empresa**, família `main` -(`api.nfe.io` `/v1`), chave principal. +`$nfe->webhooks` gerencia os **alvos de entrega** de webhook — URLs que a +NFE.io chama quando eventos acontecem (nota emitida, cancelada, documento de +entrada recebido…). Webhooks são registrados por **conta** (não por empresa): +`api.nfe.io` `/v2/webhooks`, chave principal. Todas as empresas da conta +disparam para os mesmos alvos; use `filters` para escolher os eventos. :::warning CRUD aqui, verificação lá Este recurso é só o **CRUD** dos alvos. A verificação de assinatura das @@ -23,50 +24,88 @@ entregas (`HMAC-SHA1`, header `X-Hub-Signature`) fica na classe **estática** | Método | Descrição | Retorno | |---|---|---| -| `list($companyId)` | Lista os webhooks da empresa. | `ListResponse` | -| `create($companyId, $data)` | Registra um novo alvo. | `Webhook` | -| `retrieve($companyId, $webhookId)` | Consulta por id. | `Webhook` | -| `update($companyId, $webhookId, $data)` | Atualiza URL/eventos/segredo. | `Webhook` | -| `delete($companyId, $webhookId)` | Exclui. | `void` | -| `test($companyId, $webhookId)` | Dispara uma entrega de teste. | `array` | -| `getAvailableEvents()` | Lista de eventos suportados (hard-coded, sem HTTP). | `array` | +| `listAccountWebhooks()` | Lista os webhooks da conta. | `ListResponse` | +| `createAccountWebhook($data)` | Registra um novo alvo. | `AccountWebhook` | +| `retrieveAccountWebhook($webhookId)` | Consulta por id. | `AccountWebhook` | +| `updateAccountWebhook($webhookId, $data)` | Substitui o webhook (PUT integral — veja o aviso). | `AccountWebhook` | +| `deleteAccountWebhook($webhookId)` | Exclui um webhook. | `void` | +| `deleteAllAccountWebhooks()` | ⚠️ Exclui **TODOS** os webhooks da conta. | `void` | +| `pingAccountWebhook($webhookId)` | Dispara uma entrega de teste. | `void` | +| `fetchEventTypes()` | Lista viva de event types da API. | `list` | + +Os métodos por empresa (`list($companyId)`, `create($companyId, …)`, `retrieve`, +`update`, `delete`, `test`) e o `getAvailableEvents()` estão **deprecated**: a +rota `/v1/companies/{id}/webhooks` retorna 404 na API atual e os literais +`invoice.*` não existem. Migre para os equivalentes de conta acima. ## Exemplos ### Registrar um alvo de entrega ```php -$webhook = $nfe->webhooks->create('55df4dc6b6cd9007e4f13ee8', [ - 'url' => 'https://minha-app.example.com/webhooks/nfe', - 'secret' => $_ENV['NFE_WEBHOOK_SECRET'], +$webhook = $nfe->webhooks->createAccountWebhook([ + 'uri' => 'https://minha-app.example.com/webhooks/nfe', + 'contentType' => 'json', + 'secret' => $_ENV['NFE_WEBHOOK_SECRET'], // 32–64 caracteres + 'filters' => ['service_invoice.issued_successfully'], ]); ``` +:::warning A URL é verificada na criação +No `create`, a NFE.io **pinga a `uri`** com um `POST` e exige resposta **2xx**. +Se o seu endpoint ainda não estiver no ar (ou exigir a assinatura para +responder 200), a criação falha. +::: + Guarde o mesmo `secret` na sua aplicação — ele é o HMAC usado para assinar cada -entrega, e você o passa a `Nfe\Webhook::verifySignature()` no seu endpoint. +entrega, e você o passa a `Nfe\Webhook::verifySignature()` no seu endpoint. O +`secret` é **ecoado apenas no create**; `retrieve`/`list` o omitem. -### Testar, atualizar e excluir +### Atualizar — PUT é substituição integral + +:::warning Sempre parta do estado atual +`updateAccountWebhook()` faz um `PUT` que **substitui o webhook inteiro**: +campos omitidos voltam ao padrão. Um update sem `status` **desativa o +webhook**. Monte o corpo a partir do `retrieve`: +::: ```php -// Entrega de teste para validar o endpoint de ponta a ponta: -$nfe->webhooks->test($companyId, $webhook->id); +$atual = $nfe->webhooks->retrieveAccountWebhook($webhook->id); -$nfe->webhooks->update($companyId, $webhook->id, [ - 'url' => 'https://minha-app.example.com/webhooks/nfe/v2', +$nfe->webhooks->updateAccountWebhook($webhook->id, [ + 'uri' => 'https://minha-app.example.com/webhooks/nfe/v2', // a mudança + 'contentType' => $atual->contentType, + 'status' => $atual->status, + 'filters' => $atual->filters, ]); +``` -$nfe->webhooks->delete($companyId, $webhook->id); // void +### Testar e excluir + +```php +// Entrega de teste (PUT /v2/webhooks/{id}/pings, responde 204): +$nfe->webhooks->pingAccountWebhook($webhook->id); + +$nfe->webhooks->deleteAccountWebhook($webhook->id); // void + +// ⚠️ Destrutivo e sem undo — remove TODOS os alvos da conta: +$nfe->webhooks->deleteAllAccountWebhooks(); ``` ### Eventos disponíveis ```php -// Lista embutida no SDK — não faz chamada de rede: -$eventos = $nfe->webhooks->getAvailableEvents(); +// Lista viva da API (GET /v2/webhooks/eventTypes) — 46 ids no padrão +// resource.event_action, ex.: service_invoice.issued_successfully, +// product_invoice.issued_error, consumer_invoice.cancelled_successfully… +$eventos = $nfe->webhooks->fetchEventTypes(); ``` +Use esses ids em `filters` ao criar/atualizar um webhook. Sem `filters`, o +alvo recebe todos os eventos. + ## Veja também - [Guia de webhooks](../webhooks.md) — verificação de assinatura e idempotência. - [Emissão assíncrona e polling](../async-and-polling.md) — push vs. polling. -- [Empresas](./companies.md) — o escopo dos alvos. +- [Empresas](./companies.md) — as empresas da conta que geram os eventos. diff --git a/docs/webhooks.md b/docs/webhooks.md index 069fb2c..b1c6106 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -72,7 +72,7 @@ $event = Webhook::constructEvent( secret: $_ENV['NFE_WEBHOOK_SECRET'], ); -$event->type; // ex.: "invoice.issued" (a chave `action` do envelope v2) +$event->type; // ex.: "service_invoice.issued_successfully" (a chave `action` do envelope v2) $event->data; // array com o payload (a chave `payload` do envelope) $event->id; // id estável para deduplicação, ou null $event->createdAt; // timestamp ISO-8601 da entrega, ou null diff --git a/samples/webhook-verify.php b/samples/webhook-verify.php index df72d4f..7fcb47c 100644 --- a/samples/webhook-verify.php +++ b/samples/webhook-verify.php @@ -33,14 +33,15 @@ exit; } -// Eventos típicos: invoice.issued, invoice.cancelled, invoice.error +// Eventos no padrão resource.event_action — a lista viva vem de +// $nfe->webhooks->fetchEventTypes() (ex.: service_invoice.*, product_invoice.*). error_log("Webhook ok: type={$event->type}, id=" . ($event->id ?? '-')); match ($event->type) { - 'invoice.issued' => error_log(' -> nota emitida'), - 'invoice.cancelled' => error_log(' -> nota cancelada'), - 'invoice.error' => error_log(' -> nota com erro'), - default => error_log(" -> evento não tratado: {$event->type}"), + 'service_invoice.issued_successfully' => error_log(' -> nota emitida'), + 'service_invoice.cancelled_successfully' => error_log(' -> nota cancelada'), + 'service_invoice.issued_error' => error_log(' -> nota com erro'), + default => error_log(" -> evento não tratado: {$event->type}"), }; http_response_code(200); diff --git a/skills/nfeio-php-sdk/SKILL.md b/skills/nfeio-php-sdk/SKILL.md index b50df4c..d36b67b 100644 --- a/skills/nfeio-php-sdk/SKILL.md +++ b/skills/nfeio-php-sdk/SKILL.md @@ -65,7 +65,7 @@ All 17 resources are bound eagerly as `public readonly` properties on `Nfe\Clien | `$nfe->companies` | Companies | api.nfe.io /v1 | Account | | `$nfe->legalPeople` | Legal people (PJ) | api.nfe.io /v1 | Company | | `$nfe->naturalPeople` | Natural people (PF) | api.nfe.io /v1 | Company | -| `$nfe->webhooks` | Webhooks CRUD | api.nfe.io /v1 | Company | +| `$nfe->webhooks` | Webhooks CRUD | api.nfe.io /v2 | Account | | `$nfe->addresses` | Address lookup (CEP only) | address.api.nfe.io/v2 | Global | | `$nfe->legalEntityLookup` | CNPJ lookup | legalentity.api.nfe.io | Global | | `$nfe->naturalPersonLookup` | CPF lookup | naturalperson.api.nfe.io | Global | @@ -218,7 +218,9 @@ Internally, downloads transparently follow a 302/303/307 redirect to the CDN in ## Core Pattern: Webhooks -CRUD lives on `$nfe->webhooks`; **signature validation lives on the static `Nfe\Webhook` class** (do not look for `$nfe->webhooks->verifySignature()` — it does not exist): +CRUD lives on `$nfe->webhooks` and is **account-scoped** (`/v2/webhooks` — all companies of the account fire to the same targets): `listAccountWebhooks()`, `createAccountWebhook($data)`, `retrieveAccountWebhook($id)`, `updateAccountWebhook($id, $data)`, `deleteAccountWebhook($id)`, `deleteAllAccountWebhooks()` (destructive — removes ALL), `pingAccountWebhook($id)`, `fetchEventTypes()`. Gotchas: NFE.io **pings the `uri` on create and requires a 2xx**; `secret` is 32–64 chars, echoed only on create; **update is a full PUT replacement** — omitting `status` deactivates the hook, so build the body from a fresh retrieve. The company-scoped `list`/`create`/`retrieve`/`update`/`delete`/`test($companyId, …)` methods are `@deprecated` (their `/v1/companies/{id}/webhooks` route 404s on the live API). + +**Signature validation lives on the static `Nfe\Webhook` class** (do not look for `$nfe->webhooks->verifySignature()` — it does not exist): ```php use Nfe\Webhook; @@ -232,7 +234,7 @@ $ok = Nfe\Webhook::verifySignature($payload, $signature, $secret); // bool; HMAC $event = Nfe\Webhook::constructEvent($payload, $signature, $secret); // Nfe\WebhookEvent ``` -`verifySignature()` accepts both prefixed (`sha1=…`) and bare hex, and takes an optional `$algo` (default `'sha1'`). `$nfe->webhooks->getAvailableEvents()` returns the hard-coded event list. +`verifySignature()` accepts both prefixed (`sha1=…`) and bare hex, and takes an optional `$algo` (default `'sha1'`). Event-type ids follow `resource.event_action` (e.g. `service_invoice.issued_successfully`) — fetch the live list with `$nfe->webhooks->fetchEventTypes()`; the hard-coded `getAvailableEvents()` is `@deprecated` (its `invoice.*` literals do not exist on the live API). ## Critical Pitfalls @@ -247,7 +249,7 @@ Verified against the SDK source. These are exactly where a Node-to-PHP port goes 7. **`productInvoices->list()` requires `environment`** and its `$options` arg is mandatory (cursor pagination). `serviceInvoices`/`consumerInvoices` use page-style with optional `$options`. 8. **`getStatus()` exists only on `serviceInvoices`** — not on product/consumer. 9. **Downloads return `string` bytes**, not a Buffer/stream. -10. **Deletion method names differ**: `companies->remove()` returns `array{deleted, id}`; `legalPeople`/`naturalPeople`/`stateTaxes`/`webhooks` use `delete(): void`. +10. **Deletion method names differ**: `companies->remove()` returns `array{deleted, id}`; `legalPeople`/`naturalPeople`/`stateTaxes` use `delete(): void`; webhooks use `deleteAccountWebhook($id): void` — and `deleteAllAccountWebhooks()` wipes EVERY webhook on the account, so never confuse the two. 11. **`federalTaxNumber` DTO type varies**: `Company` and `LegalPerson` = `?int`; `NaturalPerson` = `?string`. The SDK does no create-side validation/coercion — send the type the API expects (int for company/legal person). 12. **`dataApiKey` only applies to `addresses`, `legalEntityLookup`, `naturalPersonLookup`, and NF-e/NFC-e query.** `taxCalculation`, `transportationInvoices`, `inboundProductInvoices` always use the main `apiKey` — porting Node code that relied on `dataApiKey` for `api.nfse.io` resources will silently send the main key. 13. **Certificate upload is not implemented** — `companies` has only read-side cert helpers (`getCertificateStatus`, `checkCertificateExpiration`, `getCompaniesWith[Expiring]Certificates`). No `uploadCertificate`/`validateCertificate` (transport is JSON-only, no multipart). @@ -273,7 +275,7 @@ Verified against the SDK source. These are exactly where a Node-to-PHP port goes | Calculate Brazilian taxes | `$nfe->taxCalculation->calculate($tenantId, $request)` | | Manage companies & read cert status | `$nfe->companies->*` | | Manage people (PJ/PF) | `$nfe->legalPeople->*` / `$nfe->naturalPeople->*` | -| Set up webhook notifications | `$nfe->webhooks->create($companyId, $data)` | +| Set up webhook notifications | `$nfe->webhooks->createAccountWebhook($data)` (account-scoped; API pings the `uri`, expects 2xx) | | Validate an incoming webhook | `Nfe\Webhook::verifySignature($rawBody, $sig, $secret)` | | Manage state tax registrations (IE) | `$nfe->stateTaxes->*` | | Cancel a service invoice | `$nfe->serviceInvoices->cancel($companyId, $invoiceId)` (synchronous; returns the DTO) | diff --git a/skills/nfeio-php-sdk/references/service-invoices-and-polling.md b/skills/nfeio-php-sdk/references/service-invoices-and-polling.md index cc457e5..7f92a04 100644 --- a/skills/nfeio-php-sdk/references/service-invoices-and-polling.md +++ b/skills/nfeio-php-sdk/references/service-invoices-and-polling.md @@ -100,16 +100,27 @@ findByTaxNumber(string $companyId, string|int $taxNumber, ?RequestOptions $optio - **`federalTaxNumber` DTO type**: `LegalPerson` = `?int`, `NaturalPerson` = `?string`. No create-side validation/coercion. - `createBatch` runs sequentially (no parallelism). -## `$nfe->webhooks` (CRUD — Company-scoped) +## `$nfe->webhooks` (CRUD — Account-scoped, `/v2/webhooks`) + +Webhooks are registered per **account** — every company of the account fires to the same targets; use `filters` to select events. ```php -list(string $companyId, ?RequestOptions $options = null): ListResponse -create(string $companyId, array $data, ?RequestOptions $options = null): Webhook -retrieve(string $companyId, string $webhookId, ?RequestOptions $options = null): Webhook -update(string $companyId, string $webhookId, array $data, ?RequestOptions $options = null): Webhook -delete(string $companyId, string $webhookId, ?RequestOptions $options = null): void -test(string $companyId, string $webhookId, ?RequestOptions $options = null): array -getAvailableEvents(): array // hard-coded list, no API call +listAccountWebhooks(?RequestOptions $options = null): ListResponse // unwraps {webHooks: [...]} +createAccountWebhook(array $data, ?RequestOptions $options = null): AccountWebhook +retrieveAccountWebhook(string $webhookId, ?RequestOptions $options = null): AccountWebhook +updateAccountWebhook(string $webhookId, array $data, ?RequestOptions $options = null): AccountWebhook +deleteAccountWebhook(string $webhookId, ?RequestOptions $options = null): void +deleteAllAccountWebhooks(?RequestOptions $options = null): void // DESTRUCTIVE: removes ALL account webhooks +pingAccountWebhook(string $webhookId, ?RequestOptions $options = null): void // PUT /v2/webhooks/{id}/pings +fetchEventTypes(?RequestOptions $options = null): array // live list of event ids ``` +`AccountWebhook` DTO fields: `id`, `uri`, `contentType` (string on the wire, e.g. `"json"`), `secret` (32–64 chars; echoed on create, omitted on reads), `filters`, `insecureSsl`, `headers`, `properties`, `status` (`"Active"`/`"Inactive"`), `createdOn`, `modifiedOn`, `raw`. + +- The SDK wraps create/update bodies in the mandatory `{"webHook": {...}}` envelope and unwraps enveloped responses — pass the plain `$data` array, not a pre-wrapped one. +- **Create pings the `uri`** with an HTTP POST and requires a 2xx — a dead endpoint fails the create. +- **Update is a full PUT replacement**: omitted fields reset to defaults; omitting `status` deactivates the webhook. Build the body from a fresh `retrieveAccountWebhook()`. +- Event ids follow `resource.event_action` (`service_invoice.issued_successfully`, `product_invoice.*`, `consumer_invoice.*`) — use `fetchEventTypes()` for the live list. +- Deprecated (route 404s on the live API; kept for BC only): company-scoped `list`/`create`/`retrieve`/`update`/`delete`/`test($companyId, …)` and the hard-coded `getAvailableEvents()`. + **Signature validation is NOT here** — it lives on the static `Nfe\Webhook` class (see `error-handling-and-patterns.md`). diff --git a/src/Resource/Dto/Webhooks/AccountWebhook.php b/src/Resource/Dto/Webhooks/AccountWebhook.php new file mode 100644 index 0000000..cb13575 --- /dev/null +++ b/src/Resource/Dto/Webhooks/AccountWebhook.php @@ -0,0 +1,42 @@ +|null $filters Event-type filters (see `fetchEventTypes()`). + * @param array|null $headers Extra HTTP headers sent with deliveries. + * @param array|null $properties Extra properties merged into delivery bodies. + * @param array|null $raw Unwrapped response payload as received. + */ + public function __construct( + public ?string $id = null, + public ?string $uri = null, + public ?string $contentType = null, + public ?string $secret = null, + public ?array $filters = null, + public ?bool $insecureSsl = null, + public ?array $headers = null, + public ?array $properties = null, + public ?string $status = null, + public ?string $createdOn = null, + public ?string $modifiedOn = null, + public ?array $raw = null, + ) {} +} diff --git a/src/Resource/Dto/Webhooks/Webhook.php b/src/Resource/Dto/Webhooks/Webhook.php index 4bd4acc..7b65ade 100644 --- a/src/Resource/Dto/Webhooks/Webhook.php +++ b/src/Resource/Dto/Webhooks/Webhook.php @@ -10,6 +10,13 @@ * Not to be confused with the {@see \Nfe\Webhook} static helper for signature * verification — this DTO represents a managed webhook record returned by the * API's CRUD endpoints. + * + * @deprecated The `url`/`events` shape this DTO models is rejected by the live + * API (`400 "The Uri field is required"`); the company-scoped + * routes that would return it respond 404. Use + * {@see AccountWebhook} with the account-scoped methods on + * {@see \Nfe\Resource\WebhooksResource} instead. Removal planned + * for the next major version. */ final readonly class Webhook { diff --git a/src/Resource/WebhooksResource.php b/src/Resource/WebhooksResource.php index ada72fd..2c01d05 100644 --- a/src/Resource/WebhooksResource.php +++ b/src/Resource/WebhooksResource.php @@ -5,20 +5,35 @@ namespace Nfe\Resource; use Nfe\Http\RequestOptions; +use Nfe\Resource\Dto\Webhooks\AccountWebhook; use Nfe\Resource\Dto\Webhooks\Webhook as WebhookDto; use Nfe\Util\IdValidator; use Nfe\Util\ListResponse; /** - * Webhook subscription management (CRUD + test delivery). + * Webhook subscription management. * * Not to be confused with {@see \Nfe\Webhook} — the static helper that * verifies HMAC signatures on inbound webhook payloads. This resource is the * server-side management API: registering URLs, listing subscriptions, etc. * - * Routed via `main` family → `https://api.nfe.io/v1` (confirmed against - * Node SDK: `webhooks` resource uses `getMainHttpClient()` which defaults - * to `api.nfe.io/v1`). + * The live contract is **account-scoped**: `/v2/webhooks` on `api.nfe.io`, + * with create/update requests wrapped in a `{"webHook": {...}}` envelope and + * enveloped responses (confirmed by live probes on three accounts, + * 2026-07-02/03, matching the `/v2/webhooks` paths in + * `openapi/nf-servico-v1.yaml`). Use the `*AccountWebhook*` methods. + * + * The legacy company-scoped methods (`list`, `create`, `retrieve`, `update`, + * `delete`, `test`) target `/v1/companies/{id}/webhooks`, a route that + * returns 404 on the current API — they are kept only for BC and are all + * `@deprecated`. + * + * Contract source of truth: the OpenAPI spec plus live probes — never a + * sibling SDK. + * + * Routing note: this resource spans two API versions on the `main` host, so + * {@see self::apiVersion()} returns `''` and every path carries its version + * segment explicitly. */ final class WebhooksResource extends AbstractResource { @@ -29,43 +44,226 @@ protected function apiFamily(): string protected function apiVersion(): string { - return 'v1'; + // Empty on purpose: company-scoped methods hit /v1 and account-scoped + // methods hit /v2, so each path is written fully versioned. + return ''; + } + + // ------------------------------------------------------------------- + // Account-scoped webhooks (/v2/webhooks) — the live API contract + // ------------------------------------------------------------------- + + /** + * Lists all webhooks registered on the authenticated account. + * + * Unwraps the `{"webHooks": [...]}` envelope returned by + * `GET /v2/webhooks`. + * + * @return ListResponse + */ + public function listAccountWebhooks(?RequestOptions $options = null): ListResponse + { + $response = $this->httpGet('/v2/webhooks', options: $options); + $payload = $this->decodeBody($response->body); + + return $this->hydrateList(AccountWebhook::class, $payload, 'webHooks'); + } + + /** + * Creates an account webhook via `POST /v2/webhooks`. + * + * The request body is wrapped in the mandatory `webHook` envelope (a bare + * body is rejected with `400 "missing required properties: 'webHook'"`) + * and the `201 {"webHook": {...}}` response is unwrapped. + * + * NFE.io **pings the `uri` at creation time** with an HTTP POST and + * requires a 2xx response — a non-responsive URL fails the create. + * + * The `secret` must be 32–64 characters; it is echoed back on create but + * omitted on subsequent reads, so store it if you need it for signature + * verification ({@see \Nfe\Webhook::verifySignature()}). + * + * @param array $data Accepts `uri` (required), `contentType` + * (e.g. `"json"`), `secret` (32–64 chars), + * `filters` (list, see + * {@see self::fetchEventTypes()}), + * `insecureSsl`, `headers`, `properties`, + * `status`. + */ + public function createAccountWebhook(array $data, ?RequestOptions $options = null): AccountWebhook + { + $response = $this->httpPost('/v2/webhooks', ['webHook' => $data], $options); + $inner = $this->unwrap($this->decodeBody($response->body), 'webHook'); + + return $this->hydrate(AccountWebhook::class, $inner + ['raw' => $inner]); + } + + /** + * Retrieves a single account webhook (`GET /v2/webhooks/{id}`). + * + * Unwraps the `{"webHook": {...}}` envelope, falling back to the raw body + * if the envelope is absent. The `secret` is omitted on reads. + */ + public function retrieveAccountWebhook(string $webhookId, ?RequestOptions $options = null): AccountWebhook + { + $webhookId = IdValidator::invoiceId($webhookId); + $response = $this->httpGet("/v2/webhooks/{$webhookId}", options: $options); + $inner = $this->unwrap($this->decodeBody($response->body), 'webHook'); + + return $this->hydrate(AccountWebhook::class, $inner + ['raw' => $inner]); + } + + /** + * Updates an account webhook via `PUT /v2/webhooks/{id}`. + * + * ⚠️ **`PUT` is a full replacement, not a merge**: any field omitted from + * `$data` resets to its default — an update without `status` deactivates + * the webhook (live-confirmed). Always start from the current state: + * + * ```php + * $current = $nfe->webhooks->retrieveAccountWebhook($id); + * $nfe->webhooks->updateAccountWebhook($id, [ + * 'uri' => $current->uri, + * 'contentType' => $current->contentType, + * 'status' => $current->status, + * 'filters' => ['service_invoice.issued_successfully'], // the change + * ]); + * ``` + * + * The request body is wrapped in the mandatory `webHook` envelope and the + * enveloped response is unwrapped. + * + * @param array $data Full webhook state (see the warning above). + */ + public function updateAccountWebhook( + string $webhookId, + array $data, + ?RequestOptions $options = null, + ): AccountWebhook { + $webhookId = IdValidator::invoiceId($webhookId); + $response = $this->httpPut("/v2/webhooks/{$webhookId}", ['webHook' => $data], $options); + $inner = $this->unwrap($this->decodeBody($response->body), 'webHook'); + + return $this->hydrate(AccountWebhook::class, $inner + ['raw' => $inner]); + } + + /** + * Deletes a single account webhook (`DELETE /v2/webhooks/{id}`). + */ + public function deleteAccountWebhook(string $webhookId, ?RequestOptions $options = null): void + { + $webhookId = IdValidator::invoiceId($webhookId); + $this->httpDelete("/v2/webhooks/{$webhookId}", $options); } /** + * ⚠️ **Destructive**: deletes ALL webhooks on the authenticated account + * (`DELETE /v2/webhooks`, no id). There is no undo — every subscription is + * removed in a single call. Deliberately named apart from + * {@see self::deleteAccountWebhook()} so a missing id can never silently + * escalate a single delete into a bulk one. + */ + public function deleteAllAccountWebhooks(?RequestOptions $options = null): void + { + $this->httpDelete('/v2/webhooks', $options); + } + + /** + * Triggers a test delivery ping to an account webhook + * (`PUT /v2/webhooks/{id}/pings`, responds 204). + */ + public function pingAccountWebhook(string $webhookId, ?RequestOptions $options = null): void + { + $webhookId = IdValidator::invoiceId($webhookId); + $this->httpPut("/v2/webhooks/{$webhookId}/pings", null, $options); + } + + /** + * Fetches the live list of webhook event-type ids + * (`GET /v2/webhooks/eventTypes`). + * + * Returns ids following the `resource.event_action` pattern — e.g. + * `service_invoice.issued_successfully`, `product_invoice.*`, + * `consumer_invoice.*` (46 ids at probe time). Use these as `filters` + * when creating/updating a webhook. + * + * @return list + */ + public function fetchEventTypes(?RequestOptions $options = null): array + { + $response = $this->httpGet('/v2/webhooks/eventTypes', options: $options); + $payload = $this->decodeBody($response->body); + + $ids = []; + $eventTypes = $payload['eventTypes'] ?? []; + if (is_array($eventTypes)) { + foreach ($eventTypes as $eventType) { + if (is_array($eventType) && isset($eventType['id']) && is_string($eventType['id'])) { + $ids[] = $eventType['id']; + } + } + } + + return $ids; + } + + // ------------------------------------------------------------------- + // Legacy company-scoped webhooks (/v1/companies/{id}/webhooks) + // The route returns 404 on the current API (confirmed on three + // accounts, 2026-07-02/03). Kept for BC only; removal next major. + // ------------------------------------------------------------------- + + /** + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::listAccountWebhooks()}. + * * @return ListResponse */ public function list(string $companyId, ?RequestOptions $options = null): ListResponse { $companyId = IdValidator::companyId($companyId); - $response = $this->httpGet("/companies/{$companyId}/webhooks", options: $options); + $response = $this->httpGet("/v1/companies/{$companyId}/webhooks", options: $options); $payload = $this->decodeBody($response->body); return $this->hydrateList(WebhookDto::class, $payload, 'webhooks'); } /** + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::createAccountWebhook()}. + * * @param array $data Aceita `url` (required), `events` (list), * `secret` (string, optional). */ public function create(string $companyId, array $data, ?RequestOptions $options = null): WebhookDto { $companyId = IdValidator::companyId($companyId); - $response = $this->httpPost("/companies/{$companyId}/webhooks", $data, $options); + $response = $this->httpPost("/v1/companies/{$companyId}/webhooks", $data, $options); return $this->hydrate(WebhookDto::class, $this->decodeBody($response->body)); } + /** + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::retrieveAccountWebhook()}. + */ public function retrieve(string $companyId, string $webhookId, ?RequestOptions $options = null): WebhookDto { $companyId = IdValidator::companyId($companyId); $webhookId = IdValidator::invoiceId($webhookId); - $response = $this->httpGet("/companies/{$companyId}/webhooks/{$webhookId}", options: $options); + $response = $this->httpGet("/v1/companies/{$companyId}/webhooks/{$webhookId}", options: $options); return $this->hydrate(WebhookDto::class, $this->decodeBody($response->body)); } /** + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::updateAccountWebhook()}. + * * @param array $data */ public function update( @@ -76,34 +274,48 @@ public function update( ): WebhookDto { $companyId = IdValidator::companyId($companyId); $webhookId = IdValidator::invoiceId($webhookId); - $response = $this->httpPut("/companies/{$companyId}/webhooks/{$webhookId}", $data, $options); + $response = $this->httpPut("/v1/companies/{$companyId}/webhooks/{$webhookId}", $data, $options); return $this->hydrate(WebhookDto::class, $this->decodeBody($response->body)); } + /** + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::deleteAccountWebhook()}. + */ public function delete(string $companyId, string $webhookId, ?RequestOptions $options = null): void { $companyId = IdValidator::companyId($companyId); $webhookId = IdValidator::invoiceId($webhookId); - parent::httpDelete("/companies/{$companyId}/webhooks/{$webhookId}", $options); + parent::httpDelete("/v1/companies/{$companyId}/webhooks/{$webhookId}", $options); } /** * Triggers a test delivery to the webhook URL. * + * @deprecated `/v1/companies/{id}/webhooks` returns 404 on the current API + * (confirmed on three accounts, 2026-07-02/03). Use + * {@see self::pingAccountWebhook()}. + * * @return array */ public function test(string $companyId, string $webhookId, ?RequestOptions $options = null): array { $companyId = IdValidator::companyId($companyId); $webhookId = IdValidator::invoiceId($webhookId); - $response = $this->httpPost("/companies/{$companyId}/webhooks/{$webhookId}/test", [], $options); + $response = $this->httpPost("/v1/companies/{$companyId}/webhooks/{$webhookId}/test", [], $options); return $this->decodeBody($response->body); } /** - * Static list of available webhook event types (mirrors Node SDK hard-coded list). + * Static list of legacy webhook event types. + * + * @deprecated These `invoice.*`/`company.*` literals do not exist on the + * live API — the real ids follow `service_invoice.*` / + * `product_invoice.*` / `consumer_invoice.*`. Use + * {@see self::fetchEventTypes()} for the live list. * * @return list */ diff --git a/src/Version.php b/src/Version.php index 8d825dc..6888c65 100644 --- a/src/Version.php +++ b/src/Version.php @@ -12,5 +12,5 @@ */ final class Version { - public const CURRENT = '3.0.0'; + public const CURRENT = '3.1.0'; } diff --git a/tests/Resource/WebhooksResourceTest.php b/tests/Resource/WebhooksResourceTest.php new file mode 100644 index 0000000..22d187f --- /dev/null +++ b/tests/Resource/WebhooksResourceTest.php @@ -0,0 +1,173 @@ +push(new Response(201, [], ACCOUNT_WEBHOOK_CREATED_JSON)); + + $hook = buildWebhooksClient($mock)->webhooks->createAccountWebhook([ + 'uri' => 'https://example.com/hooks/nfe', + 'contentType' => 'json', + 'secret' => '0123456789abcdef0123456789abcdef', + 'filters' => ['service_invoice.issued_successfully'], + ]); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('POST'); + expect($sent?->baseUrl)->toBe('https://api.nfe.io'); + expect($sent?->path)->toBe('/v2/webhooks'); + + $body = json_decode((string) $sent?->body, associative: true); + expect($body)->toHaveKey('webHook'); + expect($body['webHook']['uri'])->toBe('https://example.com/hooks/nfe'); + + expect($hook)->toBeInstanceOf(AccountWebhook::class); + expect($hook->id)->toBe('e30cf7cc-a468-4edf-9a8d-8e5eae37cf01'); + expect($hook->secret)->toBe('0123456789abcdef0123456789abcdef'); + expect($hook->contentType)->toBe('json'); + expect($hook->status)->toBe('Active'); + expect($hook->raw)->toHaveKey('uri'); +}); + +it('updateAccountWebhook sends the webHook envelope via PUT and unwraps the response', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], ACCOUNT_WEBHOOK_CREATED_JSON)); + + $hook = buildWebhooksClient($mock)->webhooks->updateAccountWebhook('wh-1', [ + 'uri' => 'https://example.com/hooks/nfe', + 'status' => 'Active', + ]); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('PUT'); + expect($sent?->path)->toBe('/v2/webhooks/wh-1'); + + $body = json_decode((string) $sent?->body, associative: true); + expect($body)->toHaveKey('webHook'); + expect($body['webHook']['status'])->toBe('Active'); + + expect($hook)->toBeInstanceOf(AccountWebhook::class); + expect($hook->status)->toBe('Active'); +}); + +it('retrieveAccountWebhook unwraps the webHook envelope', function (): void { + // Leitura real: secret OMITIDO. + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"webHook":{"id":"wh-1","uri":"https://example.com/hooks/nfe","contentType":"json","status":"Active"}}', + )); + + $hook = buildWebhooksClient($mock)->webhooks->retrieveAccountWebhook('wh-1'); + + expect($mock->lastRequest()?->path)->toBe('/v2/webhooks/wh-1'); + expect($hook->id)->toBe('wh-1'); + expect($hook->secret)->toBeNull(); +}); + +it('retrieveAccountWebhook falls back to a raw (non-enveloped) body', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"id":"wh-1","uri":"https://example.com/hooks/nfe","status":"Active"}', + )); + + $hook = buildWebhooksClient($mock)->webhooks->retrieveAccountWebhook('wh-1'); + + expect($hook)->toBeInstanceOf(AccountWebhook::class); + expect($hook->id)->toBe('wh-1'); + expect($hook->uri)->toBe('https://example.com/hooks/nfe'); +}); + +it('listAccountWebhooks unwraps the webHooks envelope', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"webHooks":[{"id":"wh-1","uri":"https://a.example"},{"id":"wh-2","uri":"https://b.example"}]}', + )); + + $page = buildWebhooksClient($mock)->webhooks->listAccountWebhooks(); + + expect($mock->lastRequest()?->path)->toBe('/v2/webhooks'); + expect($page->data)->toHaveCount(2); + expect($page->data[0])->toBeInstanceOf(AccountWebhook::class); + expect($page->data[1]->id)->toBe('wh-2'); +}); + +it('deleteAccountWebhook targets the single-webhook path', function (): void { + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildWebhooksClient($mock)->webhooks->deleteAccountWebhook('wh-1'); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('DELETE'); + expect($sent?->path)->toBe('/v2/webhooks/wh-1'); +}); + +it('deleteAllAccountWebhooks is a distinct method hitting the collection path', function (): void { + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildWebhooksClient($mock)->webhooks->deleteAllAccountWebhooks(); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('DELETE'); + expect($sent?->path)->toBe('/v2/webhooks'); +}); + +it('pingAccountWebhook issues PUT /v2/webhooks/{id}/pings', function (): void { + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildWebhooksClient($mock)->webhooks->pingAccountWebhook('wh-1'); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('PUT'); + expect($sent?->path)->toBe('/v2/webhooks/wh-1/pings'); +}); + +it('fetchEventTypes extracts the live event ids', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"eventTypes":[' + . '{"id":"service_invoice.issued_successfully","resource":"service_invoice"},' + . '{"id":"service_invoice.cancelled_error","resource":"service_invoice"},' + . '{"id":"product_invoice.issued","resource":"product_invoice"}' + . ']}', + )); + + $ids = buildWebhooksClient($mock)->webhooks->fetchEventTypes(); + + expect($mock->lastRequest()?->path)->toBe('/v2/webhooks/eventTypes'); + expect($ids)->toBe([ + 'service_invoice.issued_successfully', + 'service_invoice.cancelled_error', + 'product_invoice.issued', + ]); +}); diff --git a/tests/Resource/WebhooksSpecAlignmentTest.php b/tests/Resource/WebhooksSpecAlignmentTest.php new file mode 100644 index 0000000..04fc201 --- /dev/null +++ b/tests/Resource/WebhooksSpecAlignmentTest.php @@ -0,0 +1,72 @@ + O spec OpenAPI completo, parseado. + */ +function loadServiceInvoiceSpec(): array +{ + static $spec = null; + if ($spec === null) { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-servico-v1.yaml'); + expect($spec)->toBeArray(); + } + + return $spec; +} + +it('POST /v2/webhooks request body requires the webHook envelope', function (): void { + $spec = loadServiceInvoiceSpec(); + + $requestSchema = $spec['paths']['/v2/webhooks']['post']['requestBody']['content']['application/json']['schema'] ?? null; + expect($requestSchema)->toBeArray(); + expect($requestSchema['properties'])->toHaveKey('webHook'); +}); + +it('AccountWebhook covers every field of the /v2/webhooks item schema', function (): void { + $spec = loadServiceInvoiceSpec(); + + $itemSchema = $spec['paths']['/v2/webhooks']['get']['responses']['200']['content']['application/json']['schema']['properties']['webHooks']['items'] ?? null; + expect($itemSchema)->toBeArray(); + + $specFields = array_keys($itemSchema['properties']); + expect($specFields)->not->toBeEmpty(); + + $dtoProperties = array_map( + fn(ReflectionProperty $p): string => $p->getName(), + (new ReflectionClass(AccountWebhook::class))->getProperties(), + ); + + expect(array_diff($specFields, $dtoProperties))->toBeEmpty(); +}); + +it('pins the deliberate wire deviations: contentType/status are int enums in the spec but strings on the wire', function (): void { + // A API serializa strings ("json", "Active"), mas o spec declara enum int + // (0/1) — o DTO segue o fio. Este teste PINA o enum int: se um sync do + // spec o corrigir para string, esta falha é o sinal para tipar o DTO + // direto do spec e remover o desvio documentado. + $spec = loadServiceInvoiceSpec(); + + $itemSchema = $spec['paths']['/v2/webhooks']['get']['responses']['200']['content']['application/json']['schema']['properties']['webHooks']['items']; + + foreach (['contentType', 'status'] as $field) { + $declared = $itemSchema['properties'][$field]; + expect($declared['type'])->toBe('integer', "spec mudou o tipo de {$field} — reavaliar o desvio no DTO"); + expect($declared['enum'])->toBe([0, 1]); + + $dtoType = (new ReflectionProperty(AccountWebhook::class, $field))->getType(); + assert($dtoType instanceof ReflectionNamedType); + expect($dtoType->getName())->toBe('string'); + } +});