From e8f98e6d84901a9c154aada265d7b27fd97486bc Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 9 Jul 2026 22:09:36 -0300 Subject: [PATCH] =?UTF-8?q?feat(invoice):=20expande=20o=20DTO=20ServiceInv?= =?UTF-8?q?oice=20(campos=20tipados,=20Borrower,=20raw)=20=E2=80=94=20rele?= =?UTF-8?q?ase=20v3.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mitigação do lado do SDK para o DTO de NFS-e incompleto, enquanto o upstream (nfe/docs) não componentiza a resposta de sucesso. - AbstractResource::hydrate() agora popula `raw` de qualquer DTO que o declare (payload decodificado completo) — o escape-hatch de forward-compat vira consistente em todo o SDK (antes só Webhooks/lookups o faziam à mão). Também hidrata DTOs aninhados (Nfe\...) recursivamente. Nenhum campo do fio se perde. - ServiceInvoice ganha campos tipados de alto valor: number, checkCode, description, cityServiceCode, baseTaxAmount, issRate, issTaxAmount, amountNet (anexados ao fim do construtor — aditivo). - Novo DTO aninhado Borrower (federalTaxNumber int|string|null, tolerante a CPF/CNPJ); provider (pesado) permanece via raw. - Teste de alinhamento YAML↔DTO (ServiceInvoiceSpecAlignmentTest) ancorado por PATH /serviceinvoices/{id} — o operationId ServiceInvoices_idGet colide com /external/{id}. - totalAmount marcado @deprecated (a API nunca o retorna; sempre null). - Sample e docs/recursos/service-invoices.md atualizados. SemVer: minor. Tudo aditivo; totalAmount deprecado (não removido). É uma PONTE: quando o nfe/docs componentizar a resposta + re-sync, o modelo migra para o tipo gerado, protegido pelo teste de alinhamento. Ref: OpenSpec change expand-service-invoice-dto. --- CHANGELOG.md | 29 +++++ VERSION | 2 +- docs/recursos/service-invoices.md | 34 ++++++ samples/service-invoice-issue.php | 4 +- src/Resource/AbstractResource.php | 37 +++++- src/Resource/Dto/ServiceInvoices/Borrower.php | 36 ++++++ .../Dto/ServiceInvoices/ServiceInvoice.php | 28 +++-- src/Version.php | 2 +- .../ServiceInvoiceSpecAlignmentTest.php | 77 +++++++++++++ .../Resource/ServiceInvoicesResourceTest.php | 106 ++++++++++++++++++ 10 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 src/Resource/Dto/ServiceInvoices/Borrower.php create mode 100644 tests/Resource/ServiceInvoiceSpecAlignmentTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4571921..275a264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +## [3.3.0] — 2026-07-09 + +### Adicionado + +- Campos tipados de alto valor no DTO `ServiceInvoice` (NFS-e): `number` (número + fiscal), `checkCode` (código de verificação), `description`, `cityServiceCode`, + `baseTaxAmount`, `issRate`, `issTaxAmount`, `amountNet` — anexados ao fim do + construtor (aditivo) (`expand-service-invoice-dto`). +- DTO aninhado `Nfe\Resource\Dto\ServiceInvoices\Borrower`, exposto em + `ServiceInvoice::$borrower`. `federalTaxNumber` é `int|string|null` (tolerante a + CPF/CNPJ; desvio deliberado vs. o `integer` do spec, pinado por teste de + alinhamento). O `provider` (objeto pesado) permanece acessível via `raw`. +- `AbstractResource::hydrate()` passa a **popular o campo `raw`** de qualquer DTO + que o declare, com o payload decodificado completo — o escape-hatch de + forward-compat agora é consistente em todo o SDK (antes, só `WebhooksResource` e + os lookups o preenchiam à mão). Efeito: todo campo que a API retorna fica + acessível via `$dto->raw['campo']`, mesmo sem tipo. Também passa a hidratar DTOs + aninhados (`Nfe\...`) recursivamente. +- Teste de alinhamento YAML↔DTO para a resposta de NFS-e + (`ServiceInvoiceSpecAlignmentTest`), ancorado pelo **path** + `/serviceinvoices/{id}` (o `operationId` `ServiceInvoices_idGet` colide com a + rota `/external/{id}`). + +### Depreciado + +- `ServiceInvoice::$totalAmount` — a API **nunca** o retorna (sempre `null`; + confirmado ao vivo). Use `servicesAmount`/`amountNet`, ou `raw` para outros + valores. Comportamento inalterado; remoção na próxima major. + ## [3.2.0] — 2026-07-06 ### ⚠️ Mudança de comportamento — retry de `POST` diff --git a/VERSION b/VERSION index 944880f..15a2799 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.2.0 +3.3.0 diff --git a/docs/recursos/service-invoices.md b/docs/recursos/service-invoices.md index 92bc7ad..36c470c 100644 --- a/docs/recursos/service-invoices.md +++ b/docs/recursos/service-invoices.md @@ -157,6 +157,40 @@ Com a emissão retry-safe acima, não é mais necessário manter dois `Nfe\Clien para tudo e use `externalId` na emissão. ::: +## Campos da nota (DTO `ServiceInvoice`) + +O `ServiceInvoice` é **deliberadamente parcial**: os campos de alto valor são tipados; qualquer +outro campo que a API devolva fica acessível via `->raw` (sempre populado). + +```php +$invoice = $nfe->serviceInvoices->retrieve($companyId, $invoiceId); + +// Tipados (autocomplete + tipo): +$invoice->number; // ?int — número fiscal da nota +$invoice->checkCode; // ?string — código de verificação +$invoice->description; // ?string +$invoice->cityServiceCode; // ?string +$invoice->issRate; // ?float — alíquota de ISS +$invoice->issTaxAmount; // ?float — valor de ISS +$invoice->baseTaxAmount; // ?float +$invoice->amountNet; // ?float +$invoice->servicesAmount; // ?float + +// Tomador tipado (DTO aninhado Nfe\Resource\Dto\ServiceInvoices\Borrower): +$invoice->borrower->name; +$invoice->borrower->federalTaxNumber; // int|string|null (CPF ou CNPJ) + +// Qualquer outro campo do fio — via raw (ex.: prestador, retenções): +$invoice->raw['provider']['tradeName']; +$invoice->raw['inssAmountWithheld']; +``` + +:::note `raw` é o fallback; campos tipados são a via preferida +`raw` carrega o payload cru completo (forward-compat). Para os campos tipados acima, prefira as +propriedades. O `provider` (objeto pesado) fica só no `raw`. `totalAmount` é `@deprecated` +(a API nunca o retorna — use `servicesAmount`/`amountNet`). +::: + ## Baixar PDF e XML (bytes crus) Os downloads retornam uma `string` com os bytes do arquivo — grave com diff --git a/samples/service-invoice-issue.php b/samples/service-invoice-issue.php index 845d642..dbd18e6 100644 --- a/samples/service-invoice-issue.php +++ b/samples/service-invoice-issue.php @@ -62,7 +62,9 @@ } while (!FlowStatus::isTerminal($invoice->flowStatus)); if ($invoice->flowStatus === 'Issued') { - echo "✓ Nota emitida: id={$invoice->id}, número={$invoice->number}\n"; + echo "✓ Nota emitida: id={$invoice->id}, número={$invoice->number}, código={$invoice->checkCode}\n"; + // Campos não tipados continuam acessíveis via ->raw (ex.: tributos): + echo " ISS: base={$invoice->baseTaxAmount} alíquota={$invoice->issRate} valor={$invoice->issTaxAmount}\n"; exit(0); } diff --git a/src/Resource/AbstractResource.php b/src/Resource/AbstractResource.php index 58e5ec7..6611079 100644 --- a/src/Resource/AbstractResource.php +++ b/src/Resource/AbstractResource.php @@ -14,6 +14,8 @@ use Nfe\Http\Response; use Nfe\Response\InvoiceResponse; use ReflectionClass; +use ReflectionNamedType; +use ReflectionType; /** * Base class for every resource. @@ -275,11 +277,20 @@ protected function hydrate(string $class, array $data): object return $instance; } + // Forward-compat escape hatch: DTOs that declare a `raw` constructor + // parameter get the full decoded payload injected there, so every field + // the API returns stays reachable via `$dto->raw['field']` even when not + // typed. Skipped when the caller already provided `raw` explicitly + // (e.g. WebhooksResource passes the unwrapped envelope). + $rawSnapshot = $data; + $args = []; foreach ($constructor->getParameters() as $param) { $name = $param->getName(); - if (array_key_exists($name, $data)) { - $args[] = $data[$name]; + if ($name === 'raw' && !array_key_exists('raw', $data)) { + $args[] = $rawSnapshot; + } elseif (array_key_exists($name, $data)) { + $args[] = $this->hydrateValue($param->getType(), $data[$name]); } elseif ($param->isDefaultValueAvailable()) { $args[] = $param->getDefaultValue(); } else { @@ -292,6 +303,28 @@ protected function hydrate(string $class, array $data): object return $instance; } + /** + * Recursively hydrate a nested DTO when a constructor parameter is typed as + * a `Nfe\` class and the value is an associative array. Everything else + * (scalars, arrays for `?array` params, unions, already-built objects) is + * passed through unchanged. + */ + private function hydrateValue(?ReflectionType $type, mixed $value): mixed + { + if ( + is_array($value) + && $type instanceof ReflectionNamedType + && !$type->isBuiltin() + && str_starts_with($type->getName(), 'Nfe\\') + && class_exists($type->getName()) + ) { + /** @var array $value */ + return $this->hydrate($type->getName(), $value); + } + + return $value; + } + /** * @return array */ diff --git a/src/Resource/Dto/ServiceInvoices/Borrower.php b/src/Resource/Dto/ServiceInvoices/Borrower.php new file mode 100644 index 0000000..c42cf9e --- /dev/null +++ b/src/Resource/Dto/ServiceInvoices/Borrower.php @@ -0,0 +1,36 @@ +|null $address Endereço do tomador (mantido como array + * nesta iteração — 11 campos, raramente lido de volta). + * @param array|null $raw Payload cru do tomador (forward-compat). + */ + public function __construct( + public ?string $name = null, + public int|string|null $federalTaxNumber = null, + public ?string $email = null, + public ?string $phoneNumber = null, + public ?string $id = null, + public ?string $parentId = null, + public ?array $address = null, + public ?array $raw = null, + ) {} +} diff --git a/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php b/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php index 676823c..39ad37e 100644 --- a/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php +++ b/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php @@ -7,9 +7,12 @@ /** * Hand-written DTO for a service invoice (NFS-e). * - * The OpenAPI spec (`service-invoice-rtc-v1.yaml`) provides request types but - * does not include a canonical response type for the materialised invoice. This - * DTO captures the fields the Node SDK and the WHMCS module actively read. + * The `nf-servico-v1.yaml` spec declares the response shape **inline** (no named, + * reusable schema), so the codegen does not produce a model — this DTO is + * maintained by hand. It is **deliberately partial**: the high-value fields a + * consumer routinely reads are typed; everything else the API returns stays + * reachable via {@see self::$raw} (always populated). A YAML↔DTO alignment test + * pins the typed fields to the spec. * * All fields are nullable because (a) the API omits some fields depending on * processing state and (b) we want hydration to tolerate partial payloads. @@ -17,8 +20,10 @@ final readonly class ServiceInvoice { /** - * @param array|null $raw Original raw payload from the API - * (kept for forward-compat with fields not yet typed here). + * @param float|null $totalAmount @deprecated A API nunca retorna este campo (sempre `null`). + * Use `servicesAmount`/`amountNet`, ou `raw` para outros valores. Será removido na próxima major. + * @param array|null $raw Payload cru completo da API — escape-hatch para + * qualquer campo não tipado aqui (`$invoice->raw['field']`). Sempre populado. */ public function __construct( public ?string $id = null, @@ -35,8 +40,17 @@ public function __construct( public ?float $servicesAmount = null, public ?float $totalAmount = null, public ?array $raw = null, - // Appended last so it is purely additive to the constructor signature - // (hydration is by-name, so position is irrelevant to the SDK itself). + // Campos abaixo anexados ao fim do construtor: puramente aditivos + // (a hidratação é por nome, então a posição é irrelevante para o SDK). public ?string $externalId = null, + public ?int $number = null, + public ?string $checkCode = null, + public ?string $description = null, + public ?string $cityServiceCode = null, + public ?float $baseTaxAmount = null, + public ?float $issRate = null, + public ?float $issTaxAmount = null, + public ?float $amountNet = null, + public ?Borrower $borrower = null, ) {} } diff --git a/src/Version.php b/src/Version.php index 67035b6..e1a3ec3 100644 --- a/src/Version.php +++ b/src/Version.php @@ -12,5 +12,5 @@ */ final class Version { - public const CURRENT = '3.2.0'; + public const CURRENT = '3.3.0'; } diff --git a/tests/Resource/ServiceInvoiceSpecAlignmentTest.php b/tests/Resource/ServiceInvoiceSpecAlignmentTest.php new file mode 100644 index 0000000..bca20f4 --- /dev/null +++ b/tests/Resource/ServiceInvoiceSpecAlignmentTest.php @@ -0,0 +1,77 @@ + Propriedades da resposta 200 do retrieve. */ +function nfServicoRetrieveProps(): array +{ + static $props = null; + if ($props === null) { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-servico-v1.yaml'); + $props = $spec['paths']['/v1/companies/{company_id}/serviceinvoices/{id}']['get']['responses']['200']['content']['application/json']['schema']['properties'] ?? null; + expect($props)->toBeArray(); + } + + return $props; +} + +/** + * @param class-string $dto + * @return list + */ +function dtoConstructorFields(string $dto): array +{ + return array_map( + fn(ReflectionParameter $p): string => $p->getName(), + (new ReflectionClass($dto))->getConstructor()->getParameters(), + ); +} + +it('ServiceInvoice typed fields are a subset of the retrieve response schema', function (): void { + $specFields = array_keys(nfServicoRetrieveProps()); + + // `raw` é o escape-hatch (não é campo do fio); `totalAmount` é o phantom + // deprecado (ver o teste abaixo). Todo o resto DEVE existir no spec. + $typed = array_diff(dtoConstructorFields(ServiceInvoice::class), ['raw', 'totalAmount']); + + expect(array_values(array_diff($typed, $specFields)))->toBe([]); +}); + +it('Borrower typed fields are a subset of the spec borrower schema', function (): void { + $borrowerProps = nfServicoRetrieveProps()['borrower']['properties'] ?? null; + expect($borrowerProps)->toBeArray(); + + $typed = array_diff(dtoConstructorFields(Borrower::class), ['raw']); + + expect(array_values(array_diff($typed, array_keys($borrowerProps))))->toBe([]); +}); + +it('pins totalAmount as a phantom field absent from the spec', function (): void { + // totalAmount é @deprecated exatamente porque a API nunca o retorna. Se um + // sync de spec passar a declará-lo, esta falha é o sinal para reavaliar a + // deprecação (tipar de verdade a partir do spec). + expect(array_keys(nfServicoRetrieveProps()))->not->toContain('totalAmount'); +}); + +it('the retrieve path exists and its operationId collides (why we anchor by path)', function (): void { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-servico-v1.yaml'); + $retrieveId = $spec['paths']['/v1/companies/{company_id}/serviceinvoices/{id}']['get']['operationId'] ?? null; + $externalId = $spec['paths']['/v1/companies/{company_id}/serviceinvoices/external/{id}']['get']['operationId'] ?? null; + + // Documenta a colisão: por isso o alinhamento acima resolve por PATH. + expect($retrieveId)->toBe($externalId); +}); diff --git a/tests/Resource/ServiceInvoicesResourceTest.php b/tests/Resource/ServiceInvoicesResourceTest.php index 7faad07..ff6d88c 100644 --- a/tests/Resource/ServiceInvoicesResourceTest.php +++ b/tests/Resource/ServiceInvoicesResourceTest.php @@ -8,6 +8,7 @@ use Nfe\Exception\NotFoundException; use Nfe\Http\Response; use Nfe\Http\RetryPolicy; +use Nfe\Resource\Dto\ServiceInvoices\Borrower; use Nfe\Resource\Dto\ServiceInvoices\ServiceInvoice; use Nfe\Resource\ServiceInvoicesResource; use Nfe\Response\ServiceInvoiceIssued; @@ -129,6 +130,111 @@ function buildSvcClient(MockTransport $mock): Client expect($list->page->pageCount)->toBe(2); }); +// --- raw escape hatch (expand-service-invoice-dto) --- + +it('populates raw with untyped fields on retrieve', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"id":"inv-1","flowStatus":"Issued","paidAmount":12.34,"rpsStatus":"Normal"}', + )); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->raw)->not->toBeNull(); + expect($invoice->raw['paidAmount'])->toBe(12.34); + expect($invoice->raw['rpsStatus'])->toBe('Normal'); +}); + +it('populates raw per item on list', function (): void { + $payload = json_encode([ + 'serviceInvoices' => [ + ['id' => 'a', 'checkCode' => 'AAA', 'taxationType' => 'WithinCity'], + ['id' => 'b', 'checkCode' => 'BBB'], + ], + ]); + $mock = (new MockTransport())->push(new Response(200, [], (string) $payload)); + $client = buildSvcClient($mock); + + $list = $client->serviceInvoices->list('abc'); + + expect($list->data[0]->raw['taxationType'])->toBe('WithinCity'); + expect($list->data[1]->raw['checkCode'])->toBe('BBB'); +}); + +// --- typed high-value fields + Borrower (expand-service-invoice-dto) --- + +it('hydrates high-value typed fields on retrieve', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], json_encode([ + 'id' => 'inv-1', + 'number' => 4, + 'checkCode' => '440944027552914', + 'description' => 'Consultoria', + 'cityServiceCode' => '2690', + 'baseTaxAmount' => 100.0, + 'issRate' => 0.05, + 'issTaxAmount' => 5.0, + 'amountNet' => 95.0, + ]))); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->number)->toBe(4); + expect($invoice->checkCode)->toBe('440944027552914'); + expect($invoice->description)->toBe('Consultoria'); + expect($invoice->cityServiceCode)->toBe('2690'); + expect($invoice->issTaxAmount)->toBe(5.0); + expect($invoice->amountNet)->toBe(95.0); +}); + +it('hydrates borrower into a typed Borrower DTO', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], json_encode([ + 'id' => 'inv-1', + 'borrower' => [ + 'name' => 'Cliente Ltda', + 'federalTaxNumber' => 11444555000149, + 'email' => 'cli@example.com', + 'address' => ['postalCode' => '01310-100', 'city' => ['name' => 'São Paulo']], + ], + ]))); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->borrower)->toBeInstanceOf(Borrower::class); + expect($invoice->borrower->name)->toBe('Cliente Ltda'); + expect($invoice->borrower->federalTaxNumber)->toBe(11444555000149); + expect($invoice->borrower->address)->toBeArray(); + expect($invoice->borrower->raw['email'])->toBe('cli@example.com'); +}); + +it('borrower federalTaxNumber tolerates a string (no TypeError)', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], json_encode([ + 'id' => 'inv-1', + 'borrower' => ['name' => 'Fulano', 'federalTaxNumber' => '01234567890'], + ]))); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->borrower->federalTaxNumber)->toBe('01234567890'); +}); + +it('keeps the heavy provider object in raw, not typed', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], json_encode([ + 'id' => 'inv-1', + 'provider' => ['tradeName' => 'Minha Empresa', 'municipalTaxNumber' => '123'], + ]))); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->raw['provider']['tradeName'])->toBe('Minha Empresa'); + expect(property_exists($invoice, 'provider'))->toBeFalse(); +}); + // --- externalId reconciliation (add-safe-retry-idempotency) --- it('hydrates externalId from a retrieve payload', function (): void {