diff --git a/CHANGELOG.md b/CHANGELOG.md index 6619e6f..db3e59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). ## [Não lançado] +> Correção do contrato de webhooks contra a API real, provado por sonda ao vivo +> (2026-07-02/03, três contas). O contrato correto sempre esteve nos specs oficiais +> (`openapi/nf-servico-v1.yaml` e equivalentes) — o recurso manuscrito havia +> divergido deles. + +### Corrigido + +- **O CRUD de webhooks funcionava 0% das vezes**: a rota company-scoped + `/v1/companies/{id}/webhooks` retorna 404 na API atual. O contrato real é + account-scoped (`/v2/webhooks`) e exige o request envelopado em + `{ "webHook": {...} }` (sem ele responde + `400 "missing required properties: 'webHook'"`), devolvendo a resposta também + envelopada. Os novos métodos account-scoped envelopam o request + (create/update) e desembrulham as respostas (create/retrieve/update/list), + com fallback defensivo para corpo cru. + +### Adicionado + +- Métodos account-scoped em `client.webhooks`: `list_account_webhooks`, + `create_account_webhook`, `retrieve_account_webhook`, + `update_account_webhook`, `delete_account_webhook`, + `delete_all_account_webhooks` (destrutivo, nome propositalmente distinto), + `ping_account_webhook` e `fetch_event_types`. +- Value object **`Nfe::AccountWebhook`** (`Data.define`, com RBS) com o shape + real da API: `uri`, `content_type`, `secret` (32–64 caracteres, ecoado no + create e omitido nas leituras), `filters`, `insecure_ssl`, `headers`, + `properties`, `status`, `created_on`, `modified_on`. Nota: o spec declara + `contentType`/`status` como enums inteiros, mas a API serializa strings + (`"json"`, `"Active"`) — o DTO segue o fio real. +- `fetch_event_types` retorna os event types reais de + `GET /v2/webhooks/eventTypes` (46 ids ao vivo, padrão + `service_invoice.*`/`product_invoice.*`/`consumer_invoice.*`). +- Teste de alinhamento (RSpec + Psych) amarrando o `Nfe::AccountWebhook` ao + schema de `/v2/webhooks` em `openapi/nf-servico-v1.yaml` — um sync de spec + que mude o contrato de webhooks quebra a suíte em vez de driftar. +- YARD do `create_account_webhook` documenta a verificação de URI na criação + (a NFE.io faz um ping e exige resposta 2xx) e o `secret` de 32–64 caracteres. +- YARD do `update_account_webhook` documenta que o `PUT` é substituição + integral (confirmado ao vivo em 2026-07-03): campos omitidos voltam ao + padrão — update sem `status` **desativa o webhook**. Envie o objeto completo + (parta do retrieve). + +### Deprecado + +- Métodos company-scoped de webhooks (`list`, `create`, `retrieve`, `update`, + `delete`, `test` sobre `/v1/companies/{id}/webhooks`): a rota retorna **404** + na API atual (confirmado em três contas, 2026-07-02/03). Use os equivalentes + account-scoped. O comportamento não mudou; remoção fica para a próxima major. +- `Nfe::WebhookSubscription` (`url`/`events`/`active`) e + `get_available_events`/`AVAILABLE_EVENTS` (literais `invoice.*`): shapes e + eventos que a API real rejeita ou desconhece. Use `Nfe::AccountWebhook` e + `fetch_event_types`. + ## [1.0.0] - 2026-07-02 ### Adicionado diff --git a/README.md b/README.md index ab53290..1116ee9 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ A `v1` expõe **17 recursos canônicos** no `Nfe::Client` (mais 2 addons RTC). | `companies` | `api.nfe.io` (`/v1`) | Empresas + certificado | `create`, `retrieve`, `list`, `update`, `remove`, `upload_certificate`, `get_certificate_status` | | `legal_people` | `api.nfe.io` (`/v1`) | Pessoas jurídicas (tomadores) | `create`, `retrieve`, `list`, `update`, `delete`, `create_batch`, `find_by_tax_number` | | `natural_people` | `api.nfe.io` (`/v1`) | Pessoas físicas (tomadores) | `create`, `retrieve`, `list`, `update`, `delete`, `create_batch`, `find_by_tax_number` | -| `webhooks` | `api.nfe.io` (`/v1`) | Assinaturas de webhook | `create`, `retrieve`, `list`, `update`, `delete`, `test`, `verify_signature` | +| `webhooks` | `api.nfe.io` (`/v2`, conta) | Webhooks da conta | `create_account_webhook`, `retrieve_account_webhook`, `list_account_webhooks`, `update_account_webhook`, `delete_account_webhook`, `ping_account_webhook`, `fetch_event_types`, `verify_signature` | | `product_invoices` | `api.nfse.io` (`/v2`) | NF-e | `create`, `create_with_state_tax`, `list`, `retrieve`, `cancel`, `send_correction_letter`, `disable`, `download_*` | | `consumer_invoices` | `api.nfse.io` (`/v2`) | NFC-e | `create`, `create_with_state_tax`, `list`, `retrieve`, `cancel`, `disable_range`, `download_pdf`/`download_xml` | | `transportation_invoices` | `api.nfse.io` (`/v2`) | CT-e (recepção) | `enable`, `disable`, `get_settings`, `retrieve`, `download_xml`, `get_event` | @@ -374,16 +374,26 @@ Devolvem bytes: `service_invoices`, `consumer_invoices`, ## Webhooks -Crie a assinatura e **verifique a assinatura** de cada entrega. +Crie o webhook (escopo da **conta**, `/v2/webhooks`) e **verifique a +assinatura** de cada entrega. A NFE.io pinga a `uri` na criação e exige 2xx; +descubra os filtros válidos com `fetch_event_types`. ```ruby -client.webhooks.create("co_1", { - url: "https://minha-app.com/webhooks/nfe", - events: ["invoice.issued", "invoice.cancelled", "invoice.failed"], - secret: ENV["NFE_WEBHOOK_SECRET"] -}) +client.webhooks.create_account_webhook( + uri: "https://minha-app.com/webhooks/nfe", + contentType: "json", + secret: ENV["NFE_WEBHOOK_SECRET"], # 32–64 caracteres + filters: ["service_invoice.issued_successfully", "service_invoice.issued_error"], + status: "Active" +) ``` +> ⚠️ `update_account_webhook` faz **PUT integral**: campos omitidos voltam ao +> padrão — um update sem `status` desativa o webhook. Parta do +> `retrieve_account_webhook` e envie o objeto completo. Os métodos +> company-scoped (`create`/`list`/... com `company_id`) estão **deprecated** — +> a rota `/v1/companies/{id}/webhooks` retorna 404 na API atual. + A verificação é **HMAC-SHA1 sobre os bytes crus** da requisição (header `X-Hub-Signature`, comparação case-insensitive e timing-safe). Leia o corpo bruto **antes** de fazer parse do JSON — reserializar (`payload.to_json`) muda diff --git a/docs/recursos/webhooks.md b/docs/recursos/webhooks.md index 303e36b..0d5460b 100644 --- a/docs/recursos/webhooks.md +++ b/docs/recursos/webhooks.md @@ -3,54 +3,111 @@ title: Webhooks no SDK Ruby da NFE.io sidebar_label: Webhooks sidebar_position: 10 slug: webhooks -description: CRUD de assinaturas de webhook por empresa, disparo de teste, lista de eventos disponíveis e verificação de assinatura no SDK Ruby da NFE.io. +description: CRUD de webhooks no escopo da conta (/v2/webhooks), ping de teste, tipos de eventos ao vivo e verificação de assinatura no SDK Ruby da NFE.io. --- # Webhooks -O recurso `webhooks` gerencia as assinaturas de webhook **escopadas por empresa**, -sob `/companies/{id}/webhooks`. Faz parte da família `main`, servida por -`api.nfe.io` (`/v1`), e usa a `api_key`. Além do CRUD, expõe um disparo de teste, -a lista estática de eventos e a verificação de assinatura. +O recurso `webhooks` gerencia os webhooks **no escopo da conta autenticada**, +sob `/v2/webhooks` em `api.nfe.io` (família `main`, `api_key`). A API envelopa +os requests de create/update e as respostas de objeto único em `webHook` — o +SDK envelopa e desembrulha automaticamente. Além do CRUD, expõe um ping de +teste, a lista **ao vivo** de tipos de eventos e a verificação de assinatura. + +:::warning Métodos por empresa estão deprecated +Os métodos company-scoped (`list`, `create`, `retrieve`, `update`, `delete`, +`test` sob `/v1/companies/{id}/webhooks`) estão **deprecated**: a rota retorna +**404** na API atual (confirmado em três contas, 2026-07-02/03). Use os +equivalentes `*_account_webhook*` abaixo. A remoção fica para a próxima major. +::: ## Métodos públicos ```ruby -webhooks.list(company_id) # => Nfe::ListResponse -webhooks.create(company_id, data) # => Nfe::WebhookSubscription -webhooks.retrieve(company_id, webhook_id) # => Nfe::WebhookSubscription -webhooks.update(company_id, webhook_id, data) # => Nfe::WebhookSubscription -webhooks.delete(company_id, webhook_id) # => nil -webhooks.test(company_id, webhook_id) # => { success: bool, message: String? } -webhooks.get_available_events # => Array +webhooks.list_account_webhooks # => Nfe::ListResponse +webhooks.create_account_webhook(data) # => Nfe::AccountWebhook +webhooks.retrieve_account_webhook(webhook_id) # => Nfe::AccountWebhook +webhooks.update_account_webhook(webhook_id, data) # => Nfe::AccountWebhook +webhooks.delete_account_webhook(webhook_id) # => nil +webhooks.delete_all_account_webhooks # => nil (⚠️ apaga TODOS) +webhooks.ping_account_webhook(webhook_id) # => nil +webhooks.fetch_event_types # => Array webhooks.verify_signature(payload:, signature:, secret:) # => Boolean ``` -Os eventos disponíveis (`get_available_events`) são: -`invoice.issued`, `invoice.cancelled`, `invoice.failed`, `invoice.processing`, -`company.created`, `company.updated` e `company.deleted`. +`Nfe::AccountWebhook` é um `Data.define` imutável com o shape real do fio: +`id`, `uri`, `content_type`, `secret`, `filters`, `insecure_ssl`, `headers`, +`properties`, `status`, `created_on`, `modified_on`. O `secret` (32–64 +caracteres) é ecoado **apenas no create** e omitido nas leituras. ## Exemplos -### Criar e testar uma assinatura +### Criar um webhook ```ruby require "nfe" client = Nfe::Client.new(api_key: ENV.fetch("NFE_API_KEY")) -company_id = "55df4dc6b6cd9007e4f13ee8" - -hook = client.webhooks.create( - company_id, - url: "https://app.exemplo.com.br/webhooks/nfe", - events: ["invoice.issued", "invoice.failed"], - secret: ENV.fetch("NFE_WEBHOOK_SECRET"), - active: true + +hook = client.webhooks.create_account_webhook( + uri: "https://app.exemplo.com.br/webhooks/nfe", + contentType: "json", + secret: ENV.fetch("NFE_WEBHOOK_SECRET"), # 32–64 caracteres + filters: ["service_invoice.issued_successfully", "service_invoice.issued_error"], + status: "Active" ) -# Dispara uma entrega sintética para validar que o endpoint responde: -result = client.webhooks.test(company_id, hook.id) -result[:success] +hook.id # GUID do webhook +hook.secret # ecoado só aqui — guarde-o agora +``` + +:::warning A NFE.io pinga a URI na criação +No `create`, a NFE.io faz um **ping de verificação na `uri` e exige resposta +2xx** — o endpoint precisa estar no ar **antes** de criar o webhook, ou a +criação falha. +::: + +### Descobrir os tipos de eventos (ao vivo) + +```ruby +client.webhooks.fetch_event_types +# => ["service_invoice.issued_successfully", "service_invoice.cancelled_error", +# "product_invoice.issued", "consumer_invoice.issued", ...] (46 ids) +``` + +Os ids seguem o padrão `service_invoice.*` / `product_invoice.*` / +`consumer_invoice.*` e são os valores válidos para `filters`. Os literais +antigos `invoice.*` (de `get_available_events`, deprecated) **não existem** na +API real. + +### Atualizar — o PUT é substituição integral + +```ruby +current = client.webhooks.retrieve_account_webhook(hook.id) + +client.webhooks.update_account_webhook(hook.id, { + uri: current.uri, + contentType: current.content_type, + status: current.status, # sem isso o hook é DESATIVADO + filters: ["service_invoice.issued_successfully"] +}) +``` + +:::warning Campos omitidos voltam ao padrão +`PUT /v2/webhooks/{id}` **substitui o objeto inteiro** (confirmado ao vivo): +um update sem `status` desativa o webhook. Sempre parta do `retrieve` e envie +o objeto completo. +::: + +### Testar, deletar + +```ruby +client.webhooks.ping_account_webhook(hook.id) # entrega de teste (204) +client.webhooks.delete_account_webhook(hook.id) # remove um webhook + +# ⚠️ DESTRUTIVO: remove TODOS os webhooks da conta — método propositalmente +# distinto do delete unitário: +client.webhooks.delete_all_account_webhooks ``` ### Verificar a assinatura de um payload recebido @@ -58,7 +115,7 @@ result[:success] ```ruby ok = client.webhooks.verify_signature( payload: request.raw_post, - signature: request.get_header("HTTP_X_NFE_SIGNATURE"), + signature: request.get_header("HTTP_X_HUB_SIGNATURE"), secret: ENV.fetch("NFE_WEBHOOK_SECRET") ) @@ -66,19 +123,21 @@ head :unauthorized unless ok ``` :::tip Use o módulo `Nfe::Webhook` no handler -`webhooks.verify_signature` é uma delegação fina para `Nfe::Webhook.verify_signature`, -oferecida por paridade com o SDK Node. No seu controlador HTTP, prefira chamar o -módulo diretamente — ele não exige um `Nfe::Client` e **nunca** levanta exceção, -apenas devolve `true`/`false`. Veja o guia de [Webhooks](../webhooks.md). +`webhooks.verify_signature` é uma delegação fina para `Nfe::Webhook.verify_signature`. +No seu controlador HTTP, prefira chamar o módulo diretamente — ele não exige um +`Nfe::Client` e **nunca** levanta exceção, apenas devolve `true`/`false`. Veja o +guia de [Webhooks](../webhooks.md). ::: -:::note `list` tolera vários formatos -`list(company_id)` aceita a resposta da API como array puro, embrulhada em `data` -ou em um envelope `webhooks`, sempre devolvendo um `Nfe::ListResponse`. +:::note Fonte do contrato +O contrato acima vem do spec OpenAPI (`openapi/nf-servico-v1.yaml`) validado +por sonda ao vivo contra `api.nfe.io`, e está amarrado por um teste de +alinhamento na suíte — nunca de outro SDK. Única divergência deliberada: o +spec declara `contentType`/`status` como enums inteiros, mas a API serializa +strings (`"json"`, `"Active"`); o SDK segue o fio real. ::: ## Veja também - [Webhooks (guia)](../webhooks.md) — verificação de assinatura e recebimento por push. -- [Empresas](./companies.md) — o escopo (`company_id`) das assinaturas. - [Emissão assíncrona e polling](../async-and-polling.md) — alternativa por polling. diff --git a/docs/webhooks.md b/docs/webhooks.md index 48d515c..b4bc0b3 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -72,7 +72,7 @@ event = Nfe::Webhook.construct_event( secret: ENV.fetch("NFE_WEBHOOK_SECRET") ) -event.type # ex.: "invoice.issued" +event.type # ex.: "service_invoice.issued_successfully" event.data # Hash com o payload (a chave "payload" ou "data" do envelope) event.id # id estável para deduplicação, ou nil event.created_at # timestamp da entrega como String, ou nil @@ -151,6 +151,10 @@ pode gerar efeitos colaterais duplicados. ## Próximos passos +- [Webhooks (recurso)](./recursos/webhooks.md) — crie e gerencie os webhooks da + conta (`/v2/webhooks`), descubra os tipos de eventos ao vivo com + `fetch_event_types` e veja os cuidados do create (ping 2xx na URI) e do + update (PUT é substituição integral). - [Primeiros passos](./getting-started.md) — emissão e polling como alternativa ao push. - [Paginação](./pagination.md) — percorra listas de notas. - [Downloads](./downloads.md) — baixe PDF/XML das notas. diff --git a/lib/nfe/resources/dto/account_webhook.rb b/lib/nfe/resources/dto/account_webhook.rb new file mode 100644 index 0000000..931815c --- /dev/null +++ b/lib/nfe/resources/dto/account_webhook.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Nfe + # Immutable value object for an account-level webhook, as returned by the + # +/v2/webhooks+ API (account scope, envelope +{"webHook": {...}}+ on the + # wire). This is the shape the live API accepts and returns — confirmed by + # probe against +api.nfe.io+ (2026-07-02/03) and matching the schema in + # +openapi/nf-servico-v1.yaml+. + # + # Wire-format note: the OpenAPI spec declares +contentType+/+status+ as int + # enums (0/1), but the API serializes strings (+"json"+, +"Active"+) — this + # DTO follows the wire. +secret+ (32–64 chars) is echoed only on create and + # omitted on reads, so it is +nil+ on retrieve/list. + # + # {from_api} maps API camelCase onto snake_case, drops unknown keys, and is + # nil-tolerant (+from_api(nil)+ returns +nil+). + class AccountWebhook < Data.define( + :id, + :uri, + :content_type, + :secret, + :filters, + :insecure_ssl, + :headers, + :properties, + :status, + :created_on, + :modified_on + ) + # @param payload [Hash, nil] the unwrapped webhook object. + # @return [Nfe::AccountWebhook, nil] +nil+ when +payload+ is +nil+. + def self.from_api(payload) + return nil if payload.nil? + + new( + id: payload["id"], + uri: payload["uri"], + content_type: payload["contentType"], + secret: payload["secret"], + filters: payload["filters"], + insecure_ssl: payload["insecureSsl"], + headers: payload["headers"], + properties: payload["properties"], + status: payload["status"], + created_on: payload["createdOn"], + modified_on: payload["modifiedOn"] + ) + end + end +end diff --git a/lib/nfe/resources/dto/webhook.rb b/lib/nfe/resources/dto/webhook.rb index dbbd28e..dbe7888 100644 --- a/lib/nfe/resources/dto/webhook.rb +++ b/lib/nfe/resources/dto/webhook.rb @@ -13,6 +13,12 @@ module Nfe # # {from_api} maps API camelCase onto snake_case, drops unknown keys, and is # nil-tolerant (+from_api(nil)+ returns +nil+). + # + # @deprecated This shape (+url+/+events+/+active+) is rejected by the live + # API (+400 "The Uri field is required"+), and the company-scoped route + # that would return it responds 404 (confirmed on three accounts, + # 2026-07-02/03). Use {Nfe::AccountWebhook} with the account-scoped + # methods on {Nfe::Resources::Webhooks}. class WebhookSubscription < Data.define( :id, :url, diff --git a/lib/nfe/resources/webhooks.rb b/lib/nfe/resources/webhooks.rb index ca48c04..3392f36 100644 --- a/lib/nfe/resources/webhooks.rb +++ b/lib/nfe/resources/webhooks.rb @@ -2,17 +2,39 @@ require "json" require "nfe/resources/abstract_resource" +require "nfe/resources/dto/account_webhook" require "nfe/resources/dto/webhook" require "nfe/webhook" module Nfe module Resources - # Webhooks resource, company-scoped under +/companies/{id}/webhooks+ on the - # +:main+ host family. Exposes CRUD, a synthetic-delivery +test+, the static - # list of available events, and a thin {#verify_signature} delegation to - # {Nfe::Webhook} (the canonical signature-verification API is the module). + # Webhooks resource on the +:main+ host family. + # + # Webhooks are managed at the **account** level over +/v2/webhooks+ — use + # the +*_account_webhook*+ methods. The live API wraps create/update + # requests and single-object responses in a +webHook+ envelope; the SDK + # envelopes/unwraps transparently. Contract source of truth: + # +openapi/nf-servico-v1.yaml+ plus live probes (2026-07-02/03). + # + # The company-scoped methods (+list+/+create+/+retrieve+/+update+/ + # +delete+/+test+ under +/v1/companies/{id}/webhooks+) are deprecated: the + # route returns 404 on the current API (confirmed on three accounts, + # 2026-07-02/03). They remain, unchanged, until the next major. + # + # Also exposes a thin {#verify_signature} delegation to {Nfe::Webhook} + # (the canonical signature-verification API is the module). class Webhooks < AbstractResource - # The seven event types the NFE.io API can deliver (parity with Node). + # Preserve the inherited HTTP DELETE helper under a private name before + # the public company-scoped +delete+ shadows it — the account-scoped + # deletes still need the raw verb. + alias http_delete delete + private :http_delete + + # Legacy static list of event types. + # + # @deprecated These literals do not exist on the live API — the real + # event types follow +service_invoice.*+/+product_invoice.*+/ + # +consumer_invoice.*+ (46 ids live). Use {#fetch_event_types}. AVAILABLE_EVENTS = %w[ invoice.issued invoice.cancelled @@ -29,10 +51,144 @@ def api_family :main end + # Account-scoped endpoints live under +/v2+ on the same +:main+ host, + # while the (deprecated) company-scoped ones keep the resource default + # +/v1+. A path that already carries an explicit version segment passes + # through unprefixed. + def full_path(path) + path.start_with?("/v2/") ? path : super + end + public + # List the account's webhooks (+GET /v2/webhooks+). + # + # The API wraps the collection as +{"webHooks": [...]}+; the SDK unwraps + # it into an {Nfe::ListResponse} of {Nfe::AccountWebhook}. +secret+ is + # omitted on reads. + # + # @return [Nfe::ListResponse] + def list_account_webhooks + response = get("/v2/webhooks") + hydrate_list(Nfe::AccountWebhook, parse_json(response.body), wrapper_key: "webHooks") + end + + # Create an account webhook (+POST /v2/webhooks+). + # + # The request is wrapped in the mandatory +webHook+ envelope (the API + # rejects a bare body with +400 "missing required properties: 'webHook'"+) + # and the +201 {"webHook": {...}}+ response is unwrapped. + # + # NFE.io **pings the +uri+ at creation time and requires a 2xx + # response** — the endpoint must already be live, or the create fails. + # +secret+ must be 32–64 characters; it is echoed back on create and + # omitted on subsequent reads. + # + # @param data [Hash] webhook attributes in wire (camelCase) keys, e.g. + # +{ uri: "https://...", contentType: "json", secret: "<32-64 chars>", + # filters: ["service_invoice.issued_successfully"], status: "Active" }+. + # Discover valid +filters+ with {#fetch_event_types}. + # @return [Nfe::AccountWebhook] + def create_account_webhook(data) + response = post("/v2/webhooks", + body: json_body({ webHook: data }), headers: json_headers) + hydrate_account_webhook(response) + end + + # Retrieve an account webhook by id (+GET /v2/webhooks/{id}+). + # + # Unwraps the +{"webHook": {...}}+ envelope, falling back to the raw + # body when the envelope is absent. +secret+ is omitted on reads. + # + # @param webhook_id [String] + # @return [Nfe::AccountWebhook] + def retrieve_account_webhook(webhook_id) + wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") + response = get("/v2/webhooks/#{wid}") + hydrate_account_webhook(response) + end + + # Update an account webhook (+PUT /v2/webhooks/{id}+), request wrapped + # in the +webHook+ envelope. + # + # @note **+PUT+ is a full replacement** (live-confirmed 2026-07-03): + # omitted fields reset to their defaults — an update without +status+ + # **deactivates the webhook**. Always send the complete object, + # starting from a retrieve: + # + # current = client.webhooks.retrieve_account_webhook(id) + # client.webhooks.update_account_webhook(id, { + # uri: current.uri, + # contentType: current.content_type, + # status: current.status, # keep it "Active"! + # filters: ["service_invoice.issued_successfully"] + # }) + # + # @param webhook_id [String] + # @param data [Hash] the complete webhook attributes (wire camelCase keys). + # @return [Nfe::AccountWebhook] + def update_account_webhook(webhook_id, data) + wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") + response = put("/v2/webhooks/#{wid}", + body: json_body({ webHook: data }), headers: json_headers) + hydrate_account_webhook(response) + end + + # Delete a single account webhook by id (+DELETE /v2/webhooks/{id}+). + # + # @param webhook_id [String] + # @return [nil] + def delete_account_webhook(webhook_id) + wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") + http_delete("/v2/webhooks/#{wid}") + nil + end + + # ⚠️ DESTRUCTIVE: delete **ALL** of the account's webhooks + # (+DELETE /v2/webhooks+). Named distinctly from + # {#delete_account_webhook} so it can never be reached by a mistyped + # single delete. + # + # @return [nil] + def delete_all_account_webhooks + http_delete("/v2/webhooks") + nil + end + + # Trigger a test ping for an account webhook + # (+PUT /v2/webhooks/{id}/pings+, responds 204). + # + # @param webhook_id [String] + # @return [nil] + def ping_account_webhook(webhook_id) + wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") + put("/v2/webhooks/#{wid}/pings", body: json_body({}), headers: json_headers) + nil + end + + # Fetch the live list of webhook event types + # (+GET /v2/webhooks/eventTypes+). + # + # The API wraps the result as +{"eventTypes": [{ "id": ... }, ...]}+; + # this extracts the ids (e.g. +"service_invoice.issued_successfully"+, + # +"product_invoice.issued"+ — 46 ids live). Use these as +filters+ when + # creating or updating a webhook. + # + # @return [Array] + def fetch_event_types + response = get("/v2/webhooks/eventTypes") + payload = parse_json(response.body) + items = [] #: Array[untyped] + items = payload["eventTypes"] || items if payload.is_a?(Hash) + items.map { |item| item["id"] }.compact + end + # List a company's webhook subscriptions. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#list_account_webhooks}. + # # @param company_id [String] # @return [Nfe::ListResponse] def list(company_id) @@ -45,9 +201,13 @@ def list(company_id) # Create a webhook subscription. Accepts +url+, +events+, +secret+, +active+. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#create_account_webhook}. + # # @param company_id [String] # @param data [Hash] - # @return [Nfe::Webhook] + # @return [Nfe::WebhookSubscription, nil] def create(company_id, data) id = Nfe::IdValidator.company_id(company_id) response = post("/companies/#{id}/webhooks", @@ -57,9 +217,13 @@ def create(company_id, data) # Retrieve a webhook by id. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#retrieve_account_webhook}. + # # @param company_id [String] # @param webhook_id [String] - # @return [Nfe::Webhook] + # @return [Nfe::WebhookSubscription, nil] def retrieve(company_id, webhook_id) id = Nfe::IdValidator.company_id(company_id) wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") @@ -69,10 +233,14 @@ def retrieve(company_id, webhook_id) # Update a webhook. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#update_account_webhook}. + # # @param company_id [String] # @param webhook_id [String] # @param data [Hash] - # @return [Nfe::Webhook] + # @return [Nfe::WebhookSubscription, nil] def update(company_id, webhook_id, data) id = Nfe::IdValidator.company_id(company_id) wid = Nfe::IdValidator.presence!(webhook_id, "webhook_id") @@ -83,6 +251,10 @@ def update(company_id, webhook_id, data) # Delete a webhook. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#delete_account_webhook}. + # # @param company_id [String] # @param webhook_id [String] # @return [nil] @@ -95,6 +267,10 @@ def delete(company_id, webhook_id) # Trigger a synthetic delivery to verify a webhook is reachable. # + # @deprecated The +/v1/companies/{id}/webhooks+ route returns 404 on the + # current API (confirmed on three accounts, 2026-07-02/03). Use + # {#ping_account_webhook}. + # # @param company_id [String] # @param webhook_id [String] # @return [Hash] +{ success: bool, message: String? }+. @@ -107,7 +283,10 @@ def test(company_id, webhook_id) { success: payload["success"] || false, message: payload["message"] } end - # The static list of webhook event types the API can deliver. + # The legacy static list of webhook event types. + # + # @deprecated These literals do not exist on the live API. Use + # {#fetch_event_types} for the real, live list. # # @return [Array] def get_available_events @@ -124,6 +303,12 @@ def verify_signature(payload:, signature:, secret:) private + # Unwrap a single-object +{"webHook": {...}}+ envelope (raw-body + # fallback) and hydrate an {Nfe::AccountWebhook}. + def hydrate_account_webhook(response) + hydrate(Nfe::AccountWebhook, unwrap(parse_json(response.body), "webHook")) + end + def json_headers { "Content-Type" => "application/json" } end @@ -133,7 +318,7 @@ def json_body(data) end # Tolerate either a bare array, a +data+-wrapped list, or a +webhooks+ - # envelope for the list response. + # envelope for the (deprecated) company-scoped list response. def webhook_items(payload) return payload if payload.is_a?(Array) return [] unless payload.is_a?(Hash) diff --git a/lib/nfe/webhook_event.rb b/lib/nfe/webhook_event.rb index 13333b6..3ac4c6d 100644 --- a/lib/nfe/webhook_event.rb +++ b/lib/nfe/webhook_event.rb @@ -4,7 +4,7 @@ module Nfe # Immutable value object for a verified webhook delivery, produced by # {Nfe::Webhook.construct_event} after the HMAC-SHA1 signature checks out. # - # - +type+ is the event type (e.g. +"invoice.issued"+), unwrapped from the + # - +type+ is the event type (e.g. +"service_invoice.issued_successfully"+), unwrapped from the # delivery envelope's +action+ or +event+ key. # - +data+ is the payload +Hash+ (the envelope's +payload+ or +data+ key). # - +id+ is a stable event/invoice id for deduplication, or +nil+ when the diff --git a/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/.openspec.yaml b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/design.md b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/design.md new file mode 100644 index 0000000..6fdfa63 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/design.md @@ -0,0 +1,91 @@ +# Design: fix-account-webhooks-contract (client-ruby) + +## Context + +`Nfe::Resources::Webhooks` implementa 6 operações company-scoped sobre `/v1/companies/{id}/webhooks` — todas retornam 404 na API real. O contrato veio por "parity with Node" (comentário no próprio código), e o Node o havia alucinado no rewrite (arqueologia completa em `client-nodejs/openspec/changes/fix-account-webhooks-contract/design.md`). O SDK Ruby não tem NENHUM método account-scoped. Ironia registrada: a change `add-entity-resources` sondou `api.nfse.io/v2/webhooks` ao vivo (para o esquema HMAC) e mesmo assim o management shipou company-scoped. + +### Contrato confirmado ao vivo (3 sondas, 3 contas, 2026-07-02/03) + +``` +GET /v1/companies/{id}/webhooks -> 404 (todas as contas) +POST /v1/companies/{id}/webhooks -> 404 + +GET /v2/webhooks -> 200 { "webHooks": [ {...} ] } +GET /v2/webhooks/{id} -> 200 { "webHook": {...} } (secret OMITIDO) +GET /v2/webhooks/eventTypes -> 200 { "eventTypes": [ { id, ... } ] } + +POST /v2/webhooks (body cru) -> 400 "missing required properties: 'webHook'" +POST /v2/webhooks { "webHook": {...} } -> 201 { "webHook": { id, uri, secret, contentType, + insecureSsl, status, filters, createdOn, modifiedOn } } + (secret ECOADO no create; NFE.io PINGA a uri e exige 2xx) + +PUT /v2/webhooks/{id} (body cru) -> 400 errors.WebHook = "The WebHook field is required." +PUT /v2/webhooks/{id} { "webHook": {...} } -> 200 { "webHook": {...} } + ⚠️ SUBSTITUIÇÃO INTEGRAL: campos omitidos voltam ao + padrão — sem `status`, o hook volta a "Inactive" +PUT /v2/webhooks/{id}/pings -> 204 +DELETE /v2/webhooks/{id} -> 204 +``` + +Event types reais (46 ids ao vivo): `service_invoice.issued_successfully`, `service_invoice.cancelled_error`, `product_invoice.*`, `consumer_invoice.*`, etc. Os `invoice.*` de `AVAILABLE_EVENTS` não existem. + +O contrato correto já está em `openapi/nf-servico-v1.yaml` (e nf-produto-v2/nf-consumidor-v2). Única divergência spec vs. vivo: `contentType`/`status` declarados como enum int (0/1), serializados como string (`"json"`, `"Active"`). + +## Goals / Non-Goals + +**Goals:** +- CRUD de webhooks funcionando contra a API real (account-scoped, envelope nos dois sentidos), em idioma Ruby (snake_case, `Data.define`, keyword args onde couber). +- `Nfe::AccountWebhook` com shape real + RBS; deprecations YARD honestas nos company-scoped. +- Teste de alinhamento YAML↔DTO para impedir novo drift manuscrito. + +**Non-Goals:** +- Remover métodos company-scoped (próxima major). +- Regenerar codegen para incluir webhook paths (o gerado atual não os emite; alinhamento via parse do YAML basta). +- Mexer em `Nfe::Webhook` (assinatura HMAC — correto desde o início). + +## Decisions + +### D1 — Métodos account no MESMO resource, com path v2 explícito +`Nfe::Resources::Webhooks` resolve `:main` com `/v1`. Os métodos account precisam de `/v2/webhooks` no mesmo host — mecânica concreta a validar no apply (path versionado explícito nos helpers get/post/put/delete, ou override de `api_version` por chamada, conforme o `AbstractResource` permitir). Espelha o Node (mesmo resource, client de conta). +*Alternativa rejeitada*: resource separado — quebra a descoberta `client.webhooks.*`. + +### D2 — Envelope tratado no resource +`create_account_webhook`/`update_account_webhook` enviam `{ webHook: data }` (chave camelCase no fio); create/retrieve/update fazem unwrap `payload["webHook"] || payload` (fallback defensivo). Transporte HTTP fica genérico. + +### D3 — DTO novo `Nfe::AccountWebhook`; `WebhookSubscription` intocado e deprecated +```ruby +class AccountWebhook < Data.define( + :id, :uri, :content_type, :secret, :filters, + :insecure_ssl, :headers, :properties, :status, + :created_on, :modified_on +) + # from_api: camelCase -> snake_case, nil-tolerante (padrão dos DTOs do repo) +end +``` +`content_type`/`status` como String (fio real: `"json"`, `"Active"`), não int. `secret` opcional (ecoado só no create). +*Compat*: ninguém usa os métodos atuais com sucesso (404 incondicional) — DTO novo só nos métodos novos, zero quebra. + +### D4 — Event types vivos +`fetch_event_types` (GET `/v2/webhooks/eventTypes`, extrai `id`s, retorna `Array`). `AVAILABLE_EVENTS`/`get_available_events` viram `@deprecated` YARD apontando para ele. + +### D5 — Alinhamento via parse do YAML no RSpec +Teste que carrega `openapi/nf-servico-v1.yaml` (Psych, stdlib) e compara: (a) chave `webHook` no requestBody do POST `/v2/webhooks`; (b) campos do schema ⊆ membros do `Data.define` (via mapa camelCase→snake_case); (c) PINA o enum int de `contentType`/`status` — se o spec for corrigido para string, o teste avisa para remover o desvio. + +### D6 — Fonte de contrato +A cláusula "match the Node SDK 1:1" do spec é requalificada no delta: paridade de **superfície** (nomes/ergonomia), nunca de **contrato** — contrato vem de spec OpenAPI + sonda ao vivo. Comentários "(parity with Node)" junto a valores de contrato são removidos. + +## Risks / Trade-offs + +- [Mecânica do path v2 depende do `AbstractResource`] → Validar no início do apply; ajuste interno pequeno se necessário. +- [PUT integral pode surpreender] → YARD destacado + exemplo partindo do retrieve (espelha Node/PHP). +- [RBS pode divergir do runtime] → `rbs validate`/steep na CI já cobre (verificar no apply). + +## Migration Plan + +1. Implementar DTO + métodos + deprecations + RBS (uma PR); smoke ao vivo opcional reutilizando a sonda do client-nodejs. +2. Release **minor** com CHANGELOG destacando: CRUD company-scoped morto (deprecated), fluxo novo account-scoped. +3. Coordenar com client-nodejs (v5.1.0, já implementado) e client-php (change espelhada) para mensagens consistentes nos três CHANGELOGs. + +## Open Questions + +_(nenhuma — as três sondas do client-nodejs fecharam envelope no GET/{id}, PUT e pings; ver transcript acima)_ diff --git a/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/proposal.md b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/proposal.md new file mode 100644 index 0000000..37281ca --- /dev/null +++ b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/proposal.md @@ -0,0 +1,41 @@ +# Proposal: fix-account-webhooks-contract + +## Why + +O CRUD de webhooks do SDK Ruby está **100% quebrado contra a API real** — o contrato foi herdado por "parity with Node" de um contrato que o SDK Node havia **alucinado** (nunca existiu na API, nos specs, nem no SDK legado). Evidência de 3 sondas ao vivo contra `api.nfe.io` (2026-07-02/03, três contas — transcript completo em `client-nodejs/openspec/changes/fix-account-webhooks-contract/design.md`): + +1. Todas as rotas company-scoped `/v1/companies/{id}/webhooks` retornam **404** (as 6 operações de `Nfe::Resources::Webhooks`). +2. O contrato real é account-scoped: `/v2/webhooks`, com envelope `{ "webHook": {...} }` obrigatório no request de create/update (sem ele: `400 "missing required properties: 'webHook'"`) e respostas também envelopadas. +3. O shape real é `{ id, uri, contentType, secret, filters, insecureSsl, headers, properties, status, createdOn, modifiedOn }` — o `Nfe::WebhookSubscription` atual (`url`/`events`/`active`) é rejeitado (`400 "The Uri field is required"`). +4. Os event types reais são `service_invoice.*`/`product_invoice.*`/`consumer_invoice.*` — os literais `invoice.*` de `AVAILABLE_EVENTS` não existem. +5. `PUT /v2/webhooks/{id}` é **substituição integral**: update sem `status` desativa o webhook (observado ao vivo). + +Detalhe agravante: a change arquivada `add-entity-resources` **sondou `api.nfse.io/v2/webhooks` ao vivo** para confirmar o esquema de assinatura HMAC — o endpoint certo estava documentado no próprio repo e o management foi shipado company-scoped mesmo assim, por paridade. O contrato correto está em `openapi/nf-servico-v1.yaml`, `nf-produto-v2.yaml` e `nf-consumidor-v2.yaml` deste repo. + +## What Changes + +- Adicionar operações account-scoped a `Nfe::Resources::Webhooks`: `list_account_webhooks`, `create_account_webhook`, `retrieve_account_webhook`, `update_account_webhook`, `delete_account_webhook`, `delete_all_account_webhooks` (destrutivo, nome distinto), `ping_account_webhook`, `fetch_event_types` — todas sobre `/v2/webhooks`, com envelope `webHook` no request (create/update) e unwrap das respostas (fallback defensivo). +- Novo value object `Nfe::AccountWebhook` (`Data.define`) com o shape real, mapeando camelCase→snake_case no `from_api`. Nota de contrato: o spec declara `contentType`/`status` como enums inteiros, mas a API serializa strings (`"json"`, `"Active"`) — o DTO segue o fio real. +- Marcar `list/create/retrieve/update/delete/test` company-scoped, `get_available_events` e `Nfe::WebhookSubscription` como `@deprecated` (YARD; rota 404 na API atual; apontar equivalente account). Sem mudança de comportamento; remoção na próxima major. +- Documentar (YARD): create verifica a URI com ping (exige 2xx); `secret` 32–64 chars (ecoado no create, omitido nas leituras); `PUT` é substituição integral (enviar objeto completo, partir do retrieve). +- Teste de alinhamento (RSpec) amarrando os campos do `AccountWebhook` ao schema de `/v2/webhooks` em `openapi/nf-servico-v1.yaml` — sync de spec que mude o contrato quebra a suíte em vez de driftar. +- Atualizar RBS (`sig/nfe/resources/webhooks.rbs` + sig novo do DTO), docs (`docs/webhooks.md` e recurso), samples e a skill do SDK Ruby. +- Revisar a cláusula "SHALL match the Node SDK 1:1" do spec: paridade de superfície continua desejável, mas **contrato vem de spec OpenAPI + sonda ao vivo, nunca de SDK irmão** (foi assim que a alucinação virou 3 SDKs quebrados). + +## Capabilities + +### New Capabilities + +_(nenhuma)_ + +### Modified Capabilities + +- `entity-resources`: o requisito "Webhooks resource with CRUD, test, and available events" é reescrito — (1) webhooks passam a ser account-scoped com envelope `webHook` nos dois sentidos; (2) DTO `Nfe::AccountWebhook` com shape real; (3) métodos company-scoped deprecated (404 em 3 contas); (4) event types vivos via `fetch_event_types`; (5) fonte de contrato passa a ser o spec OpenAPI + sonda ao vivo. + +## Impact + +- **Código**: `lib/nfe/resources/webhooks.rb` (métodos novos + deprecations), `lib/nfe/resources/dto/account_webhook.rb` (novo), `lib/nfe/resources/dto/webhook.rb` (@deprecated), `sig/nfe/**` (RBS). +- **Atenção arquitetural**: o resource resolve `api_family :main` com `/v1` — os métodos account precisam atingir `/v2/webhooks` (decisão de mecânica no design). +- **Testes**: RSpec para envelope nos dois sentidos (fixtures do transcript real), alinhamento YAML↔DTO, testes company-scoped mantidos. +- **SemVer**: minor — métodos novos + deprecations; nada que funciona hoje muda (o CRUD atual já retorna 404 incondicional). +- **Sem quebra**: `Nfe::Webhook` (verificação de assinatura HMAC-SHA1, "nasceu certo") não é tocado. diff --git a/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/specs/entity-resources/spec.md b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/specs/entity-resources/spec.md new file mode 100644 index 0000000..156918e --- /dev/null +++ b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/specs/entity-resources/spec.md @@ -0,0 +1,47 @@ +# entity-resources — Delta (fix-account-webhooks-contract) + +> Contexto: 3 sondas ao vivo contra `api.nfe.io` (2026-07-02/03, 3 contas) provaram que +> `/v1/companies/{id}/webhooks` retorna 404 e que o contrato real é `/v2/webhooks` +> (escopo conta) com envelope `{ "webHook": {...} }` nos dois sentidos. O contrato +> anterior foi herdado por "parity with Node" de uma alucinação. Transcript em `../../design.md`. + +## MODIFIED Requirements + +### Requirement: Webhooks resource with CRUD, test, and available events +`Nfe::Resources::Webhooks` SHALL manage webhooks at the **account** level over `/v2/webhooks`, honoring the live API contract: create/update requests MUST be wrapped in a `webHook` envelope, and enveloped responses MUST be unwrapped before returning. The account-scoped surface is `list_account_webhooks`, `create_account_webhook`, `retrieve_account_webhook`, `update_account_webhook`, `delete_account_webhook`, `delete_all_account_webhooks` (distinctly-named destructive bulk delete), `ping_account_webhook`, and `fetch_event_types` (live event type list). The legacy company-scoped methods (`list`, `create`, `retrieve`, `update`, `delete`, `test`) and the hardcoded `get_available_events` SHALL remain with unchanged behavior but be marked `@deprecated` (YARD) — the `/v1/companies/{id}/webhooks` route returns 404 on the current API (confirmed on three accounts, 2026-07-02/03). Surface parity with the Node SDK remains desirable for names/ergonomics, but the contract source of truth is the OpenAPI spec (`openapi/nf-servico-v1.yaml`) plus live probes — NEVER a sibling SDK. + +#### Scenario: Account webhook create with envelope (live-confirmed) +- **WHEN** `client.webhooks.create_account_webhook({ uri: "...", content_type: "json", secret: <32–64 chars>, filters: ["service_invoice.issued_successfully"] })` is called +- **THEN** the SDK SHALL POST `/v2/webhooks` with body `{"webHook": {...}}` (the API rejects a bare body with `400 "missing required properties: 'webHook'"`) +- **AND** SHALL unwrap the `201 {"webHook": {...}}` response into an `Nfe::AccountWebhook` with `id` populated +- **AND** the YARD doc SHALL state that NFE.io pings the `uri` at creation time and requires a 2xx response + +#### Scenario: List, retrieve, update, delete, ping +- **WHEN** the consumer lists / retrieves / updates / deletes / pings account webhooks +- **THEN** the SDK SHALL issue `GET /v2/webhooks` (unwrapping `{webHooks: [...]}`), `GET /v2/webhooks/{id}`, `PUT /v2/webhooks/{id}` (request wrapped in `webHook`), `DELETE /v2/webhooks/{id}`, and `PUT /v2/webhooks/{id}/pings` +- **AND** single-object responses SHALL be unwrapped from `{webHook: {...}}` with a defensive raw-body fallback +- **AND** the update YARD doc SHALL warn that `PUT` is a full replacement — omitted fields reset to defaults; an update without `status` deactivates the webhook (live-confirmed) + +#### Scenario: Bulk delete is a distinct, dangerous method +- **WHEN** `delete_all_account_webhooks` is exposed (`DELETE /v2/webhooks` removes ALL account webhooks) +- **THEN** it SHALL be a separately-named method unreachable by a mistyped single delete, documented as destructive + +#### Scenario: Live event types +- **WHEN** `client.webhooks.fetch_event_types` is called +- **THEN** the SDK SHALL GET `/v2/webhooks/eventTypes` and return the live `Array` of event ids (pattern `service_invoice.*`/`product_invoice.*`/`consumer_invoice.*` — the legacy `invoice.*` literals do not exist on the live API) +- **AND** `get_available_events` SHALL be `@deprecated` pointing to it + +#### Scenario: Deprecated company-scoped methods stay intact +- **WHEN** the consumer calls `client.webhooks.list(company_id)` (or any company-scoped method) +- **THEN** the request still targets `/companies/{id}/webhooks` unchanged (no behavior change, no runtime warning) +- **AND** the `@deprecated` YARD doc names the account-scoped replacement and the 404 evidence + +## ADDED Requirements + +### Requirement: AccountWebhook value object mirrors the live resource shape +The SDK SHALL expose `Nfe::AccountWebhook` (`Data.define`) with the live fields — `id`, `uri`, `content_type`, `secret` (32–64 chars; echoed on create, omitted on reads), `filters`, `insecure_ssl`, `headers`, `properties`, `status`, `created_on`, `modified_on` — with `from_api` mapping the wire camelCase, used by all account-scoped methods, and covered by RBS signatures. The legacy `Nfe::WebhookSubscription` (`url`/`events`/`active`) SHALL remain and be `@deprecated` (its shape is rejected live with `400 "The Uri field is required"`). Wire-format note: the spec declares `contentType`/`status` as int enums but the API serializes strings (`"json"`, `"Active"`) — the DTO follows the wire. + +#### Scenario: Alignment test pins the contract +- **WHEN** the RSpec suite runs +- **THEN** an alignment spec SHALL parse the `/v2/webhooks` schema from `openapi/nf-servico-v1.yaml` (Psych) and compare its fields against `Nfe::AccountWebhook`'s members (camelCase→snake_case map) +- **AND** SHALL pin the deliberate deviations (`contentType`/`status` string vs int enum) so a corrected spec sync fails the test as a signal to drop the deviation diff --git a/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/tasks.md b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/tasks.md new file mode 100644 index 0000000..caedb12 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-fix-account-webhooks-contract/tasks.md @@ -0,0 +1,38 @@ +# Tasks: fix-account-webhooks-contract (client-ruby) + +## 1. DTO + +- [x] 1.1 Criar `lib/nfe/resources/dto/account_webhook.rb` (`Data.define`: `id`, `uri`, `content_type`, `secret`, `filters`, `insecure_ssl`, `headers`, `properties`, `status`, `created_on`, `modified_on`; `from_api` camelCase→snake_case, nil-tolerante) +- [x] 1.2 Marcar `Nfe::WebhookSubscription` como `@deprecated` (YARD) apontando `Nfe::AccountWebhook` +- [x] 1.3 RBS: `sig/nfe/account_webhook.rbs` (novo) + atualizar `sig/nfe/resources/webhooks.rbs` + +## 2. Resource + +- [x] 2.1 Resolver a mecânica do path v2 no host `main` (D1: path versionado explícito ou override de `api_version` — validar contra `AbstractResource`) +- [x] 2.2 `create_account_webhook`: body `{ webHook: data }`, unwrap `payload["webHook"] || payload`, retorna `AccountWebhook` +- [x] 2.3 `update_account_webhook`: mesmo envelope; YARD destacado do PUT integral (update sem `status` desativa o hook — exemplo partindo do retrieve) +- [x] 2.4 `retrieve_account_webhook` (unwrap + fallback), `list_account_webhooks` (unwrap `webHooks` → `ListResponse`), `delete_account_webhook`, `delete_all_account_webhooks` (nome distinto, doc destrutivo), `ping_account_webhook` (`PUT /{id}/pings`) +- [x] 2.5 `fetch_event_types` (GET `/v2/webhooks/eventTypes`, extrai ids); `get_available_events`/`AVAILABLE_EVENTS` `@deprecated` +- [x] 2.6 Marcar os 6 métodos company-scoped como `@deprecated` (404 em 3 contas, 2026-07-02/03; apontar equivalente account); comportamento inalterado +- [x] 2.7 YARD do create: ping de verificação da URI (exige 2xx) e `secret` 32–64 chars +- [x] 2.8 Remover comentários "(parity with Node)" junto a valores de contrato (fonte: spec OpenAPI + sonda ao vivo) + +## 3. Alinhamento com o spec + +- [x] 3.1 Spec RSpec de alinhamento: parse (Psych) do schema `/v2/webhooks` em `openapi/nf-servico-v1.yaml` vs membros do `AccountWebhook` +- [x] 3.2 Pinar no teste os desvios deliberados (`contentType`/`status` string no fio vs enum int no spec) + +## 4. Testes + +- [x] 4.1 Unit: create envia envelope e desembrulha 201 (fixture do transcript real das sondas) +- [x] 4.2 Unit: update envelopado + retrieve/list unwrap + fallback corpo cru +- [x] 4.3 Unit: `delete_all_account_webhooks` é método distinto do delete single +- [x] 4.4 Manter testes company-scoped (comportamento deprecated inalterado) +- [x] 4.5 `rbs validate`/steep limpos com os sigs novos +- [x] 4.6 (Opcional) Smoke ao vivo reutilizando a sonda do client-nodejs + +## 5. Docs & release + +- [x] 5.1 Atualizar `docs/webhooks.md` e o cookbook do recurso (fluxo account, eventos reais, callouts: ping 2xx na criação, PUT integral) +- [x] 5.2 Atualizar samples e a skill do SDK Ruby (nfeio-ruby-sdk) se citarem o fluxo company-scoped ou `invoice.*` +- [x] 5.3 CHANGELOG (minor): fix do CRUD de webhooks, deprecations, DTO novo — mensagens consistentes com client-nodejs v5.1.0 e client-php diff --git a/openspec/specs/entity-resources/spec.md b/openspec/specs/entity-resources/spec.md index ffdeddd..703c29e 100644 --- a/openspec/specs/entity-resources/spec.md +++ b/openspec/specs/entity-resources/spec.md @@ -151,19 +151,41 @@ The certificate password and PKCS#12 (.pfx/.p12) bytes SHALL be handled in-memor - **THEN** the method SHALL return `nil` ### Requirement: Webhooks resource with CRUD, test, and available events -`Nfe::Resources::Webhooks` SHALL expose `list`, `create`, `retrieve`, `update`, `delete`, `test`, and `get_available_events`, all company-scoped under `/companies/{id}/webhooks`. The `test` method triggers a synthetic delivery. `get_available_events` returns a static list matching the Node SDK's hard-coded events. - -#### Scenario: Webhook subscription CRUD -- **WHEN** `client.webhooks.create(company_id, { url: "https://example.com/hook", events: ["invoice.issued"] })` is called -- **THEN** the API SHALL persist the subscription and the method SHALL return a `Nfe::Webhook` with `id`, `url`, `events`, and `secret` populated - -#### Scenario: Test webhook delivery -- **WHEN** `client.webhooks.test(company_id, webhook_id)` is called -- **THEN** the SDK SHALL POST `/companies/{id}/webhooks/{webhook_id}/test` and return `{ success: , message: }` - -#### Scenario: Available events list -- **WHEN** `client.webhooks.get_available_events` is called -- **THEN** the method SHALL return a static `Array` of exactly: `invoice.issued`, `invoice.cancelled`, `invoice.failed`, `invoice.processing`, `company.created`, `company.updated`, `company.deleted` +`Nfe::Resources::Webhooks` SHALL manage webhooks at the **account** level over `/v2/webhooks`, honoring the live API contract: create/update requests MUST be wrapped in a `webHook` envelope, and enveloped responses MUST be unwrapped before returning. The account-scoped surface is `list_account_webhooks`, `create_account_webhook`, `retrieve_account_webhook`, `update_account_webhook`, `delete_account_webhook`, `delete_all_account_webhooks` (distinctly-named destructive bulk delete), `ping_account_webhook`, and `fetch_event_types` (live event type list). The legacy company-scoped methods (`list`, `create`, `retrieve`, `update`, `delete`, `test`) and the hardcoded `get_available_events` SHALL remain with unchanged behavior but be marked `@deprecated` (YARD) — the `/v1/companies/{id}/webhooks` route returns 404 on the current API (confirmed on three accounts, 2026-07-02/03). Surface parity with the Node SDK remains desirable for names/ergonomics, but the contract source of truth is the OpenAPI spec (`openapi/nf-servico-v1.yaml`) plus live probes — NEVER a sibling SDK. + +#### Scenario: Account webhook create with envelope (live-confirmed) +- **WHEN** `client.webhooks.create_account_webhook({ uri: "...", content_type: "json", secret: <32–64 chars>, filters: ["service_invoice.issued_successfully"] })` is called +- **THEN** the SDK SHALL POST `/v2/webhooks` with body `{"webHook": {...}}` (the API rejects a bare body with `400 "missing required properties: 'webHook'"`) +- **AND** SHALL unwrap the `201 {"webHook": {...}}` response into an `Nfe::AccountWebhook` with `id` populated +- **AND** the YARD doc SHALL state that NFE.io pings the `uri` at creation time and requires a 2xx response + +#### Scenario: List, retrieve, update, delete, ping +- **WHEN** the consumer lists / retrieves / updates / deletes / pings account webhooks +- **THEN** the SDK SHALL issue `GET /v2/webhooks` (unwrapping `{webHooks: [...]}`), `GET /v2/webhooks/{id}`, `PUT /v2/webhooks/{id}` (request wrapped in `webHook`), `DELETE /v2/webhooks/{id}`, and `PUT /v2/webhooks/{id}/pings` +- **AND** single-object responses SHALL be unwrapped from `{webHook: {...}}` with a defensive raw-body fallback +- **AND** the update YARD doc SHALL warn that `PUT` is a full replacement — omitted fields reset to defaults; an update without `status` deactivates the webhook (live-confirmed) + +#### Scenario: Bulk delete is a distinct, dangerous method +- **WHEN** `delete_all_account_webhooks` is exposed (`DELETE /v2/webhooks` removes ALL account webhooks) +- **THEN** it SHALL be a separately-named method unreachable by a mistyped single delete, documented as destructive + +#### Scenario: Live event types +- **WHEN** `client.webhooks.fetch_event_types` is called +- **THEN** the SDK SHALL GET `/v2/webhooks/eventTypes` and return the live `Array` of event ids (pattern `service_invoice.*`/`product_invoice.*`/`consumer_invoice.*` — the legacy `invoice.*` literals do not exist on the live API) +- **AND** `get_available_events` SHALL be `@deprecated` pointing to it + +#### Scenario: Deprecated company-scoped methods stay intact +- **WHEN** the consumer calls `client.webhooks.list(company_id)` (or any company-scoped method) +- **THEN** the request still targets `/companies/{id}/webhooks` unchanged (no behavior change, no runtime warning) +- **AND** the `@deprecated` YARD doc names the account-scoped replacement and the 404 evidence + +### Requirement: AccountWebhook value object mirrors the live resource shape +The SDK SHALL expose `Nfe::AccountWebhook` (`Data.define`) with the live fields — `id`, `uri`, `content_type`, `secret` (32–64 chars; echoed on create, omitted on reads), `filters`, `insecure_ssl`, `headers`, `properties`, `status`, `created_on`, `modified_on` — with `from_api` mapping the wire camelCase, used by all account-scoped methods, and covered by RBS signatures. The legacy `Nfe::WebhookSubscription` (`url`/`events`/`active`) SHALL remain and be `@deprecated` (its shape is rejected live with `400 "The Uri field is required"`). Wire-format note: the spec declares `contentType`/`status` as int enums but the API serializes strings (`"json"`, `"Active"`) — the DTO follows the wire. + +#### Scenario: Alignment test pins the contract +- **WHEN** the RSpec suite runs +- **THEN** an alignment spec SHALL parse the `/v2/webhooks` schema from `openapi/nf-servico-v1.yaml` (Psych) and compare its fields against `Nfe::AccountWebhook`'s members (camelCase→snake_case map) +- **AND** SHALL pin the deliberate deviations (`contentType`/`status` string vs int enum) so a corrected spec sync fails the test as a signal to drop the deviation ### Requirement: ID validators run before HTTP Every entity resource method that takes an identifier (`company_id`, `legal_person_id`, `natural_person_id`, `webhook_id`) SHALL validate it through the shared ID validators from `add-client-core` before issuing the HTTP request, failing fast with a Portuguese-language message. diff --git a/samples/webhook_verify.rb b/samples/webhook_verify.rb index 7ee1c8e..8d49a02 100644 --- a/samples/webhook_verify.rb +++ b/samples/webhook_verify.rb @@ -33,7 +33,7 @@ raw_payload = JSON.generate( { "id" => "evt_123", - "action" => "invoice.issued", + "action" => "service_invoice.issued_successfully", "payload" => { "id" => "inv_456", "flowStatus" => "Issued" } } ) diff --git a/sig/nfe/resources/dto/account_webhook.rbs b/sig/nfe/resources/dto/account_webhook.rbs new file mode 100644 index 0000000..8f25a4b --- /dev/null +++ b/sig/nfe/resources/dto/account_webhook.rbs @@ -0,0 +1,21 @@ +module Nfe + class AccountWebhook < Data + attr_reader id: String? + attr_reader uri: String? + attr_reader content_type: String? + attr_reader secret: String? + attr_reader filters: Array[String]? + attr_reader insecure_ssl: bool? + attr_reader headers: untyped + attr_reader properties: untyped + attr_reader status: String? + attr_reader created_on: String? + attr_reader modified_on: String? + + def self.from_api: (Hash[untyped, untyped]? payload) -> Nfe::AccountWebhook? + + def self.new: (id: String?, uri: String?, content_type: String?, secret: String?, filters: Array[String]?, insecure_ssl: bool?, headers: untyped, properties: untyped, status: String?, created_on: String?, modified_on: String?) -> instance + + def initialize: (id: String?, uri: String?, content_type: String?, secret: String?, filters: Array[String]?, insecure_ssl: bool?, headers: untyped, properties: untyped, status: String?, created_on: String?, modified_on: String?) -> void + end +end diff --git a/sig/nfe/resources/webhooks.rbs b/sig/nfe/resources/webhooks.rbs index 0acbd3c..6d0dd4d 100644 --- a/sig/nfe/resources/webhooks.rbs +++ b/sig/nfe/resources/webhooks.rbs @@ -4,6 +4,16 @@ module Nfe AVAILABLE_EVENTS: Array[String] def api_family: () -> Symbol + def full_path: (String path) -> String + + def list_account_webhooks: () -> Nfe::ListResponse + def create_account_webhook: (Hash[untyped, untyped] data) -> Nfe::AccountWebhook? + def retrieve_account_webhook: (String webhook_id) -> Nfe::AccountWebhook? + def update_account_webhook: (String webhook_id, Hash[untyped, untyped] data) -> Nfe::AccountWebhook? + def delete_account_webhook: (String webhook_id) -> nil + def delete_all_account_webhooks: () -> nil + def ping_account_webhook: (String webhook_id) -> nil + def fetch_event_types: () -> Array[String] def list: (String company_id) -> Nfe::ListResponse def create: (String company_id, Hash[untyped, untyped] data) -> Nfe::WebhookSubscription? @@ -16,6 +26,8 @@ module Nfe private + def http_delete: (String path, ?query: Hash[untyped, untyped], ?request_options: Nfe::RequestOptions?, ?headers: Hash[String, String]) -> Nfe::Http::Response + def hydrate_account_webhook: (Nfe::Http::Response response) -> Nfe::AccountWebhook? def json_headers: () -> Hash[String, String] def json_body: (untyped data) -> String def webhook_items: (untyped payload) -> Array[untyped] diff --git a/skills/nfeio-ruby-sdk/SKILL.md b/skills/nfeio-ruby-sdk/SKILL.md index 497346e..5caff8e 100644 --- a/skills/nfeio-ruby-sdk/SKILL.md +++ b/skills/nfeio-ruby-sdk/SKILL.md @@ -106,7 +106,7 @@ client.product_invoices.list(company_id: id, environment: "Test") # String! | `companies` | api.nfe.io `/v1` | empresas + certificado A1 | create, list, retrieve, update, **remove**, find_by_*, upload_certificate, get_certificate_status | | `legal_people` | api.nfe.io `/v1` | PJ (tomadores) | list, create, retrieve, update, delete, create_batch, find_by_tax_number | | `natural_people` | api.nfe.io `/v1` | PF (tomadores) | list, create, retrieve, update, delete, create_batch, find_by_tax_number | -| `webhooks` | api.nfe.io `/v1` | assinaturas de webhook | list, create, retrieve, update, delete, test, get_available_events | +| `webhooks` | api.nfe.io `/v2` (conta) | webhooks da conta | create_account_webhook, list_account_webhooks, retrieve_account_webhook, update_account_webhook, delete_account_webhook, ping_account_webhook, fetch_event_types (company-scoped list/create/... deprecated: rota 404) | | `addresses` | address.api.nfe.io `/v2` | consulta de CEP | lookup_by_postal_code, search, lookup_by_term | | `legal_entity_lookup` | legalentity.api.nfe.io | consulta CNPJ | get_basic_info, get_state_tax_info, get_state_tax_for_invoice, get_suggested_state_tax_for_invoice | | `natural_person_lookup` | naturalperson.api.nfe.io | consulta CPF | get_status(cpf, birth_date) | @@ -260,7 +260,14 @@ end ## Pitfalls idiomáticos - `companies` apaga com `#remove(company_id)` (retorna `{ deleted:, id: }`), **não** - `#delete`. Já `legal_people`/`natural_people`/`state_taxes`/`webhooks` usam `#delete`. + `#delete`. Já `legal_people`/`natural_people`/`state_taxes` usam `#delete`; `webhooks` + usa `#delete_account_webhook(id)` (e `#delete_all_account_webhooks` apaga TODOS — destrutivo). +- Webhooks são **account-scoped** (`/v2/webhooks`): o create pinga a `uri` e exige 2xx + (endpoint no ar antes); `secret` 32–64 chars (ecoado só no create); o + `update_account_webhook` é **PUT integral** — sem `status`, o hook é desativado + (parta do retrieve). Filtros válidos via `fetch_event_types` + (`service_invoice.*`/`product_invoice.*`/`consumer_invoice.*`; os literais + `invoice.*` de `get_available_events` estão deprecated e não existem na API). - Recursos de emissão usam **keyword args**; entidades/lookups usam **posicionais** (ver nota no Mapa de recursos). - Objetos de valor são `Data.define` **imutáveis** (Pending/Issued, ServiceInvoice, @@ -290,6 +297,9 @@ end `addresses` → `references/data-services-and-lookups.md`. - **Calcular impostos** → `tax_calculation.calculate(tenant_id, request)`. - **Verificar webhook** → `Nfe::Webhook.verify_signature(...)`. +- **Criar/gerenciar webhooks da conta** → `webhooks.create_account_webhook(...)` + (envelope `webHook` automático; filtros via `fetch_event_types`) → + `references/error-handling-and-patterns.md`. - **Tratar erros / retry / multi-tenant** → `references/error-handling-and-patterns.md`. ## Referências diff --git a/skills/nfeio-ruby-sdk/references/error-handling-and-patterns.md b/skills/nfeio-ruby-sdk/references/error-handling-and-patterns.md index 42619f5..3e9ba98 100644 --- a/skills/nfeio-ruby-sdk/references/error-handling-and-patterns.md +++ b/skills/nfeio-ruby-sdk/references/error-handling-and-patterns.md @@ -127,9 +127,16 @@ Pontos-chave: - **Validade ≠ frescor:** não há timestamp/nonce anti-replay. Assinatura válida prova autenticidade, **não** frescor → handler DEVE ser **idempotente** e **deduplicar** por `event.id` (ou id da nota). -- Eventos disponíveis (`client.webhooks.get_available_events`): `invoice.issued`, - `invoice.cancelled`, `invoice.failed`, `invoice.processing`, `company.created`, - `company.updated`, `company.deleted`. +- Tipos de evento **ao vivo** (`client.webhooks.fetch_event_types`): padrão + `service_invoice.*`/`product_invoice.*`/`consumer_invoice.*` (46 ids). Os + literais `invoice.*` do deprecated `get_available_events` **não existem** na API. +- Gerenciamento é **account-scoped** (`/v2/webhooks`, envelope `webHook` + automático): `create_account_webhook` (a NFE.io pinga a `uri` e exige 2xx; + `secret` 32–64 chars, ecoado só no create), `update_account_webhook` (**PUT + integral** — sem `status` o hook é desativado; parta do retrieve), + `delete_all_account_webhooks` (destrutivo, apaga TODOS). Os métodos + company-scoped (`list`/`create`/... com `company_id`) estão deprecated — + rota `/v1/companies/{id}/webhooks` retorna 404. - Esquema legado `X-NFe-Signature`/SHA-256/Base64 **não** é suportado. ## Concorrência diff --git a/skills/nfeio-ruby-sdk/references/product-invoices-and-taxes.md b/skills/nfeio-ruby-sdk/references/product-invoices-and-taxes.md index 5558de1..5205491 100644 --- a/skills/nfeio-ruby-sdk/references/product-invoices-and-taxes.md +++ b/skills/nfeio-ruby-sdk/references/product-invoices-and-taxes.md @@ -28,7 +28,8 @@ client.product_invoices.download_correction_letter_xml(company_id:, invoice_id:) `create`/`create_with_state_tax` retornam `ProductInvoicePending` (202, comum — conclusão chega por **webhook**) ou `ProductInvoiceIssued` (201). Faça polling -com `retrieve` + `Nfe::FlowStatus.terminal?`, ou trate o webhook `invoice.issued`. +com `retrieve` + `Nfe::FlowStatus.terminal?`, ou trate o webhook +`product_invoice.issued` (filtros reais via `webhooks.fetch_event_types`). ```ruby res = client.product_invoices.create(company_id: id, data: nfe_payload) diff --git a/skills/nfeio-ruby-sdk/references/rtc-emission.md b/skills/nfeio-ruby-sdk/references/rtc-emission.md index c59287f..284f06d 100644 --- a/skills/nfeio-ruby-sdk/references/rtc-emission.md +++ b/skills/nfeio-ruby-sdk/references/rtc-emission.md @@ -129,5 +129,6 @@ end ``` Para NF-e (mod 55) a conclusão normalmente chega por **webhook** — combine -`invoice.issued`/`invoice.failed` com o polling acima como fallback. CC-e segue a +`product_invoice.issued`/`product_invoice.issued_error` (filtros reais via +`webhooks.fetch_event_types`) com o polling acima como fallback. CC-e segue a regra de **15..1000 caracteres**, validada client-side. diff --git a/spec/nfe/resources/dto/account_webhook_alignment_spec.rb b/spec/nfe/resources/dto/account_webhook_alignment_spec.rb new file mode 100644 index 0000000..e76a701 --- /dev/null +++ b/spec/nfe/resources/dto/account_webhook_alignment_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "yaml" + +# Alignment test pinning Nfe::AccountWebhook to the /v2/webhooks contract in +# the official OpenAPI spec (openapi/nf-servico-v1.yaml). A spec sync that +# changes the webhook contract fails this suite instead of silently drifting — +# the contract source of truth is the OpenAPI spec plus live probes, never a +# sibling SDK. +RSpec.describe Nfe::AccountWebhook do + let(:openapi) do + YAML.safe_load_file(File.expand_path("../../../../openapi/nf-servico-v1.yaml", __dir__)) + end + + let(:item_schema) do + openapi.dig( + "paths", "/v2/webhooks", "get", "responses", "200", "content", + "application/json", "schema", "properties", "webHooks", "items" + ) + end + + let(:request_schema) do + openapi.dig( + "paths", "/v2/webhooks", "post", "requestBody", "content", + "application/json", "schema" + ) + end + + def snake_case(camel) + camel.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" } + end + + it "wraps the create request body in a webHook envelope" do + expect(request_schema["properties"].keys).to eq(["webHook"]) + end + + it "covers every field of the /v2/webhooks item schema" do + spec_fields = item_schema["properties"].keys.map { |key| snake_case(key) }.sort + members = described_class.members.map(&:to_s).sort + + expect(spec_fields - members).to be_empty, + "spec fields missing from the DTO: #{(spec_fields - members).join(', ')}" + expect(members - spec_fields).to be_empty, + "DTO members absent from the spec: #{(members - spec_fields).join(', ')}" + end + + # Deliberate deviation, pinned: the spec declares contentType/status as int + # enums (0/1), but the live API serializes strings ("json", "Active") — the + # DTO follows the wire. If a spec sync turns these into strings, this test + # fails as the signal to drop the deviation note from the DTO docs. + it "pins the contentType/status int-enum deviation (wire serializes strings)" do + %w[contentType status].each do |field| + schema = item_schema["properties"].fetch(field) + expect(schema["type"]).to eq("integer"), + "#{field} is no longer an int enum in the spec — " \ + "drop the wire-deviation pin and revisit the DTO docs" + expect(schema["enum"]).to eq([0, 1]) + end + end +end diff --git a/spec/nfe/resources/dto/account_webhook_spec.rb b/spec/nfe/resources/dto/account_webhook_spec.rb new file mode 100644 index 0000000..5ca5d0f --- /dev/null +++ b/spec/nfe/resources/dto/account_webhook_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.describe Nfe::AccountWebhook do + describe ".from_api" do + let(:hook) do + described_class.from_api( + "id" => "5f3a0b1c-2d4e-4f6a-8b9c-0d1e2f3a4b5c", + "uri" => "https://example.com/hook", + "contentType" => "json", + "secret" => "0123456789abcdef0123456789abcdef", + "filters" => ["service_invoice.issued_successfully"], + "insecureSsl" => false, + "headers" => { "X-Custom" => "1" }, + "properties" => { "env" => "prod" }, + "status" => "Active", + "createdOn" => "2026-07-02T18:00:00Z", + "modifiedOn" => "2026-07-03T09:00:00Z" + ) + end + + it "maps the identity and delivery fields" do + expect(hook.id).to eq("5f3a0b1c-2d4e-4f6a-8b9c-0d1e2f3a4b5c") + expect(hook.uri).to eq("https://example.com/hook") + expect(hook.content_type).to eq("json") + expect(hook.secret).to eq("0123456789abcdef0123456789abcdef") + expect(hook.filters).to eq(["service_invoice.issued_successfully"]) + expect(hook.insecure_ssl).to be(false) + end + + it "maps the metadata and audit fields" do + expect(hook.headers).to eq({ "X-Custom" => "1" }) + expect(hook.properties).to eq({ "env" => "prod" }) + expect(hook.status).to eq("Active") + expect(hook.created_on).to eq("2026-07-02T18:00:00Z") + expect(hook.modified_on).to eq("2026-07-03T09:00:00Z") + end + + it "drops unknown keys and tolerates missing fields (secret omitted on reads)" do + hook = described_class.from_api("id" => "wh_2", "mystery" => 1) + expect(hook.id).to eq("wh_2") + expect(hook.uri).to be_nil + expect(hook.secret).to be_nil + end + + it "returns nil for a nil payload" do + expect(described_class.from_api(nil)).to be_nil + end + + it "produces an immutable value object" do + expect(described_class.from_api("id" => "wh_3")).to be_frozen + end + end +end diff --git a/spec/nfe/resources/webhooks_spec.rb b/spec/nfe/resources/webhooks_spec.rb index e7ff558..78edcec 100644 --- a/spec/nfe/resources/webhooks_spec.rb +++ b/spec/nfe/resources/webhooks_spec.rb @@ -8,6 +8,23 @@ let(:client) { Nfe::Client.new(api_key: "key") } let(:transport) { FakeTransport.new } + # Wire fixture taken from the live probe transcript (2026-07-02/03): the API + # envelopes single objects as {"webHook": {...}} and serializes + # contentType/status as strings ("json", "Active"). + let(:wire_webhook) do + { + "id" => "5f3a0b1c-2d4e-4f6a-8b9c-0d1e2f3a4b5c", + "uri" => "https://example.com/hook", + "contentType" => "json", + "secret" => "0123456789abcdef0123456789abcdef", + "insecureSsl" => false, + "status" => "Active", + "filters" => ["service_invoice.issued_successfully"], + "createdOn" => "2026-07-02T18:00:00Z", + "modifiedOn" => "2026-07-02T18:00:00Z" + } + end + before { allow(client).to receive(:build_transport).and_return(transport) } def json(status: 200, body: "{}") @@ -18,6 +35,138 @@ def last_request transport.requests.last end + describe "#create_account_webhook" do + it "POSTs /v2/webhooks with the webHook envelope and unwraps the 201" do + transport.enqueue(json(status: 201, body: { "webHook" => wire_webhook }.to_json)) + + hook = webhooks.create_account_webhook( + uri: "https://example.com/hook", + contentType: "json", + secret: "0123456789abcdef0123456789abcdef", + filters: ["service_invoice.issued_successfully"] + ) + + expect(last_request.method).to eq("POST") + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks") + sent = JSON.parse(last_request.body) + expect(sent.keys).to eq(["webHook"]) + expect(sent["webHook"]["uri"]).to eq("https://example.com/hook") + + expect(hook).to be_a(Nfe::AccountWebhook) + expect(hook.id).to eq("5f3a0b1c-2d4e-4f6a-8b9c-0d1e2f3a4b5c") + expect(hook.content_type).to eq("json") + expect(hook.secret).to eq("0123456789abcdef0123456789abcdef") + expect(hook.status).to eq("Active") + end + end + + describe "#retrieve_account_webhook" do + it "GETs /v2/webhooks/{id} and unwraps the webHook envelope" do + transport.enqueue(json(body: { "webHook" => wire_webhook }.to_json)) + + hook = webhooks.retrieve_account_webhook("wh-1") + + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks/wh-1") + expect(hook.uri).to eq("https://example.com/hook") + expect(hook.filters).to eq(["service_invoice.issued_successfully"]) + end + + it "falls back to a raw (unenveloped) body" do + transport.enqueue(json(body: wire_webhook.to_json)) + + hook = webhooks.retrieve_account_webhook("wh-1") + + expect(hook).to be_a(Nfe::AccountWebhook) + expect(hook.id).to eq("5f3a0b1c-2d4e-4f6a-8b9c-0d1e2f3a4b5c") + end + + it "rejects an empty webhook_id without HTTP" do + expect { webhooks.retrieve_account_webhook("") }.to raise_error(Nfe::InvalidRequestError) + expect(transport.requests).to be_empty + end + end + + describe "#update_account_webhook" do + it "PUTs the webHook envelope and unwraps the response" do + transport.enqueue(json(body: { "webHook" => wire_webhook.merge("status" => "Inactive") }.to_json)) + + hook = webhooks.update_account_webhook("wh-1", uri: "https://example.com/hook") + + expect(last_request.method).to eq("PUT") + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks/wh-1") + expect(JSON.parse(last_request.body).keys).to eq(["webHook"]) + expect(hook.status).to eq("Inactive") + end + end + + describe "#list_account_webhooks" do + it "GETs /v2/webhooks and unwraps the webHooks envelope into a ListResponse" do + transport.enqueue(json(body: { "webHooks" => [wire_webhook] }.to_json)) + + result = webhooks.list_account_webhooks + + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks") + expect(result).to be_a(Nfe::ListResponse) + expect(result.data.first).to be_a(Nfe::AccountWebhook) + expect(result.data.first.uri).to eq("https://example.com/hook") + end + end + + describe "#delete_account_webhook / #delete_all_account_webhooks" do + it "DELETEs a single webhook by id and returns nil" do + transport.enqueue(json(status: 204, body: "")) + + expect(webhooks.delete_account_webhook("wh-1")).to be_nil + expect(last_request.method).to eq("DELETE") + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks/wh-1") + end + + it "exposes the destructive bulk delete as a distinct zero-arg method" do + transport.enqueue(json(status: 204, body: "")) + + expect(webhooks.method(:delete_all_account_webhooks).arity).to eq(0) + expect(webhooks.delete_all_account_webhooks).to be_nil + expect(last_request.method).to eq("DELETE") + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks") + end + + it "keeps the single delete unreachable without an id" do + expect { webhooks.delete_account_webhook("") }.to raise_error(Nfe::InvalidRequestError) + expect(transport.requests).to be_empty + end + end + + describe "#ping_account_webhook" do + it "PUTs /v2/webhooks/{id}/pings and returns nil" do + transport.enqueue(json(status: 204, body: "")) + + expect(webhooks.ping_account_webhook("wh-1")).to be_nil + expect(last_request.method).to eq("PUT") + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks/wh-1/pings") + end + end + + describe "#fetch_event_types" do + it "GETs /v2/webhooks/eventTypes and extracts the ids" do + transport.enqueue(json(body: { + "eventTypes" => [ + { "id" => "service_invoice.issued_successfully", "description" => "..." }, + { "id" => "product_invoice.issued" } + ] + }.to_json)) + + events = webhooks.fetch_event_types + + expect(last_request.url).to eq("https://api.nfe.io/v2/webhooks/eventTypes") + expect(events).to eq(%w[service_invoice.issued_successfully product_invoice.issued]) + end + + it "returns an empty array for an empty body" do + transport.enqueue(json(body: "")) + expect(webhooks.fetch_event_types).to eq([]) + end + end + describe "#list" do it "lists company-scoped webhooks" do transport.enqueue(json(body: { "data" => [{ "id" => "wh1" }] }.to_json))