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
17 changes: 11 additions & 6 deletions skills/nfeio-php-sdk/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: nfeio-php-sdk
description: "NFE.io PHP SDK integration expert (Composer package: nfe/nfe, namespace Nfe\\). MUST trigger when: composer.json requires 'nfe/nfe' or code has `use Nfe\\Client`, `new Nfe\\Client(`, or references the Nfe\\ namespace; user mentions NFE.io, NFS-e, NF-e, NFC-e, CT-e, CFe-SAT, nota fiscal, nota fiscal eletronica, nota fiscal de servico, Brazilian invoice, fiscal document, electronic invoice Brazil, CNPJ lookup, CPF lookup, consulta CEP, service invoice, product invoice, consumer invoice, transportation invoice, tax calculation Brazilian taxes, SEFAZ, emissao de nota; user builds Brazilian electronic fiscal document automation or tax compliance in PHP/Laravel/Symfony/WHMCS/WooCommerce; user mentions polling for invoice status, async invoice processing, webhook signature validation, or certificate management for Brazilian fiscal documents. Covers all 17 SDK resources, async invoice creation via native union return types (instanceof), MANUAL polling (there is no createAndWait), the Nfe\\Exception hierarchy, 1-based pagination, string-byte downloads, the static Nfe\\Webhook signature helper, dual API keys (dataApiKey), CNPJ/CPF/CEP lookups, and tax calculation. Use this skill even if the user doesn't name the SDK -- if they build Brazilian fiscal document automation in PHP, it applies. Note: this is the PHP SDK; its idioms differ from the Node SDK (nfe-io) -- do NOT port Node patterns blindly."
description: "NFE.io PHP SDK integration expert (Composer package: nfe/nfe, namespace Nfe\\). MUST trigger when: composer.json requires 'nfe/nfe' or code has `use Nfe\\Client`, `new Nfe\\Client(`, or references the Nfe\\ namespace; user mentions NFE.io, NFS-e, NF-e, NFC-e, CT-e, CFe-SAT, nota fiscal, nota fiscal eletronica, nota fiscal de servico, Brazilian invoice, fiscal document, electronic invoice Brazil, CNPJ lookup, CPF lookup, consulta CEP, service invoice, product invoice, consumer invoice, transportation invoice, tax calculation Brazilian taxes, SEFAZ, emissao de nota; user builds Brazilian electronic fiscal document automation or tax compliance in PHP/Laravel/Symfony/WHMCS/WooCommerce; user mentions polling for invoice status, async invoice processing, webhook signature validation, or certificate management for Brazilian fiscal documents. Covers all 17 SDK resources, async invoice creation via native union return types (instanceof), MANUAL polling (there is no createAndWait), method-aware retry (POST is NOT retried on 5xx — safety vs duplicate emission), idempotent NFS-e emission via externalId + findByExternalId reconciliation, typed ServiceInvoice fields (number/checkCode/borrower) with populated raw, the Nfe\\Exception hierarchy, 1-based pagination, string-byte downloads, the static Nfe\\Webhook signature helper, dual API keys (dataApiKey), CNPJ/CPF/CEP lookups, and tax calculation. Use this skill even if the user doesn't name the SDK -- if they build Brazilian fiscal document automation in PHP, it applies. Note: this is the PHP SDK; its idioms differ from the Node SDK (nfe-io) -- do NOT port Node patterns blindly."
---

# NFE.io PHP SDK Integration Guide
Expand Down Expand Up @@ -45,7 +45,7 @@ $nfe = new Nfe\Client(

Auth: the SDK sends `Authorization: Basic <apiKey>` on every request.

For full control, build a `Nfe\Config` and pass it as `config:` (overrides the convenience args). `Config` also accepts a `Nfe\Http\RetryPolicy` (default: 3 retries, base 1s, max 30s, jitter 0.3) and a PSR-3 `logger`.
For full control, build a `Nfe\Config` and pass it as `config:` (overrides the convenience args). `Config` also accepts a `Nfe\Http\RetryPolicy` (default: 3 retries, base 1s, max 30s, jitter 0.3) and a PSR-3 `logger`. Retry is **method-aware**: idempotent methods (GET/PUT/DELETE/HEAD/OPTIONS) retry on 429/5xx/network failures, but **POST is not retried on 5xx** (re-emitting could duplicate an NFS-e — see Pitfall #17). A per-request `Nfe\Http\RequestOptions` can also override the policy for a single call (`retry: Nfe\Http\RetryPolicy::none()` to disable, or a policy to enable it) — see the error-handling reference.

**Dual API keys**: data-service resources (`addresses`, `legalEntityLookup`, `naturalPersonLookup`, `productInvoiceQuery`/`consumerInvoiceQuery`) use `dataApiKey`, falling back to `apiKey` when it is null. **Only those four families honor `dataApiKey`** — see Pitfall #12.

Expand Down Expand Up @@ -167,7 +167,7 @@ Every API error extends **`Nfe\Exception\ApiErrorException`** (which extends `Ru
| `NotFoundException` | 404 |
| `RateLimitException` | 429 |
| `ServerException` | 5xx |
| `ApiConnectionException` | network/cURL failure |
| `ApiConnectionException` | network/cURL failure — also carries `?failurePhase` (`Nfe\Http\FailurePhase`) + `?curlErrno` (both null when unclassified) |
| `SignatureVerificationException` | webhook signature mismatch |

```php
Expand Down Expand Up @@ -256,12 +256,17 @@ Verified against the SDK source. These are exactly where a Node-to-PHP port goes
14. **`addresses` is CEP-only** — `lookupByPostalCode()` is the only method. `search()`/`lookupByTerm()` were removed (those endpoints 404). The response envelope `{ address }` is unwrapped into `->addresses` (a 1-element list).
15. **`createBatch` (`legalPeople`/`naturalPeople`) is sequential** — slower than Node's concurrent batch for large lists.
16. **Webhook signature validation is the static `Nfe\Webhook`** — pass the raw `php://input` body, not a re-encoded array.
17. **Retry is method-aware — POST is NOT retried on 5xx** (nor on ambiguous/unclassified network failures). Idempotent methods (GET/PUT/DELETE/HEAD/OPTIONS) still retry on 429, 5xx, and any network failure; POST retries only on 429 or a provably-unsent connection failure (`Nfe\Http\FailurePhase::ConnectionNotEstablished`) — re-executing a POST could duplicate an NFS-e. For safe emission, send an `externalId` on `create()` and reconcile with `serviceInvoices->findByExternalId()` after an ambiguous failure (a 5xx, or a 400 "already exists" — see `ServiceInvoicesResource::isDuplicateExternalId()`). An `Idempotency-Key` request header restores full retry, but the API does not honor it yet (no server-side dedupe).
18. **`RequestOptions` has a per-request `retry` override** (4th arg: `apiKey, baseUrl, timeout, retry`). `new Nfe\Http\RequestOptions(retry: RetryPolicy::none())` disables retry for a single call; passing a policy re-enables it on a `none()` client. This removes the old two-client workaround (one retrying client for reads, one non-retrying for writes) — keep retry on for everything and use `externalId` for safe emission.
19. **`ServiceInvoice::$totalAmount` is a deprecated phantom — always `null`** (the live API never returns it). Use `servicesAmount`/`amountNet` instead. Since v3.3.0 the DTO exposes `number` (fiscal number), `checkCode`, `description`, `cityServiceCode`, core tax amounts, and a typed `borrower` (`Borrower`; `federalTaxNumber` is `int|string|null`) — and **`->raw` is populated** on every hydrated DTO (per list item too), so untyped fields like `provider` live in `$invoice->raw['provider']`.

## Decision Tree: "I want to…"

| Goal | Resource & method |
|------|-------------------|
| Issue a service invoice (NFS-e) | `$nfe->serviceInvoices->create($companyId, $data)` + manual poll |
| Issue a service invoice (NFS-e) | `$nfe->serviceInvoices->create($companyId, $data)` + manual poll (add an `externalId` for safe/idempotent emission — see Pitfall #17) |
| Reconcile an NFS-e by externalId after an ambiguous failure | `$nfe->serviceInvoices->findByExternalId($companyId, $externalId)` (returns the note or `null` if it was never created) |
| Read the fiscal number / verification code of an issued NFS-e | `$invoice->number` / `$invoice->checkCode` (typed since v3.3.0); untyped fields via `$invoice->raw[...]` |
| Issue a product invoice (NF-e) | `$nfe->productInvoices->create($companyId, $data)` (+ webhook) |
| Issue NF-e scoped to a state tax | `$nfe->productInvoices->createWithStateTax($companyId, $stateTaxId, $data)` |
| Issue a consumer invoice (NFC-e) | `$nfe->consumerInvoices->create($companyId, $data)` |
Expand All @@ -288,7 +293,7 @@ Verified against the SDK source. These are exactly where a Node-to-PHP port goes

Load these for full method signatures and per-resource detail:

- **`references/service-invoices-and-polling.md`** — NFS-e (`serviceInvoices`), `companies` (Account scope, `remove()`, cert read helpers), `legalPeople`/`naturalPeople` (PJ/PF), `webhooks` CRUD, and the full `FlowStatus` terminal/non-terminal table + manual polling recipe.
- **`references/service-invoices-and-polling.md`** — NFS-e (`serviceInvoices`, including `findByExternalId()` + the `externalId` idempotent-emission cycle, and the v3.3.0 `ServiceInvoice` DTO: typed `number`/`checkCode`/`borrower` + populated `raw`), `companies` (Account scope, `remove()`, cert read helpers), `legalPeople`/`naturalPeople` (PJ/PF), `webhooks` CRUD, and the full `FlowStatus` terminal/non-terminal table + manual polling recipe.
- **`references/product-invoices-and-taxes.md`** — NF-e/NFC-e (`productInvoices`/`consumerInvoices`: union return, `createWithStateTax`, cursor pagination + required `environment`, CC-e, EPEC, `disable`/`disableRange`), `stateTaxes` (cursor, `{stateTax}` body envelope), `taxCalculation`, `taxCodes`, `transportationInvoices` (CT-e), `inboundProductInvoices`.
- **`references/data-services-and-lookups.md`** — `legalEntityLookup` (CNPJ), `naturalPersonLookup` (CPF), `addresses` (CEP only), `productInvoiceQuery`/`consumerInvoiceQuery` (44-digit access key), the host map and the `dataApiKey` 4-family rule.
- **`references/error-handling-and-patterns.md`** — the `Nfe\Exception\*` hierarchy, `ErrorFactory`, `RetryPolicy`, the static `Nfe\Webhook` (signature validation + `constructEvent`), and the complete manual-polling pattern.
- **`references/error-handling-and-patterns.md`** — the `Nfe\Exception\*` hierarchy, `ErrorFactory`, `RetryPolicy` (+ method-aware retry, `Nfe\Http\FailurePhase`, and the per-request `RequestOptions` retry override), the static `Nfe\Webhook` (signature validation + `constructEvent`), and the complete manual-polling pattern.
28 changes: 25 additions & 3 deletions skills/nfeio-php-sdk/references/error-handling-and-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ RuntimeException
├── NotFoundException (404)
├── RateLimitException (429)
├── ServerException (5xx)
├── ApiConnectionException (network/cURL failure)
├── ApiConnectionException (network/cURL failure; adds ->failurePhase, ->curlErrno)
└── SignatureVerificationException (webhook signature mismatch)
```

Expand All @@ -40,19 +40,41 @@ try {

## Retry (`Nfe\Http\RetryPolicy`)

- Default (via `Config`): `maxRetries = 3`, `baseDelay = 1.0s`, `maxDelay = 30.0s`, `jitter = 0.3`.
- Default (via `Config`): `maxRetries = 3`, `baseDelay = 1.0s`, `maxDelay = 30.0s`, `jitter = 0.3`. The `RetryPolicy` signature is unchanged since v3.0.
- Disable with `Nfe\Http\RetryPolicy::none()`.
- Transports return a `Response` for HTTP 4xx/5xx (the resource layer decides what to throw); only genuine connection failures raise `ApiConnectionException`. Retries apply to transient conditions, not 4xx.
- Transports return a `Response` for HTTP 4xx/5xx (the resource layer decides what to throw); only genuine connection failures raise `ApiConnectionException`. Retries apply to transient conditions, not to ordinary 4xx client errors — 429 (rate limiting) is the sole 4xx that is retried (see the table below).
- Retry is **method-aware** (`Nfe\Http\RetryingTransport`, since v3.2.0): retrying an idempotent request is always safe, but re-sending a POST can duplicate an NFS-e, so POST is retried far more narrowly.

| Method | 429 | 5xx | network `ConnectionNotEstablished` | network `RequestMaybeSent` / unclassified |
|---|---|---|---|---|
| GET / PUT / DELETE / HEAD / OPTIONS | retry | retry | retry | retry |
| POST | retry | **no** | retry | **no** |
| POST + `Idempotency-Key` header | retry | retry | retry | retry |

- **Behavior change vs v3.1 and earlier:** POST no longer retries on 5xx (it used to). A 5xx can mean the server already issued the note, so re-POSTing would duplicate it — this is a safety fix. 429 is still retried for POST (a rate-limited request is rejected before processing).
- A POST carrying an `Idempotency-Key` request header is treated as idempotent (retried like a GET). Forward-compat only: **the API does not honor the header yet**, so it merely unlocks the retry — it does not make the server dedupe. For safe emission today, send an `externalId` and reconcile after an ambiguous failure (see `service-invoices-and-polling.md`).
- `Retry-After` response header is honored (integer seconds only; HTTP-date form is not).

**Failure phase (`Nfe\Http\FailurePhase`).** Network failures carry a phase on `ApiConnectionException->failurePhase` (plus the raw `->curlErrno`) so the retry layer can tell a safe-to-resend POST from an unsafe one:

- `ConnectionNotEstablished` — the request provably never reached the server (DNS, TCP connect, TLS handshake; cURL errnos 5/6/7/35, or PSR-18 `NetworkExceptionInterface`). Safe to retry any method, including POST.
- `RequestMaybeSent` — the server may have received/processed it (e.g. read timeout after send; errno 28). Unsafe for POST. Unclassified failures (`failurePhase === null`) are treated the same, conservatively.

**Per-request retry override (`RequestOptions->retry`, since v3.2.0).** Every resource method accepts a `RequestOptions` whose optional `retry` (a `RetryPolicy`) overrides the client-level policy for that single call — in both directions. This removes the old two-client workaround (one retrying client for reads, one non-retrying for writes).

```php
use Nfe\Client;
use Nfe\Config;
use Nfe\Http\RequestOptions;
use Nfe\Http\RetryPolicy;

$nfe = new Client(config: new Config(
apiKey: $key,
retry: new RetryPolicy(maxRetries: 5, baseDelay: 0.5, maxDelay: 20.0, jitter: 0.3),
));

// Disable retry for one call only (the rest of the client keeps retrying):
$nfe->serviceInvoices->create($companyId, $data, new RequestOptions(retry: RetryPolicy::none()));
```

## Webhook signature validation — the static `Nfe\Webhook`
Expand Down
39 changes: 39 additions & 0 deletions skills/nfeio-php-sdk/references/service-invoices-and-polling.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ sendEmail(string $companyId, string $invoiceId, ?RequestOptions $options = null)
downloadPdf(string $companyId, string $invoiceId, ?RequestOptions $options = null): string // raw bytes
downloadXml(string $companyId, string $invoiceId, ?RequestOptions $options = null): string
getStatus(string $companyId, string $invoiceId, ?RequestOptions $options = null): array // {flowStatus, flowMessage, ...}
findByExternalId(string $companyId, string $externalId, ?RequestOptions $options = null): ?ServiceInvoice // dedicated /external route (v3.2.0)
static isDuplicateExternalId(ApiErrorException $e): bool // matches the 400 duplicate-externalId rejection (v3.2.0)
```

- `create()` returns a **union** — discriminate with `instanceof Nfe\Response\Pending` / `Issued`. HTTP 202 → `Pending` (built from the `Location` header; no body). A 202 **without** `Location` throws `InvalidRequestException`. HTTP 201 → `Issued`; `$issued->resource()` is a `ServiceInvoice`.
Expand All @@ -21,6 +23,43 @@ getStatus(string $companyId, string $invoiceId, ?RequestOptions $options = null)
- `getStatus()` is unique to service invoices (lightweight status endpoint). Product/consumer have none.
- **No `createAndWait`, no `createBatch`** (deferred post-v3.0).

### ServiceInvoice DTO (v3.3.0)

Typed fields: `id`, `status`, `flowStatus`, `flowMessage`, `environment`, `rpsNumber`, `rpsSerialNumber`, `issuedOn`, `createdOn`, `modifiedOn`, `cancelledOn`, `servicesAmount`, `externalId`, plus (since v3.3.0) **`number`** (the fiscal invoice number, `?int`), **`checkCode`** (verification code), `description`, `cityServiceCode`, `baseTaxAmount`, `issRate`, `issTaxAmount`, `amountNet`, and **`borrower`** (a typed `Borrower` DTO).

- **`raw` is populated** (since v3.3.0): every hydrated `ServiceInvoice` carries the full decoded payload in `->raw` — any field not typed above (e.g. `provider`, `paidAmount`, `rpsStatus`, the withheld-tax breakdown) is reachable as `$invoice->raw['field']`. This holds per item in `list()` results, and SDK-wide for every DTO that declares a `raw` param (webhooks, lookups, product/consumer…).
- **`Borrower`** fields: `name`, `federalTaxNumber` (**`int|string|null`** — the borrower may be CPF or CNPJ; hydration is strict-typed, so the union tolerates both wire forms), `email`, `phoneNumber`, `id`, `parentId`, `address` (`?array`), `raw`.
- **`provider` is NOT typed** — read it via `$invoice->raw['provider']`.
- **`totalAmount` is a deprecated phantom** — the live API never returns it (always `null`). Use `servicesAmount`/`amountNet` or `raw`. Do NOT generate code that reads `->totalAmount`.
- The DTO is pinned to `openapi/nf-servico-v1.yaml` by an alignment test (anchored by path — the spec's `operationId` collides between routes).

### Idempotent emission via externalId (v3.2.0)

`externalId` is a **unique key** on emission: a 2nd `create()` with the same `externalId` is rejected with `400 "service invoice with external id (…) already exists"`. Since POST is no longer retried on 5xx (see `error-handling-and-patterns.md`), the safe-emission cycle is:

```php
use Nfe\Exception\ApiErrorException;
use Nfe\Exception\ServerException;
use Nfe\Resource\ServiceInvoicesResource;

$data['externalId'] = $orderId; // stable across attempts
try {
$result = $nfe->serviceInvoices->create($companyId, $data);
} catch (ApiErrorException $e) {
// duplicate rejection (a retry that was already processed) OR ambiguous 5xx
// (the API can return 500 and STILL create the note — live-confirmed):
if (ServiceInvoicesResource::isDuplicateExternalId($e) || $e instanceof ServerException) {
$invoice = $nfe->serviceInvoices->findByExternalId($companyId, $orderId);
// null → the note truly was not created; safe to re-emit
} else {
throw $e;
}
}
```

- `findByExternalId()` uses the dedicated route `GET /v1/companies/{id}/serviceinvoices/external/{externalId}`. Wire contract: hit = 200 with a **collection envelope** `{"serviceInvoices":[...]}`; miss = **200 with an empty list** (not 404) → returns `null`.
- **Indexing lag:** right after a 202-pending create, `findByExternalId()` may return `null` for a few seconds; re-query with a small backoff before concluding the note does not exist. The `400 "already exists"` rejection is immediate.

### Create payload (typical NFS-e)

```php
Expand Down