Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 2 additions & 2 deletions docs/async-and-polling.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion docs/recursos/service-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/nfe/generated.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lib/nfe/generated/generated_marker.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions lib/nfe/generated/nf_servico_v1/errors_resource.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 34 additions & 8 deletions lib/nfe/resources/dto/service_invoice.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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?

Expand All @@ -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
59 changes: 59 additions & 0 deletions lib/nfe/resources/dto/service_invoice_borrower.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/nfe/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Nfe
VERSION = "1.0.0"
VERSION = "1.1.0"
end
12 changes: 9 additions & 3 deletions openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Loading