diff --git a/CHANGELOG.md b/CHANGELOG.md index db3e59e..d98745f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ 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. +## [1.1.0] - 2026-07-09 + +> Duas correções de contrato contra a API real: o CRUD de webhooks (provado por +> sonda ao vivo, 2026-07-02/03, três contas) e a cobertura do retrieve de NFS-e +> (o DTO descartava mais da metade dos campos). Em ambas, o contrato correto +> sempre esteve nos specs oficiais (`openapi/nf-servico-v1.yaml` e equivalentes) — +> o código manuscrito havia divergido deles. ### Corrigido @@ -22,6 +25,11 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). 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. +- **`Nfe::ServiceInvoice` descartava ~25 dos 44 campos do retrieve de NFS-e** + (toda a árvore de retenções, `provider`, `taxationType`, `location`, + `approximateTax`, ...): o `from_api` agora preserva o payload completo em + `invoice.raw` (padrão do `ConsumerInvoice`), em todas as leituras + (list/retrieve/cancel/201-issued). ### Adicionado @@ -48,6 +56,22 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). 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). +- Campos de ISS tipados em `Nfe::ServiceInvoice`: `base_tax_amount`, + `iss_rate`, `iss_tax_amount`. +- Value object **`Nfe::ServiceInvoiceBorrower`** (tomador, com RBS): + `federal_tax_number` sempre `String` (tolerante ao CNPJ alfanumérico da + IN RFB 2.229/2024, fio Integer ou String) e **ponte Hash** — leituras + `borrower["..."]`/`borrower.dig(...)` continuam funcionando (delegam ao + payload cru), agora ao lado dos leitores tipados. +- Teste de alinhamento (RSpec + Psych) amarrando `Nfe::ServiceInvoice` ao + schema inline do retrieve em `openapi/nf-servico-v1.yaml`, **ancorado por + path** (há colisão de `operationId` no spec) — também serve de gatilho de + migração: quando a resposta for componentizada upstream, o teste falha e + sinaliza migrar para o modelo gerado. +- Spec `nf-servico-v1.yaml` atualizado (respostas de erro tipadas com + `ErrorsResource` em `components.schemas`) + namespace gerado + `Nfe::Generated::NfServicoV1` (somente o modelo de erros; a resposta de + sucesso segue inline e o DTO manuscrito). ### Deprecado @@ -59,6 +83,9 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). `get_available_events`/`AVAILABLE_EVENTS` (literais `invoice.*`): shapes e eventos que a API real rejeita ou desconhece. Use `Nfe::AccountWebhook` e `fetch_event_types`. +- `Nfe::ServiceInvoice#pdf` e `#xml`: campos-fantasma — a resposta do retrieve + não os traz (sempre `nil`). Use `download_pdf`/`download_xml`. Remoção na + próxima major. ## [1.0.0] - 2026-07-02 @@ -108,6 +135,7 @@ e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). - Última versão da série `0.x` (legada, baseada em `rest-client`). Congelada, sem manutenção. -[Não lançado]: https://github.com/nfe/client-ruby/compare/v1.0.0...HEAD +[Não lançado]: https://github.com/nfe/client-ruby/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/nfe/client-ruby/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/nfe/client-ruby/compare/v0.3.2...v1.0.0 [0.3.2]: https://github.com/nfe/client-ruby/releases/tag/v0.3.2 diff --git a/README.md b/README.md index 1116ee9..a71befa 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ discriminado**: `pending?` ⇒ `true`, `issued?` ⇒ `false`. - `*Issued` (HTTP 201, já materializado) — expõe `resource`; `issued?` ⇒ `true`. -Não há `create_and_wait` nem `create_batch` na v1.0 — faça **polling** chamando +Não há `create_and_wait` nem `create_batch` na v1.x — faça **polling** chamando `retrieve` até um estado terminal, usando `Nfe::FlowStatus.terminal?`. Os estados terminais são: `Issued`, `IssueFailed`, `Cancelled`, `CancelFailed`. diff --git a/docs/async-and-polling.md b/docs/async-and-polling.md index a168769..5b46748 100644 --- a/docs/async-and-polling.md +++ b/docs/async-and-polling.md @@ -116,7 +116,7 @@ HTTP extra), como atalho para inspecionar o estado atual. ## Por que não existe `create_and_wait`? -A `v1.0` **não** implementa `create_and_wait`, `create_batch` nem +A `v1.x` **não** implementa `create_and_wait`, `create_batch` nem `poll_until_complete`. O contrato discriminado `*Pending` / `*Issued` somado a `Nfe::FlowStatus.terminal?` é suficiente para escrever loops de polling manuais, e esses auxiliares ficam deliberadamente adiados para uma versão futura — sem @@ -124,7 +124,7 @@ quebrar o contrato público. :::warning Não chame helpers inexistentes Referenciar `client.poll_until_complete(...)` ou `resource.create_and_wait(...)` -na `v1.0` levanta `NoMethodError`, pois o método não está definido. +na `v1.x` levanta `NoMethodError`, pois o método não está definido. ::: ## Próximos passos diff --git a/docs/getting-started.md b/docs/getting-started.md index e05fdd7..169d603 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -76,7 +76,7 @@ end ## 5. Acompanhe até um estado terminal (polling) -Não existe `create_and_wait` na `v1.0` — faça polling com `retrieve` até um +Não existe `create_and_wait` na `v1.x` — faça polling com `retrieve` até um estado terminal, usando `Nfe::FlowStatus.terminal?`: ```ruby diff --git a/docs/recursos/service-invoices.md b/docs/recursos/service-invoices.md index 2b52e67..be817eb 100644 --- a/docs/recursos/service-invoices.md +++ b/docs/recursos/service-invoices.md @@ -15,7 +15,7 @@ e-mail e download de PDF/XML. A emissão segue o **contrato 202 discriminado**: `create` devolve um de dois tipos, e você acompanha o processamento com `retrieve` (ou `get_status`) até um -estado terminal. Não existe `create_and_wait` nem `create_batch` na `v1.0`. +estado terminal. Não existe `create_and_wait` nem `create_batch` na `v1.x`. :::note Host e versão Todas as URLs efetivas ficam sob `https://api.nfe.io/v1/...`. Este é o único @@ -99,6 +99,46 @@ end Como alternativa, você pode chamar `retrieve` diretamente e testar `Nfe::FlowStatus.terminal?(invoice.flow_status)`. +## Ler os campos da nota (tipados + `raw`) + +`Nfe::ServiceInvoice` tipa os campos de maior valor — incluindo o trio de ISS +`base_tax_amount`, `iss_rate` e `iss_tax_amount` — e preserva **o payload +completo** da API em `invoice.raw`. Campos sem membro tipado (a árvore de +retenções, `provider`, `taxationType`, `location`, `approximateTax`, ...) +ficam acessíveis por ali: + +```ruby +invoice = client.service_invoices.retrieve( + company_id: company_id, + invoice_id: invoice_id +) + +invoice.number # número fiscal +invoice.iss_rate # 0.05 +invoice.iss_tax_amount # 50.0 + +invoice.raw["taxationType"] # "WithinCity" +invoice.raw["issAmountWithheld"] # retenção de ISS +invoice.raw.dig("provider", "name") # prestador (21 campos via raw) +``` + +O `borrower` (tomador) é um `Nfe::ServiceInvoiceBorrower` tipado, com +`federal_tax_number` sempre `String` (tolerante ao CNPJ alfanumérico da +IN RFB 2.229/2024). Leituras estilo Hash continuam funcionando (delegam ao +payload cru, chaves camelCase): + +```ruby +invoice.borrower.name # tipado +invoice.borrower.federal_tax_number # "191" (String, mesmo se o fio mandar Integer) +invoice.borrower["federalTaxNumber"] # 191 (valor cru do fio) +invoice.borrower.dig("address", "city", "name") +``` + +:::warning `pdf` e `xml` estão deprecated +Os membros `invoice.pdf` e `invoice.xml` são campos-fantasma — a resposta do +retrieve não os traz (sempre `nil`). Use `download_pdf`/`download_xml`. +::: + ## Baixar PDF e XML (bytes binários) Os downloads retornam uma `String` binária (`ASCII-8BIT`) — grave com diff --git a/lib/nfe/generated.rb b/lib/nfe/generated.rb index 471e5ff..49a015a 100644 --- a/lib/nfe/generated.rb +++ b/lib/nfe/generated.rb @@ -362,6 +362,7 @@ require_relative "generated/nf_produto_v2/vehicle_detail_resource" require_relative "generated/nf_produto_v2/volume_resource" require_relative "generated/nf_produto_v2/withdrawal_information_resource" +require_relative "generated/nf_servico_v1/errors_resource" require_relative "generated/nfeio/batch_process_response" require_relative "generated/nfeio/environment" require_relative "generated/nfeio/file_parsing_options_request" diff --git a/lib/nfe/generated/generated_marker.rb b/lib/nfe/generated/generated_marker.rb index af57212..df8bc2f 100644 --- a/lib/nfe/generated/generated_marker.rb +++ b/lib/nfe/generated/generated_marker.rb @@ -13,6 +13,7 @@ module Generated "contribuintes-v2.json" => "sha256:e2d215a19f5dc85c08067d51644e807aae32b6c4754390872670f2e18a938102", "nf-consumidor-v2.yaml" => "sha256:8c39e692ff794ccb2587ebe142be040e44d76cbf970f45e65b28d56a6165bdb5", "nf-produto-v2.yaml" => "sha256:e565b47e4d8b17255f99efc2b6354d589d2903c4ba9b97caabd74f84de59e4e2", + "nf-servico-v1.yaml" => "sha256:621a3b8e437e5cb37367c8cd26fec93fa2b3f87c9a59252ac987c56bb0c7ba56", "nfeio.yaml" => "sha256:813bda287538f8599c3565485eb523d1b1311b26b5be94ead62ba0b7a17f6af3", "product-invoice-rtc-v1.yaml" => "sha256:4327ad141eeace6219dc4267678c44a134ae1fdde93f7dd69cf4c9ae9418415a", "product-register-pt-br-v1.yaml" => "sha256:beba0a3fb4dc1bc157a5a4a28e55768cea0e7390b491bdd4bedee2ee2297ca64", diff --git a/lib/nfe/generated/nf_servico_v1/errors_resource.rb b/lib/nfe/generated/nf_servico_v1/errors_resource.rb new file mode 100644 index 0000000..8dde4af --- /dev/null +++ b/lib/nfe/generated/nf_servico_v1/errors_resource.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# AUTO-GENERATED — do not edit +# Source: openapi/nf-servico-v1.yaml +# Hash: sha256:621a3b8e437e5cb37367c8cd26fec93fa2b3f87c9a59252ac987c56bb0c7ba56 + +module Nfe + module Generated + module NfServicoV1 + ErrorsResource = Data.define(:errors) do + def self.from_api(payload) + return nil if payload.nil? + + new( + errors: payload["errors"], + ) + end + end + end + end +end diff --git a/lib/nfe/resources/dto/service_invoice.rb b/lib/nfe/resources/dto/service_invoice.rb index cce9756..000134e 100644 --- a/lib/nfe/resources/dto/service_invoice.rb +++ b/lib/nfe/resources/dto/service_invoice.rb @@ -1,17 +1,33 @@ # frozen_string_literal: true +require "nfe/resources/dto/service_invoice_borrower" + module Nfe # Immutable value object for a service invoice (NFS-e) as returned by the # NFE.io +api.nfe.io/v1+ service-invoice API. # - # The +nf-servico-v1.yaml+ OpenAPI spec defines NO component schemas (the - # shape is derived from the operations), so this DTO is hand-written — mirror - # of the fields the Node/PHP SDKs read. {from_api} maps the API camelCase keys - # onto snake_case members, drops unknown keys, and is nil-tolerant - # (+from_api(nil)+ returns +nil+). + # The +nf-servico-v1.yaml+ OpenAPI spec declares the retrieve success + # response **inline** (no named schema in +components.schemas+ — only the + # error model is componentized), so this DTO is hand-written and pinned to + # the spec by an alignment test. {from_api} maps the API camelCase keys onto + # snake_case members and is nil-tolerant (+from_api(nil)+ returns +nil+). + # The full parsed payload is preserved under +raw+ for forward + # compatibility — fields without a typed member (the withholding tree, + # +provider+, +taxationType+, +location+, +approximateTax+, ...) are + # accessible through it. + # + # +borrower+ is hydrated into {Nfe::ServiceInvoiceBorrower} (typed readers + # plus a Hash-compatibility bridge, so +borrower["name"]+ keeps working). # # +flow_status+ drives the polling lifecycle; pass it to # {Nfe::FlowStatus.terminal?} to decide when the document is settled. + # + # @!attribute [r] pdf + # @deprecated Ghost field — the retrieve response carries no +pdf+ key + # (always +nil+). Use {Nfe::Resources::ServiceInvoices#download_pdf}. + # @!attribute [r] xml + # @deprecated Ghost field — the retrieve response carries no +xml+ key + # (always +nil+). Use {Nfe::Resources::ServiceInvoices#download_xml}. class ServiceInvoice < Data.define( :id, :flow_status, @@ -33,12 +49,17 @@ class ServiceInvoice < Data.define( :pdf, :xml, :created_on, - :modified_on + :modified_on, + :base_tax_amount, + :iss_rate, + :iss_tax_amount, + :raw ) # Build a {Nfe::ServiceInvoice} from an API payload. # # @param payload [Hash, nil] the response object. # @return [Nfe::ServiceInvoice, nil] +nil+ when +payload+ is +nil+. + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- wide value-object mapping kept inline for Steep keyword-arg verification def self.from_api(payload) return nil if payload.nil? @@ -56,15 +77,20 @@ def self.from_api(payload) cancelled_on: payload["cancelledOn"], amount_net: payload["amountNet"], services_amount: payload["servicesAmount"], - borrower: payload["borrower"], + borrower: ServiceInvoiceBorrower.from_api(payload["borrower"]), city_service_code: payload["cityServiceCode"], federal_service_code: payload["federalServiceCode"], description: payload["description"], pdf: payload["pdf"], xml: payload["xml"], created_on: payload["createdOn"], - modified_on: payload["modifiedOn"] + modified_on: payload["modifiedOn"], + base_tax_amount: payload["baseTaxAmount"], + iss_rate: payload["issRate"], + iss_tax_amount: payload["issTaxAmount"], + raw: payload ) end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize end end diff --git a/lib/nfe/resources/dto/service_invoice_borrower.rb b/lib/nfe/resources/dto/service_invoice_borrower.rb new file mode 100644 index 0000000..46ceb67 --- /dev/null +++ b/lib/nfe/resources/dto/service_invoice_borrower.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "nfe/resources/dto/company" + +module Nfe + # Immutable value object for the borrower (tomador) of a service invoice, + # as embedded in the NFS-e retrieve response (+nf-servico-v1.yaml+, inline + # schema of +GET /v1/companies/{company_id}/serviceinvoices/{id}+). + # + # +federal_tax_number+ is normalized to +String+ via {Nfe::Company.stringify}: + # the spec declares it +integer int64+, but the alphanumeric CNPJ + # (IN RFB 2.229/2024) requires string tolerance — deliberate deviation, + # pinned by the alignment test. +address+ stays a raw +Hash+. + # + # Hash-compatibility bridge: before this DTO existed, +invoice.borrower+ + # returned the raw wire +Hash+ — {#[]} and {#dig} delegate to {#raw} so + # +borrower["name"]+ keeps working alongside the typed readers. + class ServiceInvoiceBorrower < Data.define( + :id, + :name, + :federal_tax_number, + :email, + :phone_number, + :address, + :parent_id, + :raw + ) + # Build a {Nfe::ServiceInvoiceBorrower} from an API payload. + # + # @param payload [Hash, nil] the borrower object from the wire. + # @return [Nfe::ServiceInvoiceBorrower, nil] +nil+ when +payload+ is +nil+. + def self.from_api(payload) + return nil if payload.nil? + + new( + id: payload["id"], + name: payload["name"], + federal_tax_number: Company.stringify(payload["federalTaxNumber"]), + email: payload["email"], + phone_number: payload["phoneNumber"], + address: payload["address"], + parent_id: payload["parentId"], + raw: payload + ) + end + + # Hash-style read, delegated to the raw wire payload (camelCase keys), + # e.g. +borrower["federalTaxNumber"]+ returns the wire value untouched. + def [](key) + raw && raw[key] + end + + # Hash-style nested read over the raw wire payload, + # e.g. +borrower.dig("address", "city", "name")+. + def dig(*keys) + raw&.dig(*keys) + end + end +end diff --git a/lib/nfe/version.rb b/lib/nfe/version.rb index b835341..07f3358 100644 --- a/lib/nfe/version.rb +++ b/lib/nfe/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Nfe - VERSION = "1.0.0" + VERSION = "1.1.0" end diff --git a/openapi/README.md b/openapi/README.md index 9fb4429..f4d32d6 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -6,7 +6,7 @@ objects under `lib/nfe/generated/` (and their signatures under documentation repository after review — never hand-edited here. - **Source:** `nfeio-docs` → `docs/static/api/` (https://nfe.io). -- **Snapshot:** 2026-06-24. +- **Snapshot:** 2026-07-09 (verificado contra `nfe/docs` `main`; specs idênticos byte a byte). - **Sync:** `rake openapi:sync` copies the spec set from `nfeio-docs` (path configurable via `NFEIO_DOCS_PATH`) into this directory and reports the diff. It does **not** run `rake generate` or commit — that is a deliberate @@ -26,5 +26,11 @@ directly — JSON is valid YAML, so the loader (Psych) reads both. Some specs declare their request/response shapes inline under `operations[...]` rather than in `components.schemas`. Those produce **no** generated namespace; the DTOs they would need are hand-written in the resource changes instead. The -generator logs each skipped spec. (Known examples include `nf-servico-v1.yaml` -and `cpf-api.yaml` — the definitive list is reported by `rake generate`.) +generator logs each skipped spec. (Known example: `cpf-api.yaml` — the +definitive list is reported by `rake generate`.) + +A spec can also be only **partially** componentized: `nf-servico-v1.yaml` +declares `ErrorsResource` in `components.schemas` (so it generates the +errors-only `nf_servico_v1` namespace), while its success responses remain +inline — the `Nfe::ServiceInvoice` DTO stays hand-written, pinned to the spec +by an alignment test. diff --git a/openapi/nf-servico-v1.yaml b/openapi/nf-servico-v1.yaml index b9bf4c2..e97cf38 100644 --- a/openapi/nf-servico-v1.yaml +++ b/openapi/nf-servico-v1.yaml @@ -373,10 +373,22 @@ paths: type: integer "400": description: Algum parametro informado não é válido, verificar resposta + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -979,12 +991,28 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "409": description: Já existe uma empresa com o CNPJ informado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -1307,10 +1335,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -1914,10 +1954,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" parameters: - name: company_id in: path @@ -1962,12 +2014,28 @@ paths: type: object "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: empresa não foi encontrada + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2012,14 +2080,34 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Empresa não foi encontrada + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "415": description: Nenhum arquivo foi encontrado na requisição + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" parameters: - name: company_id in: path @@ -2087,10 +2175,22 @@ paths: type: string "400": description: Algum parametro informado não é válido, verificar resposta + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2155,10 +2255,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2189,12 +2301,28 @@ paths: type: object "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: empresa não foi encontrada + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2273,12 +2401,28 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "409": description: Já existe uma empresa com o CNPJ informado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2313,6 +2457,10 @@ paths: type: string "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2521,10 +2669,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2742,10 +2902,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2864,10 +3036,22 @@ paths: type: integer "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -2978,10 +3162,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -3704,10 +3900,22 @@ paths: type: integer "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -6138,12 +6346,24 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "408": description: Tempo de reposta do servidor excedeu o limite (60s) "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -6792,10 +7012,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7444,10 +7676,22 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7479,12 +7723,24 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "408": description: Tempo de reposta do servidor excedeu o limite (60s) "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7517,12 +7773,24 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "408": description: Tempo de reposta do servidor excedeu o limite (60s) "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7555,14 +7823,30 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Não foi possivel o download + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "408": description: Tempo de reposta do servidor excedeu o limite (60s) "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7595,14 +7879,30 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Não foi possivel o download + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "408": description: Tempo de reposta do servidor excedeu o limite (60s) "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7635,12 +7935,28 @@ paths: type: string "400": description: Algum parametro informado não é válido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "401": description: API Key da conta não é valida + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Não há XML de evento de cancelamento para esta nota (provedor legado, ambiente não Nacional ou nota ainda não cancelada) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" security: - Authorization_Header: [] Authorization_QueryParam: [] @@ -7801,8 +8117,16 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -7981,10 +8305,22 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Webhook não encontrado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8020,8 +8356,16 @@ paths: description: Sucesso na exclusão dos WebHooks "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8149,10 +8493,22 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Webhook não encontrado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8341,10 +8697,22 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Webhook não encontrado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8409,10 +8777,22 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Webhook não encontrado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8478,10 +8858,22 @@ paths: type: string "401": description: Não autorizado, verificar o cabeçalho do HTTP Authorization + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "403": description: Accesso proibido + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "404": description: Webhook não encontrado + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorsResource" "500": description: Erro no processamento content: @@ -8508,6 +8900,25 @@ paths: - Authorization_Header: [] Authorization_QueryParam: [] components: + schemas: + ErrorsResource: + description: Lista de Erros + type: object + properties: + errors: + description: Lista de Erros + type: array + items: + description: Erro + type: object + properties: + code: + format: int32 + description: Código do erro + type: integer + message: + description: Mensagem contendo os detalhes do erro + type: string securitySchemes: Authorization_Header: name: Authorization diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/.openspec.yaml b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/README.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/README.md new file mode 100644 index 0000000..1c76a55 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/README.md @@ -0,0 +1,3 @@ +# expand-service-invoice-dto + +ServiceInvoice DTO: raw + campos ISS tipados + Borrower + teste de alinhamento (ponte até upstream componentizar) diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/design.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/design.md new file mode 100644 index 0000000..f2d38bb --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/design.md @@ -0,0 +1,85 @@ +# Design: expand-service-invoice-dto (client-ruby) + +## Context + +`Nfe::ServiceInvoice` (`lib/nfe/resources/dto/service_invoice.rb`) é `Data.define` manuscrito com 21 membros (19 reais + 2 fantasmas `pdf`/`xml`), enquanto o retrieve real devolve **44 campos** (schema inline no path `/v1/companies/{company_id}/serviceinvoices/{id}` de `openapi/nf-servico-v1.yaml`). O gerador lê apenas `components.schemas` (`scripts/generator/spec_loader.rb`), e a resposta de sucesso é inline — por isso não existe modelo gerado de `ServiceInvoice`. Estado do spec (2026-07-09): a versão do working tree (não commitada) adiciona `components.schemas.ErrorsResource`; com ela o spec deixa de ser pulado e `rake generate` passa a emitir `lib/nfe/generated/nf_servico_v1/` **apenas com o modelo de erros** — o commit desse yaml precisa levar junto a saída de `rake generate` (senão `rake generate:check` quebra a CI; verificado em Docker) e atualizar as referências obsoletas (`openapi/README.md` cita o spec como pulado; o docblock do DTO diz "defines NO component schemas"). O `from_api` descarta chaves desconhecidas — sem `:raw`, ~25 campos são perdidos. + +Referência 1:1: change `expand-service-invoice-dto` do client-php (D1–D6 fechados lá). Diferenças do lado Ruby: +- O Ruby **já tem** os campos de alto valor que o PHP adicionou (`number`, `check_code`, `description`, `city_service_code`, `amount_net`) — o escopo tipado aqui são só os 3 de ISS. +- O `from_api` é o **único ponto de hidratação** (list via `hydrate_list`, retrieve/cancel via `hydrate`, 201-issued via `handle_async_response`) — popular `raw` é um edit em um lugar, não em 5 call-sites como no PHP. +- **`borrower` já é comportamento vivo** como Hash cru (no PHP o campo não era exposto) — tipar exige ponte de compatibilidade (D3). + +## Goals / Non-Goals + +**Goals:** +- Nenhum campo do retrieve inacessível: 44/44 via `raw`, os de maior valor tipados. +- `borrower` tipado sem quebrar leituras Hash existentes. +- Teste de alinhamento YAML↔DTO que impede drift e sinaliza a hora de migrar ao gerado. + +**Non-Goals:** +- Tipar os 44 campos (gradiente de valor; `provider` com 21 subcampos fica em `raw`). +- Componentizar a resposta no `nfe/docs` (upstream, issue à parte) ou alterar o gerador. +- Remover `pdf`/`xml` (deprecação apenas; remoção na próxima major). + +## Decisions + +### D1 — `:raw` populado no `from_api` (fundação) +Membro `:raw` recebendo o `payload` completo, copiando `Nfe::ConsumerInvoice` ("preserved under raw for forward compatibility", `raw: payload`). Como todos os call-sites hidratam por `from_api`, todas as leituras (list/retrieve/cancel/issued) ganham os 44 campos de uma vez. +*Trade-off aceito*: `raw` duplica dados já tipados — os tipados são a via preferida (documentar), `raw` é forward-compat. + +### D2 — Conjunto tipado: apenas os 3 campos de ISS +`base_tax_amount`, `iss_rate`, `iss_tax_amount` (`number/double` no spec; Float no fio). O restante fica em `raw` — retenções, `provider`, `taxationType`, `location`, `approximateTax`, `externalId`, `rpsStatus`/`rpsType` etc. +*Alternativa rejeitada*: tipar tudo — 25 membros novos de baixo uso, custo de RBS/testes alto, e a migração futura para o gerado ficaria mais cara. + +### D3 — `ServiceInvoiceBorrower` com ponte Hash (decisão do usuário, 2026-07-09) +```ruby +class ServiceInvoiceBorrower < Data.define( + :id, :name, :federal_tax_number, :email, :phone_number, + :address, :parent_id, :raw +) + def self.from_api(payload) ... federal_tax_number: Company.stringify(payload["federalTaxNumber"]) ... + + # Ponte de compatibilidade: leituras Hash continuam funcionando. + def [](key) = raw && raw[key] + def dig(*keys) = raw&.dig(*keys) +end +``` +- `invoice.borrower["name"]` (comportamento vivo hoje) continua funcionando via delegação ao `raw`; `invoice.borrower.name` passa a existir. +- `federal_tax_number` via `Company.stringify` (`value&.to_s`): o spec declara `integer int64`, mas o CNPJ alfanumérico (IN RFB 2.229/2024) exige String — desvio deliberado, **pinado no teste de alinhamento**. +- `address` permanece Hash cru dentro do Borrower (baixo valor de tipar agora). +- `provider` NÃO ganha DTO — 21 subcampos, acesso via `invoice.raw["provider"]`. +*Alternativas rejeitadas*: DTO puro (quebra `borrower["..."]` vivo — viola SemVer minor); manter Hash (perde ergonomia e a proteção do CNPJ alfanumérico). + +### D4 — Teste de alinhamento ancorado por PATH, com tripwire de migração +Parse do `openapi/nf-servico-v1.yaml` (Psych, como `account_webhook_alignment_spec.rb`) e `dig` por `paths → /v1/companies/{company_id}/serviceinvoices/{id} → get → 200 → schema inline`. +- **NÃO ancorar por `operationId`**: `ServiceInvoices_idGet` colide (`/{id}` E `/external/{id}`). +- Asserções: (a) todo membro tipado (exceto `raw`/`pdf`/`xml`) existe no schema (subset, não igualdade — o DTO não cobre os 44); (b) **pina os fantasmas**: `pdf`/`xml` ausentes do schema (se um sync adicioná-los, remover a deprecação); (c) **pina o desvio** `borrower.federalTaxNumber` int64 vs String no DTO; (d) campos tipados do Borrower ⊆ propriedades de `borrower` no schema. +- **Tripwire de migração**: quando o upstream componentizar a resposta, o schema inline vira `$ref` e o `dig` retorna estrutura sem `properties` → o teste falha ruidosamente → migrar para o modelo gerado. + +### D5 — Deprecar `pdf`/`xml`, não remover +Membros mantidos (sempre `nil` — não existem na resposta), `@deprecated` YARD apontando `ServiceInvoices#download_pdf`/`#download_xml`. Remoção na próxima major. Mantém a change **minor** (mesma lógica do `totalAmount` no PHP). + +### D6 — Ordem dos membros: anexar ao fim, `:raw` por último +`base_tax_amount`, `iss_rate`, `iss_tax_amount` anexados após `modified_on`; `:raw` como último membro (convenção do `ConsumerInvoice`). Protege construção posicional hipotética e mantém `to_h`/`deconstruct` estáveis nos prefixos existentes. + +### D7 — Absorver o commit do spec atualizado + código gerado (decisão do usuário, 2026-07-09) +A atualização local do `nf-servico-v1.yaml` (ErrorsResource) entra nesta change, seguindo o fluxo já exigido pelo spec `openapi-pipeline` ("commit specs and generated files together"): yaml + `rake generate` no mesmo commit, `generate:check` verde. Justificativa para agrupar aqui: a change já toca o docblock do `service_invoice.rb` que fica obsoleto, e implementá-la com o yaml desatualizado no working tree deixaria a CI vermelha no meio do caminho. O delta de `openapi-pipeline` corrige apenas o exemplo do cenário (comportamento do gerador inalterado). +*Alternativa rejeitada*: commit separado do yaml — funcionaria, mas duplicaria toques nos mesmos arquivos (docblock, README) em duas PRs contíguas. + +## Risks / Trade-offs + +- [`to_h`/`members` do Data ganham chaves novas] → aditivo; consumidores que fazem snapshot de `to_h` veem chaves extras — aceitável em minor, notar no CHANGELOG. +- [Ponte `#[]` pode mascarar typo de chave (retorna `nil`)] → mesmo comportamento do Hash atual; documentar que a via tipada é a preferida. +- [Schema inline pode divergir do fio real] → o spec foi validado por sonda ao vivo nas changes anteriores; validar campos novos com sonda antes de fechar (lembrete de método: grep nos `openapi/*.yaml` + probe ao vivo). + +## Migration Plan + +1. Implementar D1→D6 numa PR (minor), suíte `rake` completa (spec + rubocop + steep + rbs). +2. CHANGELOG coordenado com o client-php (`expand-service-invoice-dto`, v3.3.0 lá). +3. Quando o `nfe/docs` componentizar a resposta + re-sync: o teste de alinhamento quebra → migrar `ServiceInvoice` para o modelo gerado, mantendo `raw` e a ponte do Borrower como camada de compat. + +## Open Questions + +_(nenhuma — resolvida no apply)_ + +- ~~Validar ao vivo se os campos de ISS vêm com serialização inesperada~~ **Resolvida** (sonda read-only no retrieve, 2026-07-09): valores inteiros chegam como `Integer` no JSON (`issTaxAmount: 0`), decimais como `Float` (`issRate: 0.05`, `baseTaxAmount: 9.98`) → RBS do trio fixado em `(Float | Integer)?`. A mesma sonda confirmou ao vivo: fantasmas `pdf`/`xml` ausentes, Borrower com fio Integer normalizado a String pela ponte, e 21 campos sem membro tipado acessíveis via `raw`. Nota operacional: a conta 1 das sondas responde 503 em `GET /v1/companies/{id}/serviceinvoices`; a conta 2 respondeu normalmente. diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/proposal.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/proposal.md new file mode 100644 index 0000000..f88e6fa --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/proposal.md @@ -0,0 +1,43 @@ +# Proposal: expand-service-invoice-dto (client-ruby) + +## Why + +O DTO `Nfe::ServiceInvoice` é 100% manuscrito e cobre **19 dos 44 campos** que a API devolve no retrieve de NFS-e (`GET /v1/companies/{company_id}/serviceinvoices/{id}`, schema inline em `openapi/nf-servico-v1.yaml`): + +1. **Não há membro `:raw`** — `from_api` **descarta** chaves desconhecidas (comportamento testado em `spec/nfe/resources/dto/service_invoice_spec.rb`, "drops unknown keys"), jogando fora ~25 campos: toda a árvore de retenções (`issAmountWithheld`, `irAmountWithheld`, `pisAmountWithheld`, `cofinsAmountWithheld`, `csllAmountWithheld`, `inssAmountWithheld`, `othersAmountWithheld`, `amountWithheld`), `provider` (21 subcampos), `taxationType`, `location`, `approximateTax`, `externalId`, `rpsStatus`, `rpsType`, descontos/deduções etc. Esses dados ficam **inacessíveis** — nem tipados, nem via escape-hatch. +2. **2 campos-fantasma**: `pdf` e `xml` não existem na resposta do retrieve (sempre `nil`; os documentos vêm por `/pdf` e `/xml`). +3. **`borrower` é Hash cru** sem tipo — e `borrower.federalTaxNumber` é `integer int64` no spec, o que quebra com o CNPJ alfanumérico (IN RFB 2.229/2024). +4. **Faltam os 3 campos de ISS**: `baseTaxAmount`, `issRate`, `issTaxAmount` (todos `number/double` no spec). + +Causa-raiz: o `nf-servico-v1.yaml` declara a resposta de **sucesso** do retrieve **inline** (sem schema nomeado em `components.schemas`), então o gerador (`rake generate` → `Nfe::Build::Generator`) não produz o modelo — não existe `Nfe::Generated::NfServicoV1::ServiceInvoice`. Nota de estado (2026-07-09): uma atualização local do yaml (working tree, não commitada) adicionou `components.schemas.ErrorsResource`, então o spec **deixa de ser pulado por inteiro** (passará a gerar um namespace só de erros; `rake generate` deve acompanhar esse commit ou `generate:check` quebra a CI — verificado). Isso **não** muda o gap desta change: a resposta de sucesso segue inline e o `ServiceInvoice` segue manuscrito. Esta change é a **mitigação do lado do SDK** (mesma sequência da change `expand-service-invoice-dto` do client-php) enquanto o conserto de origem — componentizar a resposta de sucesso no `nfe/docs` — não vem. + +## What Changes + +- **Adicionar membro `:raw`** ao `Nfe::ServiceInvoice`, populado com o payload completo no `from_api` (padrão já existente em `Nfe::ConsumerInvoice` — "preserved under raw for forward compatibility"). Como `from_api` é o único ponto de hidratação (list, retrieve, cancel, 201-issued), um único edit destrava os 44 campos em todas as leituras. +- **Tipar os 3 campos de ISS** (aditivo, anexados ao fim antes de `:raw`): `base_tax_amount`, `iss_rate`, `iss_tax_amount`. Os demais ~22 permanecem via `raw` (gradiente de valor — o Ruby já tem os campos de alto valor que o PHP precisou adicionar: `number`, `check_code`, `description`, `city_service_code`, `amount_net`). +- **DTO aninhado `Nfe::ServiceInvoiceBorrower`** (7 campos) com **ponte Hash**: membro `:raw` + `#[]`/`#dig` delegando ao Hash do fio, para que `invoice.borrower["name"]` (comportamento vivo hoje) continue funcionando enquanto `invoice.borrower.name` passa a existir. `federal_tax_number` tolerante a Integer OU String via o helper `Company.stringify` (preserva CNPJ alfanumérico). `provider` (21 subcampos) **não** é tipado — fica em `raw["provider"]`. +- **Teste de alinhamento YAML↔DTO** parseando o schema inline, **ancorado pelo path** `/v1/companies/{company_id}/serviceinvoices/{id}` — NÃO por `operationId` (colisão: `ServiceInvoices_idGet` aparece em `/{id}` E em `/external/{id}`). Precedente: `fix-account-webhooks-contract`. Pina os fantasmas (pdf/xml ausentes do schema) e o desvio `federalTaxNumber` int64 vs String no DTO. +- **Deprecar `pdf`/`xml`** (`@deprecated` YARD, membros mantidos — sempre `nil` no retrieve) apontando `download_pdf`/`download_xml`. +- **Atualizar RBS** (`sig/nfe/resources/dto/service_invoice.rbs` + sig novo do Borrower), o `service_invoice_spec.rb` ("drops unknown keys" → preservado em `:raw`), docs do recurso e a skill se citarem os campos. +- **Absorver a atualização do `openapi/nf-servico-v1.yaml`** (já no working tree: `ErrorsResource` em `components.schemas` + respostas 4xx/5xx tipadas) seguindo o fluxo documentado "commit specs and generated files together": rodar `rake generate` e commitar o namespace gerado (`lib/nfe/generated/nf_servico_v1/`, só erros) junto — sem isso `rake generate:check` quebra a CI (verificado). Corrigir as referências que ficam obsoletas: `openapi/README.md` (spec sai da lista de pulados) e o docblock do `service_invoice.rb` ("defines NO component schemas" → resposta de sucesso inline). + +**Não faz parte** (ortogonal): componentizar a resposta de **sucesso** no `nfe/docs` (upstream) e mudanças no código do gerador. + +## Capabilities + +### New Capabilities + +_(nenhuma)_ + +### Modified Capabilities + +- `invoice-resources`: o requisito "Service invoice CRUD, email, downloads, and status" tem o cenário de retrieve ampliado (hidratação preserva o payload completo em `raw`); requisito novo para o shape do `ServiceInvoice` (gradiente tipado+raw, Borrower com ponte Hash, fantasmas deprecados, teste de alinhamento por path). +- `openapi-pipeline`: comportamento inalterado; o cenário "Spec without schemas produces nothing" troca o exemplo obsoleto (`nf-servico-v1.yaml` agora tem `components.schemas.ErrorsResource`; `cpf-api.yaml` permanece como exemplo). + +## Impact + +- **Código**: `lib/nfe/resources/dto/service_invoice.rb` (`:raw` + 3 membros + deprecations + docblock), novo `lib/nfe/resources/dto/service_invoice_borrower.rb`, `sig/nfe/resources/dto/**`; `openapi/nf-servico-v1.yaml` + saída de `rake generate` (`lib/nfe/generated/nf_servico_v1/**`, `sig/nfe/generated/nf_servico_v1/**`, `generated.rb`, `generated_marker.rb`) + `openapi/README.md`. +- **Testes**: alinhamento YAML↔DTO por path; hidratação (`raw` populado, campos novos, Borrower + ponte); "drops unknown keys" atualizado. +- **SemVer**: **minor** — tudo aditivo; `borrower` mantém leitura Hash pela ponte; `pdf`/`xml` deprecados (não removidos); membros novos anexados ao fim (`:raw` por último, convenção do `ConsumerInvoice`). +- **Natureza de ponte**: quando o `nfe/docs` componentizar a resposta (schema vira `$ref`), o `dig` por path do teste de alinhamento quebra → sinal para migrar ao modelo gerado com segurança. +- **Consistência cross-SDK**: mesma forma e nome da change do client-php (`expand-service-invoice-dto`); CHANGELOG coordenado. diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/invoice-resources/spec.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/invoice-resources/spec.md new file mode 100644 index 0000000..82364a8 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/invoice-resources/spec.md @@ -0,0 +1,47 @@ +# invoice-resources — Delta (expand-service-invoice-dto) + +> Contexto: o retrieve de NFS-e devolve 44 campos (schema inline em +> `openapi/nf-servico-v1.yaml`, path `/v1/companies/{company_id}/serviceinvoices/{id}`); +> o DTO manuscrito cobre 19 e descarta o resto. Esta change adiciona `raw` + +> campos ISS tipados + Borrower com ponte Hash, como mitigação até o upstream +> componentizar a resposta. Espelha a change `expand-service-invoice-dto` do client-php. + +## MODIFIED Requirements + +### Requirement: Service invoice CRUD, email, downloads, and status +`ServiceInvoices` SHALL expose `create`, `list`, `retrieve`, `cancel`, `send_email`, `download_pdf`, `download_xml`, and `get_status`. Every method that takes a company ID or invoice ID SHALL validate it through `Nfe::IdValidator` (provided by `add-client-core`) before issuing the HTTP request. _(Requirement text unchanged — this delta only amends the retrieve scenario below; all other scenarios and the method table are preserved.)_ + +#### Scenario: Retrieve returns a typed model +- **WHEN** `retrieve(company_id:, invoice_id:)` succeeds +- **THEN** the return value SHALL be a typed invoice model (a generated model, or a hand-written `Nfe::ServiceInvoice` value object where the generated tree does not cover the shape) hydrated from the response body +- **AND** the hydrated model SHALL preserve the complete wire payload under its `raw` member, so no response field is inaccessible even when not covered by a typed member + +## ADDED Requirements + +### Requirement: ServiceInvoice value object covers the live retrieve shape via typed members plus raw +`Nfe::ServiceInvoice` SHALL expose the high-value fields of the NFS-e retrieve response as typed members — including the ISS tax trio `base_tax_amount`, `iss_rate`, `iss_tax_amount` — and SHALL preserve the complete wire payload under a `:raw` member populated by `from_api` (single hydration point covering list, retrieve, cancel, and the 201-issued path). New members are appended after the existing ones with `:raw` last. The ghost members `pdf` and `xml` (absent from the retrieve response — the documents come from the `/pdf` and `/xml` endpoints) SHALL remain but be marked `@deprecated` (YARD) pointing to `download_pdf`/`download_xml`. The `borrower` member SHALL be hydrated into `Nfe::ServiceInvoiceBorrower` (`Data.define`) with typed members (`id`, `name`, `federal_tax_number`, `email`, `phone_number`, `address`, `parent_id`) plus a `:raw` member, where `federal_tax_number` is normalized to `String` via `Company.stringify` (the spec declares `integer int64`, but the alphanumeric CNPJ — IN RFB 2.229/2024 — requires string tolerance), and SHALL keep Hash-style reads working by delegating `#[]` and `#dig` to `raw`. Contract source of truth: the OpenAPI spec (`openapi/nf-servico-v1.yaml`) plus live probes — never a sibling SDK. + +#### Scenario: Unknown fields are preserved under raw +- **WHEN** `Nfe::ServiceInvoice.from_api` receives a payload containing fields without a typed member (e.g. the withholding tree `issAmountWithheld`/`irAmountWithheld`/..., `provider`, `taxationType`, `location`, `approximateTax`, `externalId`) +- **THEN** those fields SHALL be accessible through `invoice.raw` exactly as received +- **AND** `raw` SHALL be the complete payload, including fields that also have typed members + +#### Scenario: ISS tax fields are typed +- **WHEN** the retrieve payload carries `baseTaxAmount`, `issRate`, and `issTaxAmount` +- **THEN** `invoice.base_tax_amount`, `invoice.iss_rate`, and `invoice.iss_tax_amount` SHALL return them as typed members + +#### Scenario: Borrower is typed with a Hash-compatibility bridge +- **WHEN** the payload carries a `borrower` object +- **THEN** `invoice.borrower` SHALL be an `Nfe::ServiceInvoiceBorrower` with typed members, `federal_tax_number` returned as `String` (Integer or String on the wire) +- **AND** Hash-style reads SHALL keep working: `invoice.borrower["name"]` and `invoice.borrower.dig("address", "city")` delegate to the raw wire Hash + +#### Scenario: Ghost members are deprecated, not removed +- **WHEN** the consumer reads `invoice.pdf` or `invoice.xml` +- **THEN** the members still exist (returning `nil` — the retrieve response has no such fields) and their YARD docs are `@deprecated`, pointing to `download_pdf`/`download_xml` + +#### Scenario: Alignment test pins the contract by path +- **WHEN** the RSpec suite runs +- **THEN** an alignment spec SHALL parse the inline retrieve schema from `openapi/nf-servico-v1.yaml`, anchored by the path `/v1/companies/{company_id}/serviceinvoices/{id}` (NOT by `operationId` — `ServiceInvoices_idGet` collides between `/{id}` and `/external/{id}`) +- **AND** SHALL assert every typed member (except `raw`, `pdf`, `xml`) maps to a schema property, and the Borrower's typed members map to the `borrower` sub-schema +- **AND** SHALL pin the ghosts (`pdf`/`xml` absent from the schema) and the `borrower.federalTaxNumber` int64-vs-String deviation, so a spec sync that changes either fails the suite as a signal to revisit +- **AND** when the upstream componentizes the response (inline schema becomes a `$ref`), the path-anchored dig SHALL fail loudly as the signal to migrate to the generated model diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/openapi-pipeline/spec.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/openapi-pipeline/spec.md new file mode 100644 index 0000000..3706eb1 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/specs/openapi-pipeline/spec.md @@ -0,0 +1,16 @@ +# openapi-pipeline — Delta (expand-service-invoice-dto) + +> Contexto: a atualização de `openapi/nf-servico-v1.yaml` (absorvida por esta +> change) adiciona `components.schemas.ErrorsResource`, então esse spec deixa +> de ser um exemplo válido de "spec sem schemas". O comportamento do gerador +> não muda — apenas o exemplo do cenário é corrigido (e o namespace +> `nf_servico_v1` passa a existir, contendo somente o modelo de erros). + +## MODIFIED Requirements + +### Requirement: Specs are validated before code is emitted +The spec loader SHALL parse each spec, verify it is a YAML/JSON document declaring an OpenAPI/Swagger version, and treat `components.schemas` as a map. A broken spec SHALL raise loudly during generation rather than silently emitting corrupt code. A spec without `components.schemas` SHALL produce no namespace. _(Behavior unchanged — this delta only refreshes the stale example in the scenario below; `nf-servico-v1.yaml` now carries `components.schemas.ErrorsResource` and generates an errors-only namespace, while its success responses remain inline and their DTOs hand-written.)_ + +#### Scenario: Spec without schemas produces nothing +- **WHEN** a spec has no `components.schemas` (e.g., `cpf-api.yaml` deriving its type from `operations[...]`) +- **THEN** the generator SHALL skip it without error, and the missing DTOs SHALL be hand-written by the resource changes (not under `lib/nfe/generated/`) diff --git a/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/tasks.md b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/tasks.md new file mode 100644 index 0000000..e9a7d99 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-expand-service-invoice-dto/tasks.md @@ -0,0 +1,33 @@ +# Tasks: expand-service-invoice-dto (client-ruby) + +## 1. Spec OpenAPI & código gerado (absorve a atualização local do yaml) + +- [x] 1.1 Incluir a atualização de `openapi/nf-servico-v1.yaml` (já no working tree: `ErrorsResource` em `components.schemas` + `content` nas respostas 4xx/5xx) no escopo da change +- [x] 1.2 `rake generate` e commitar junto a saída (`lib/nfe/generated/nf_servico_v1/` com o modelo de erros, `sig/nfe/generated/nf_servico_v1/`, `lib/nfe/generated.rb`, `generated_marker.rb`) — fluxo documentado "commit specs and generated files together"; `rake generate:check` verde +- [x] 1.3 Atualizar `openapi/README.md`: `nf-servico-v1.yaml` sai da lista de exemplos pulados (`cpf-api.yaml` permanece); notar que o namespace gerado cobre só erros — a resposta de sucesso segue inline +- [x] 1.4 Atualizar o docblock de `lib/nfe/resources/dto/service_invoice.rb` ("defines NO component schemas" → a resposta de sucesso é inline/sem schema nomeado; o modelo segue manuscrito) + +## 2. DTO + +- [x] 2.1 `Nfe::ServiceInvoice`: adicionar membros `base_tax_amount`, `iss_rate`, `iss_tax_amount` e `:raw` (anexados ao fim, `:raw` por último — convenção `ConsumerInvoice`); `from_api` popula `raw: payload` +- [x] 2.2 Criar `lib/nfe/resources/dto/service_invoice_borrower.rb` (`Data.define`: `id`, `name`, `federal_tax_number`, `email`, `phone_number`, `address`, `parent_id`, `raw`; `federal_tax_number` via `Company.stringify`; ponte Hash `#[]`/`#dig` delegando ao `raw`); `from_api` do `ServiceInvoice` hidrata `borrower` com ele +- [x] 2.3 Deprecar `pdf`/`xml` (`@deprecated` YARD → `download_pdf`/`download_xml`; membros mantidos) +- [x] 2.4 RBS: atualizar `sig/nfe/resources/dto/service_invoice.rbs` + criar `sig/nfe/resources/dto/service_invoice_borrower.rbs` + +## 3. Alinhamento com o spec + +- [x] 3.1 Spec RSpec de alinhamento (Psych) ancorado pelo path `/v1/companies/{company_id}/serviceinvoices/{id}` (NÃO por `operationId` — colisão `ServiceInvoices_idGet`): membros tipados (exceto `raw`/`pdf`/`xml`) ⊆ propriedades do schema; Borrower ⊆ sub-schema `borrower` +- [x] 3.2 Pinar no teste: fantasmas (`pdf`/`xml` ausentes do schema) e desvio `borrower.federalTaxNumber` int64 no spec vs String no DTO + +## 4. Testes + +- [x] 4.1 Atualizar "drops unknown keys" em `service_invoice_spec.rb` → chaves desconhecidas preservadas em `:raw` (payload completo) +- [x] 4.2 Unit: Borrower tipado + ponte Hash (`borrower["name"]`, `borrower.dig(...)`) + `federal_tax_number` String para fio Integer E String +- [x] 4.3 Unit: mapeamento dos 3 campos de ISS; `raw` presente em list/retrieve/cancel/issued (via `from_api`) +- [x] 4.4 `rake` completo limpo (spec + rubocop + steep + rbs + generate:check) +- [x] 4.5 (Opcional) Sonda ao vivo no retrieve confirmando serialização dos campos de ISS e shape do `borrower` + +## 5. Docs & release + +- [x] 5.1 Atualizar `docs/recursos/service-invoices.md` (campos tipados, `raw`, Borrower, deprecação pdf/xml) e a skill `nfeio-ruby-sdk` se citar os campos +- [x] 5.2 CHANGELOG (minor): `raw` + campos ISS + Borrower com ponte + deprecações + spec/gerado do `nf-servico-v1` — mensagens consistentes com o client-php (`expand-service-invoice-dto`) diff --git a/openspec/specs/invoice-resources/spec.md b/openspec/specs/invoice-resources/spec.md index f2a9047..ae0b52a 100644 --- a/openspec/specs/invoice-resources/spec.md +++ b/openspec/specs/invoice-resources/spec.md @@ -95,6 +95,7 @@ When `request_options:` is supplied, it SHALL be a `Nfe::RequestOptions` value o #### Scenario: Retrieve returns a typed model - **WHEN** `retrieve(company_id:, invoice_id:)` succeeds - **THEN** the return value SHALL be a typed invoice model (a generated model, or a hand-written `Nfe::ServiceInvoice` value object where the generated tree does not cover the shape) hydrated from the response body +- **AND** the hydrated model SHALL preserve the complete wire payload under its `raw` member, so no response field is inaccessible even when not covered by a typed member #### Scenario: Retrieve not found - **WHEN** `retrieve(company_id:, invoice_id:)` receives HTTP 404, or the response body is empty @@ -116,6 +117,34 @@ When `request_options:` is supplied, it SHALL be a `Nfe::RequestOptions` value o - **WHEN** `get_status(company_id:, invoice_id:)` is called - **THEN** the SDK SHALL call `retrieve` exactly once and SHALL return a value carrying `status`, `invoice`, `complete?` (via `FlowStatus.terminal?`), and `failed?` (true for `IssueFailed`/`CancelFailed`), making no separate status HTTP request +### Requirement: ServiceInvoice value object covers the live retrieve shape via typed members plus raw +`Nfe::ServiceInvoice` SHALL expose the high-value fields of the NFS-e retrieve response as typed members — including the ISS tax trio `base_tax_amount`, `iss_rate`, `iss_tax_amount` — and SHALL preserve the complete wire payload under a `:raw` member populated by `from_api` (single hydration point covering list, retrieve, cancel, and the 201-issued path). New members are appended after the existing ones with `:raw` last. The ghost members `pdf` and `xml` (absent from the retrieve response — the documents come from the `/pdf` and `/xml` endpoints) SHALL remain but be marked `@deprecated` (YARD) pointing to `download_pdf`/`download_xml`. The `borrower` member SHALL be hydrated into `Nfe::ServiceInvoiceBorrower` (`Data.define`) with typed members (`id`, `name`, `federal_tax_number`, `email`, `phone_number`, `address`, `parent_id`) plus a `:raw` member, where `federal_tax_number` is normalized to `String` via `Company.stringify` (the spec declares `integer int64`, but the alphanumeric CNPJ — IN RFB 2.229/2024 — requires string tolerance), and SHALL keep Hash-style reads working by delegating `#[]` and `#dig` to `raw`. Contract source of truth: the OpenAPI spec (`openapi/nf-servico-v1.yaml`) plus live probes — never a sibling SDK. + +#### Scenario: Unknown fields are preserved under raw +- **WHEN** `Nfe::ServiceInvoice.from_api` receives a payload containing fields without a typed member (e.g. the withholding tree `issAmountWithheld`/`irAmountWithheld`/..., `provider`, `taxationType`, `location`, `approximateTax`, `externalId`) +- **THEN** those fields SHALL be accessible through `invoice.raw` exactly as received +- **AND** `raw` SHALL be the complete payload, including fields that also have typed members + +#### Scenario: ISS tax fields are typed +- **WHEN** the retrieve payload carries `baseTaxAmount`, `issRate`, and `issTaxAmount` +- **THEN** `invoice.base_tax_amount`, `invoice.iss_rate`, and `invoice.iss_tax_amount` SHALL return them as typed members + +#### Scenario: Borrower is typed with a Hash-compatibility bridge +- **WHEN** the payload carries a `borrower` object +- **THEN** `invoice.borrower` SHALL be an `Nfe::ServiceInvoiceBorrower` with typed members, `federal_tax_number` returned as `String` (Integer or String on the wire) +- **AND** Hash-style reads SHALL keep working: `invoice.borrower["name"]` and `invoice.borrower.dig("address", "city")` delegate to the raw wire Hash + +#### Scenario: Ghost members are deprecated, not removed +- **WHEN** the consumer reads `invoice.pdf` or `invoice.xml` +- **THEN** the members still exist (returning `nil` — the retrieve response has no such fields) and their YARD docs are `@deprecated`, pointing to `download_pdf`/`download_xml` + +#### Scenario: Alignment test pins the contract by path +- **WHEN** the RSpec suite runs +- **THEN** an alignment spec SHALL parse the inline retrieve schema from `openapi/nf-servico-v1.yaml`, anchored by the path `/v1/companies/{company_id}/serviceinvoices/{id}` (NOT by `operationId` — `ServiceInvoices_idGet` collides between `/{id}` and `/external/{id}`) +- **AND** SHALL assert every typed member (except `raw`, `pdf`, `xml`) maps to a schema property, and the Borrower's typed members map to the `borrower` sub-schema +- **AND** SHALL pin the ghosts (`pdf`/`xml` absent from the schema) and the `borrower.federalTaxNumber` int64-vs-String deviation, so a spec sync that changes either fails the suite as a signal to revisit +- **AND** when the upstream componentizes the response (inline schema becomes a `$ref`), the path-anchored dig SHALL fail loudly as the signal to migrate to the generated model + ### Requirement: Product invoice resource mirrors Node SDK breadth `ProductInvoices` SHALL expose `create`, `create_with_state_tax`, `list`, `retrieve`, `cancel`, `list_items`, `list_events`, `download_pdf`, `download_xml`, `download_rejection_xml`, `download_epec_xml`, `send_correction_letter`, `download_correction_letter_pdf`, `download_correction_letter_xml`, `disable`, and `disable_range`, all under `/v2/companies/{company_id}/productinvoices*` on `https://api.nfse.io`. diff --git a/openspec/specs/openapi-pipeline/spec.md b/openspec/specs/openapi-pipeline/spec.md index b9b5f6b..ed0a1b8 100644 --- a/openspec/specs/openapi-pipeline/spec.md +++ b/openspec/specs/openapi-pipeline/spec.md @@ -186,7 +186,7 @@ The spec loader SHALL parse each spec, verify it is a YAML/JSON document declari - **THEN** the generator SHALL raise with a message naming the offending file, and SHALL NOT write partial output #### Scenario: Spec without schemas produces nothing -- **WHEN** a spec has no `components.schemas` (e.g., `nf-servico-v1.yaml` deriving its type from `operations[...]`, or `cpf-api.yaml`) +- **WHEN** a spec has no `components.schemas` (e.g., `cpf-api.yaml` deriving its type from `operations[...]`) - **THEN** the generator SHALL skip it without error, and the missing DTOs SHALL be hand-written by the resource changes (not under `lib/nfe/generated/`) ### Requirement: Specs are synced from nfeio-docs via a documented manual mechanism diff --git a/samples/README.md b/samples/README.md index 3475a16..7645dd9 100644 --- a/samples/README.md +++ b/samples/README.md @@ -65,7 +65,7 @@ instalar a gem para experimentar. ## Notas - A emissão é **assíncrona** (HTTP 202). Não há `create_and_wait`/`create_batch` - na v1.0 — faça polling chamando `retrieve` em laço até + na v1.x — faça polling chamando `retrieve` em laço até `Nfe::FlowStatus.terminal?(invoice.flow_status)` (estados terminais: `Issued`, `IssueFailed`, `Cancelled`, `CancelFailed`). - Downloads de NFS-e/NFC-e retornam **bytes** (`ASCII-8BIT`) — grave com diff --git a/sig/nfe/generated/nf_servico_v1/errors_resource.rbs b/sig/nfe/generated/nf_servico_v1/errors_resource.rbs new file mode 100644 index 0000000..ddd2ca4 --- /dev/null +++ b/sig/nfe/generated/nf_servico_v1/errors_resource.rbs @@ -0,0 +1,16 @@ +# AUTO-GENERATED — do not edit +# Source: openapi/nf-servico-v1.yaml +# Hash: sha256:621a3b8e437e5cb37367c8cd26fec93fa2b3f87c9a59252ac987c56bb0c7ba56 + +module Nfe + module Generated + module NfServicoV1 + class ErrorsResource < Data + attr_reader errors: Array[Hash[String, untyped]] + def self.new: (?errors: Array[Hash[String, untyped]]) -> instance + def initialize: (?errors: Array[Hash[String, untyped]]) -> void + def self.from_api: (Hash[String, untyped]? payload) -> instance? + end + end + end +end diff --git a/sig/nfe/resources/dto/service_invoice.rbs b/sig/nfe/resources/dto/service_invoice.rbs index 8a626a8..3c50b06 100644 --- a/sig/nfe/resources/dto/service_invoice.rbs +++ b/sig/nfe/resources/dto/service_invoice.rbs @@ -13,7 +13,7 @@ module Nfe attr_reader cancelled_on: String? attr_reader amount_net: untyped attr_reader services_amount: untyped - attr_reader borrower: untyped + attr_reader borrower: Nfe::ServiceInvoiceBorrower? attr_reader city_service_code: untyped attr_reader federal_service_code: untyped attr_reader description: String? @@ -21,10 +21,14 @@ module Nfe attr_reader xml: untyped attr_reader created_on: String? attr_reader modified_on: String? + attr_reader base_tax_amount: (Float | Integer)? + attr_reader iss_rate: (Float | Integer)? + attr_reader iss_tax_amount: (Float | Integer)? + attr_reader raw: Hash[untyped, untyped]? def self.from_api: (Hash[untyped, untyped]? payload) -> Nfe::ServiceInvoice? - def self.new: (id: String?, flow_status: String?, flow_message: String?, status: untyped, environment: untyped, rps_number: untyped, rps_serial_number: untyped, number: untyped, check_code: String?, issued_on: String?, cancelled_on: String?, amount_net: untyped, services_amount: untyped, borrower: untyped, city_service_code: untyped, federal_service_code: untyped, description: String?, pdf: untyped, xml: untyped, created_on: String?, modified_on: String?) -> instance - def initialize: (id: String?, flow_status: String?, flow_message: String?, status: untyped, environment: untyped, rps_number: untyped, rps_serial_number: untyped, number: untyped, check_code: String?, issued_on: String?, cancelled_on: String?, amount_net: untyped, services_amount: untyped, borrower: untyped, city_service_code: untyped, federal_service_code: untyped, description: String?, pdf: untyped, xml: untyped, created_on: String?, modified_on: String?) -> void + def self.new: (id: String?, flow_status: String?, flow_message: String?, status: untyped, environment: untyped, rps_number: untyped, rps_serial_number: untyped, number: untyped, check_code: String?, issued_on: String?, cancelled_on: String?, amount_net: untyped, services_amount: untyped, borrower: Nfe::ServiceInvoiceBorrower?, city_service_code: untyped, federal_service_code: untyped, description: String?, pdf: untyped, xml: untyped, created_on: String?, modified_on: String?, base_tax_amount: (Float | Integer)?, iss_rate: (Float | Integer)?, iss_tax_amount: (Float | Integer)?, raw: Hash[untyped, untyped]?) -> instance + def initialize: (id: String?, flow_status: String?, flow_message: String?, status: untyped, environment: untyped, rps_number: untyped, rps_serial_number: untyped, number: untyped, check_code: String?, issued_on: String?, cancelled_on: String?, amount_net: untyped, services_amount: untyped, borrower: Nfe::ServiceInvoiceBorrower?, city_service_code: untyped, federal_service_code: untyped, description: String?, pdf: untyped, xml: untyped, created_on: String?, modified_on: String?, base_tax_amount: (Float | Integer)?, iss_rate: (Float | Integer)?, iss_tax_amount: (Float | Integer)?, raw: Hash[untyped, untyped]?) -> void end end diff --git a/sig/nfe/resources/dto/service_invoice_borrower.rbs b/sig/nfe/resources/dto/service_invoice_borrower.rbs new file mode 100644 index 0000000..29eee64 --- /dev/null +++ b/sig/nfe/resources/dto/service_invoice_borrower.rbs @@ -0,0 +1,21 @@ +module Nfe + class ServiceInvoiceBorrower < Data + attr_reader id: String? + attr_reader name: String? + attr_reader federal_tax_number: String? + attr_reader email: String? + attr_reader phone_number: String? + attr_reader address: untyped + attr_reader parent_id: String? + attr_reader raw: Hash[untyped, untyped]? + + def self.from_api: (Hash[untyped, untyped]? payload) -> Nfe::ServiceInvoiceBorrower? + + def self.new: (id: String?, name: String?, federal_tax_number: String?, email: String?, phone_number: String?, address: untyped, parent_id: String?, raw: Hash[untyped, untyped]?) -> instance + + def initialize: (id: String?, name: String?, federal_tax_number: String?, email: String?, phone_number: String?, address: untyped, parent_id: String?, raw: Hash[untyped, untyped]?) -> void + + def []: (untyped key) -> untyped + def dig: (*untyped keys) -> untyped + end +end diff --git a/skills/nfeio-ruby-sdk/SKILL.md b/skills/nfeio-ruby-sdk/SKILL.md index 5caff8e..945982f 100644 --- a/skills/nfeio-ruby-sdk/SKILL.md +++ b/skills/nfeio-ruby-sdk/SKILL.md @@ -11,7 +11,7 @@ description: >- zero dependências de runtime (stdlib only). --- -# NFE.io Ruby SDK (gem `nfe-io`, v1.0) +# NFE.io Ruby SDK (gem `nfe-io`, v1.x) SDK oficial da NFE.io para Ruby. Cliente estilo Stripe, **thread-safe**, **zero dependências de runtime** (só stdlib: `net/http`, `json`, `openssl`, `uri`, @@ -135,7 +135,7 @@ Emissão é tipicamente **assíncrona**. `create` retorna um resultado - **`*Pending`** (HTTP 202): `#invoice_id`, `#location`; `pending? => true`, `issued? => false`. - **`*Issued`** (HTTP 201/200): `#resource` (DTO hidratado); `issued? => true`, `pending? => false`. -Não existe `create_and_wait`/`create_batch` para notas em v1.0. Faça polling: +Não existe `create_and_wait`/`create_batch` para notas na v1.x. Faça polling: ```ruby result = client.service_invoices.create(company_id: id, data: payload) diff --git a/skills/nfeio-ruby-sdk/references/service-invoices-and-polling.md b/skills/nfeio-ruby-sdk/references/service-invoices-and-polling.md index f9f750f..29f15f7 100644 --- a/skills/nfeio-ruby-sdk/references/service-invoices-and-polling.md +++ b/skills/nfeio-ruby-sdk/references/service-invoices-and-polling.md @@ -22,13 +22,19 @@ client.service_invoices.get_status(company_id:, invoice_id:) # => StatusResul `data` é um `Hash` com chaves **camelCase** (serializado como JSON cru). Campos do DTO de leitura `Nfe::ServiceInvoice`: `id`, `flow_status`, `flow_message`, -`status`, `environment`, `rps_number`, `number`, `check_code`, `issued_on`, -`cancelled_on`, `amount_net`, `services_amount`, `borrower`, `city_service_code`, -`federal_service_code`, `description`, `pdf`, `xml`, `created_on`, `modified_on`. +`status`, `environment`, `rps_number`, `rps_serial_number`, `number`, +`check_code`, `issued_on`, `cancelled_on`, `amount_net`, `services_amount`, +`borrower`, `city_service_code`, `federal_service_code`, `description`, +`created_on`, `modified_on`, `base_tax_amount`, `iss_rate`, `iss_tax_amount` e +`raw` (payload completo — retenções, `provider`, `taxationType` etc. via +`invoice.raw["..."]`). `borrower` é `Nfe::ServiceInvoiceBorrower` tipado +(`federal_tax_number` sempre String; leituras Hash `borrower["..."]` seguem +funcionando). `pdf`/`xml` são fantasmas **deprecated** (sempre `nil`) — use +`download_pdf`/`download_xml`. ## Emitir + esperar (polling manual) -Não há `create_and_wait` em v1.0. Padrão recomendado: +Não há `create_and_wait` na v1.x. Padrão recomendado: ```ruby result = client.service_invoices.create( diff --git a/spec/nfe/resources/dto/service_invoice_alignment_spec.rb b/spec/nfe/resources/dto/service_invoice_alignment_spec.rb new file mode 100644 index 0000000..6d2cd4a --- /dev/null +++ b/spec/nfe/resources/dto/service_invoice_alignment_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "yaml" + +# Alignment test pinning Nfe::ServiceInvoice to the NFS-e retrieve contract in +# the official OpenAPI spec (openapi/nf-servico-v1.yaml). The success response +# is declared INLINE (only the error model is componentized), so the DTO is +# hand-written — this spec keeps it honest against the schema, and doubles as +# the migration tripwire: when the upstream componentizes the response (the +# inline schema becomes a $ref), the path-anchored dig fails loudly as the +# signal to migrate to the generated model. +# +# Anchored by PATH, not by operationId: ServiceInvoices_idGet collides between +# /serviceinvoices/{id} and /serviceinvoices/external/{id}. +RSpec.describe Nfe::ServiceInvoice do + let(:openapi) do + YAML.safe_load_file(File.expand_path("../../../../openapi/nf-servico-v1.yaml", __dir__)) + end + + let(:retrieve_schema) do + openapi.dig( + "paths", "/v1/companies/{company_id}/serviceinvoices/{id}", "get", + "responses", "200", "content", "application/json", "schema" + ) + end + + let(:schema_fields) { retrieve_schema.fetch("properties") } + + # Members that deliberately have no schema counterpart: the raw escape-hatch + # and the deprecated ghosts (pinned below). + let(:unmapped_members) { %i[raw pdf xml] } + + def snake_case(camel) + camel.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" } + end + + it "anchors the inline retrieve schema by path (componentization tripwire)" do + expect(retrieve_schema).to be_a(Hash), "retrieve schema not found at the anchored path" + expect(retrieve_schema["properties"]).to be_a(Hash), + "retrieve 200 schema is no longer inline (componentized upstream?) — " \ + "time to migrate Nfe::ServiceInvoice to the generated model" + end + + it "maps every typed member to a schema property" do + typed = described_class.members - unmapped_members + spec_fields = schema_fields.keys.map { |key| snake_case(key).to_sym } + + expect(typed - spec_fields).to be_empty, + "typed members absent from the spec: #{(typed - spec_fields).join(', ')}" + end + + # Deliberate deviation, pinned: pdf/xml are ghost members kept only for + # backward compatibility (@deprecated) — the retrieve response has no such + # fields. If a spec sync adds them, drop the deprecation instead. + it "pins the pdf/xml ghosts as absent from the schema" do + expect(schema_fields.keys).not_to include("pdf", "xml") + end + + it "maps every typed Borrower member to the borrower sub-schema" do + borrower_fields = schema_fields.fetch("borrower").fetch("properties") + typed = Nfe::ServiceInvoiceBorrower.members - %i[raw] + spec_fields = borrower_fields.keys.map { |key| snake_case(key).to_sym } + + expect(typed - spec_fields).to be_empty, + "Borrower members absent from the spec: #{(typed - spec_fields).join(', ')}" + end + + # Deliberate deviation, pinned: the spec declares borrower.federalTaxNumber + # as an int64, but the alphanumeric CNPJ (IN RFB 2.229/2024) requires string + # tolerance — the DTO normalizes to String via Company.stringify. If a spec + # sync turns this into a string, this test fails as the signal to drop the + # deviation note. + it "pins the borrower.federalTaxNumber int-enum deviation (DTO normalizes to String)" do + federal = schema_fields.fetch("borrower").fetch("properties").fetch("federalTaxNumber") + expect(federal["type"]).to eq("integer"), + "federalTaxNumber is no longer an integer in the spec — " \ + "drop the wire-deviation pin and revisit the stringify note" + expect(federal["format"]).to eq("int64") + end +end diff --git a/spec/nfe/resources/dto/service_invoice_borrower_spec.rb b/spec/nfe/resources/dto/service_invoice_borrower_spec.rb new file mode 100644 index 0000000..d995464 --- /dev/null +++ b/spec/nfe/resources/dto/service_invoice_borrower_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe Nfe::ServiceInvoiceBorrower do + describe ".from_api" do + let(:payload) do + { + "id" => "np_1", + "name" => "ACME LTDA", + "federalTaxNumber" => 191, + "email" => "fiscal@acme.com.br", + "phoneNumber" => "11999999999", + "address" => { "city" => { "name" => "São Paulo" }, "state" => "SP" }, + "parentId" => "co_1" + } + end + + it "maps camelCase keys onto snake_case members" do + borrower = described_class.from_api(payload) + + expect(borrower.id).to eq("np_1") + expect(borrower.name).to eq("ACME LTDA") + expect(borrower.email).to eq("fiscal@acme.com.br") + expect(borrower.phone_number).to eq("11999999999") + expect(borrower.parent_id).to eq("co_1") + expect(borrower.address).to eq({ "city" => { "name" => "São Paulo" }, "state" => "SP" }) + end + + it "normalizes an Integer federalTaxNumber to String" do + expect(described_class.from_api(payload).federal_tax_number).to eq("191") + end + + it "preserves a String federalTaxNumber (alphanumeric CNPJ)" do + borrower = described_class.from_api(payload.merge("federalTaxNumber" => "12ABC34501DE35")) + expect(borrower.federal_tax_number).to eq("12ABC34501DE35") + end + + it "returns nil for a nil payload and is immutable" do + expect(described_class.from_api(nil)).to be_nil + expect(described_class.from_api(payload)).to be_frozen + end + end + + describe "Hash-compatibility bridge" do + let(:borrower) do + described_class.from_api( + "name" => "ACME LTDA", + "federalTaxNumber" => 191, + "address" => { "city" => { "name" => "São Paulo" } } + ) + end + + it "keeps Hash-style reads working against the raw wire keys" do + expect(borrower["name"]).to eq("ACME LTDA") + expect(borrower["federalTaxNumber"]).to eq(191) # wire value, untouched + end + + it "supports nested dig over the raw payload" do + expect(borrower.dig("address", "city", "name")).to eq("São Paulo") + end + + it "returns nil for absent keys, like a Hash" do + expect(borrower["missing"]).to be_nil + expect(borrower.dig("address", "missing")).to be_nil + end + end +end diff --git a/spec/nfe/resources/dto/service_invoice_spec.rb b/spec/nfe/resources/dto/service_invoice_spec.rb index 4a3034a..5e583ad 100644 --- a/spec/nfe/resources/dto/service_invoice_spec.rb +++ b/spec/nfe/resources/dto/service_invoice_spec.rb @@ -18,7 +18,11 @@ "cityServiceCode" => "01234", "description" => "Serviço X", "createdOn" => "2026-01-01T00:00:00Z", - "modifiedOn" => "2026-01-02T00:00:00Z" + "modifiedOn" => "2026-01-02T00:00:00Z", + "baseTaxAmount" => 1000.0, + "issRate" => 0.05, + "issTaxAmount" => 50.0, + "borrower" => { "name" => "ACME LTDA", "federalTaxNumber" => 191 } } end @@ -35,11 +39,46 @@ expect(invoice.modified_on).to eq("2026-01-02T00:00:00Z") end - it "drops unknown keys and tolerates missing fields" do - invoice = described_class.from_api("id" => "si_2", "unknownKey" => "ignored") + it "maps the ISS tax fields" do + invoice = described_class.from_api(payload) + + expect(invoice.base_tax_amount).to eq(1000.0) + expect(invoice.iss_rate).to eq(0.05) + expect(invoice.iss_tax_amount).to eq(50.0) + end + + it "hydrates borrower into a ServiceInvoiceBorrower" do + borrower = described_class.from_api(payload).borrower + + expect(borrower).to be_a(Nfe::ServiceInvoiceBorrower) + expect(borrower.name).to eq("ACME LTDA") + end + + it "preserves unknown keys under raw and tolerates missing fields" do + invoice = described_class.from_api( + "id" => "si_2", + "taxationType" => "WithinCity", + "issAmountWithheld" => 12.5, + "provider" => { "id" => "co_1" } + ) + expect(invoice.id).to eq("si_2") expect(invoice.flow_status).to be_nil - expect(invoice).not_to respond_to(:unknown_key) + expect(invoice).not_to respond_to(:taxation_type) + expect(invoice.raw["taxationType"]).to eq("WithinCity") + expect(invoice.raw["issAmountWithheld"]).to eq(12.5) + expect(invoice.raw.dig("provider", "id")).to eq("co_1") + end + + it "keeps raw as the complete payload, including typed fields" do + invoice = described_class.from_api(payload) + expect(invoice.raw).to eq(payload) + end + + it "leaves the deprecated pdf/xml ghosts nil on a real retrieve payload" do + invoice = described_class.from_api(payload) + expect(invoice.pdf).to be_nil + expect(invoice.xml).to be_nil end it "returns nil for a nil payload and is immutable" do diff --git a/spec/nfe/resources/service_invoices_spec.rb b/spec/nfe/resources/service_invoices_spec.rb index 9ffd68c..2c35d19 100644 --- a/spec/nfe/resources/service_invoices_spec.rb +++ b/spec/nfe/resources/service_invoices_spec.rb @@ -102,6 +102,12 @@ def last_request expect(last_request.url).to eq("https://api.nfe.io/v1/companies/co_1/serviceinvoices/si_1") end + it "preserves the complete wire payload under raw" do + transport.enqueue(json(body: { "id" => "si_1", "taxationType" => "WithinCity" }.to_json)) + invoice = invoices.retrieve(company_id: "co_1", invoice_id: "si_1") + expect(invoice.raw).to eq({ "id" => "si_1", "taxationType" => "WithinCity" }) + end + it "raises NotFoundError on 404" do transport.enqueue(json(status: 404, body: "{}")) expect { invoices.retrieve(company_id: "co_1", invoice_id: "missing") } diff --git a/spec/nfe/version_spec.rb b/spec/nfe/version_spec.rb index 0f38fff..2a22cb7 100644 --- a/spec/nfe/version_spec.rb +++ b/spec/nfe/version_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Nfe do - it "pins the major version at 1.0.0" do - expect(Nfe::VERSION).to eq("1.0.0") + it "pins the gem version" do + expect(Nfe::VERSION).to eq("1.1.0") end end