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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.2.0
3.3.0
34 changes: 34 additions & 0 deletions docs/recursos/service-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion samples/service-invoice-issue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
37 changes: 35 additions & 2 deletions src/Resource/AbstractResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Nfe\Http\Response;
use Nfe\Response\InvoiceResponse;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionType;

/**
* Base class for every resource.
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string, mixed> $value */
return $this->hydrate($type->getName(), $value);
}

return $value;
}

/**
* @return array<string, mixed>
*/
Expand Down
36 changes: 36 additions & 0 deletions src/Resource/Dto/ServiceInvoices/Borrower.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Nfe\Resource\Dto\ServiceInvoices;

/**
* Tomador (borrower) de uma NFS-e, como devolvido na resposta da API.
*
* `federalTaxNumber` é tipado como união `int|string|null` (não `?int`): o
* tomador pode ser CPF ou CNPJ e, como o spec declara `integer` mas a hidratação
* é strict-typed, uma string no fio não pode estourar `TypeError`. É um
* alargamento deliberado vs. o `integer` do OpenAPI, pinado pelo teste de
* alinhamento.
*
* O `provider` (prestador — objeto pesado) NÃO é tipado; fica acessível via
* `ServiceInvoice::$raw['provider']`.
*/
final readonly class Borrower
{
/**
* @param array<string, mixed>|null $address Endereço do tomador (mantido como array
* nesta iteração — 11 campos, raramente lido de volta).
* @param array<string, mixed>|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,
) {}
}
28 changes: 21 additions & 7 deletions src/Resource/Dto/ServiceInvoices/ServiceInvoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@
/**
* 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.
*/
final readonly class ServiceInvoice
{
/**
* @param array<string, mixed>|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<string, mixed>|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,
Expand All @@ -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,
) {}
}
2 changes: 1 addition & 1 deletion src/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
*/
final class Version
{
public const CURRENT = '3.2.0';
public const CURRENT = '3.3.0';
}
77 changes: 77 additions & 0 deletions tests/Resource/ServiceInvoiceSpecAlignmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

use Nfe\Resource\Dto\ServiceInvoices\Borrower;
use Nfe\Resource\Dto\ServiceInvoices\ServiceInvoice;
use Symfony\Component\Yaml\Yaml;

/**
* Amarra o DTO ServiceInvoice (e o aninhado Borrower) ao schema de resposta da
* NFS-e em openapi/nf-servico-v1.yaml. Como a resposta de sucesso é declarada
* INLINE (sem schema nomeado), o codegen não a cobre — o alinhamento é
* verificado por parse direto do YAML.
*
* IMPORTANTE: ancorado pelo PATH `/serviceinvoices/{id}`, NÃO pelo operationId —
* `ServiceInvoices_idGet` colide entre `/{id}` e `/external/{id}` neste spec.
*/

/** @return array<string, mixed> 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<string>
*/
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);
});
Loading
Loading