From 7e67ad2912e113c361bb5da939aece45cd3f6d5e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:19:31 +0000 Subject: [PATCH 01/29] feat: add typed NT009 draft data for recipients, payments and adjustments Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009BaseAdjustmentData.php | 25 +++++++++++++++++++++++ src/Dto/Nt009LinkedPaymentData.php | 28 ++++++++++++++++++++++++++ src/Dto/Nt009MovableAssetData.php | 24 ++++++++++++++++++++++ src/Dto/Nt009RecipientAddressData.php | 29 +++++++++++++++++++++++++++ src/Dto/Nt009RecipientData.php | 26 ++++++++++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 src/Dto/Nt009BaseAdjustmentData.php create mode 100644 src/Dto/Nt009LinkedPaymentData.php create mode 100644 src/Dto/Nt009MovableAssetData.php create mode 100644 src/Dto/Nt009RecipientAddressData.php create mode 100644 src/Dto/Nt009RecipientData.php diff --git a/src/Dto/Nt009BaseAdjustmentData.php b/src/Dto/Nt009BaseAdjustmentData.php new file mode 100644 index 0000000..6acf41f --- /dev/null +++ b/src/Dto/Nt009BaseAdjustmentData.php @@ -0,0 +1,25 @@ + Date: Fri, 9 Oct 2026 04:19:55 +0000 Subject: [PATCH 02/29] feat: expose opt-in structured NT009 fields in preview input Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009DpsPreviewData.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Dto/Nt009DpsPreviewData.php b/src/Dto/Nt009DpsPreviewData.php index 4a4e0b4..140314d 100644 --- a/src/Dto/Nt009DpsPreviewData.php +++ b/src/Dto/Nt009DpsPreviewData.php @@ -16,6 +16,10 @@ */ final readonly class Nt009DpsPreviewData { + /** + * @param list $pagamentosVinculados + * @param list $bensMoveis + */ public function __construct( /** finNFSe: 0 regular, 1 credit, 2 debit. */ public int $finalidade, @@ -43,6 +47,14 @@ public function __construct( public ?string $valorAjusteCbs = null, /** regApIBSCBSSN 1, 2 or 3, when applicable. */ public ?int $regimeApuracaoSimples = null, + /** Recipient identity is required when indDest=1. */ + public ?Nt009RecipientData $destinatario = null, + /** Explicit new base-adjustment form; never repurpose legacy vDedRed. */ + public ?Nt009BaseAdjustmentData $ajusteBase = null, + /** Up to 1000 movable-rental items for cTribNac 99.04.01. */ + public array $bensMoveis = [], + /** Up to 99 transaction records under IBSCBS/gPgtoVinc. */ + public array $pagamentosVinculados = [], ) { } } From b5c7005eb79fcced5e09c03bba52d0bc0a7b3692 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:21:24 +0000 Subject: [PATCH 03/29] fix: reject deprecated deduction group in NT009 structural preview Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009DpsPreviewBuilder.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Xml/Nt009DpsPreviewBuilder.php b/src/Xml/Nt009DpsPreviewBuilder.php index d209488..bf6b43e 100644 --- a/src/Xml/Nt009DpsPreviewBuilder.php +++ b/src/Xml/Nt009DpsPreviewBuilder.php @@ -209,6 +209,13 @@ private function validatePreview(Nt009DpsPreviewData $preview): void private function rejectLegacyIbsCbs(DpsData $dps): void { + // Legacy vDedRed and NT009 vAjusteBC use different field names, + // alternatives and rules. Never pass the old group through silently. + if ($dps->deducaoReducao !== null) { + throw new \InvalidArgumentException( + 'NT009 preview cannot reuse legacy vDedRed; supply ajusteBase explicitly' + ); + } if ($dps->ibsCbsFinalidade !== null || $dps->ibsCbsIndFinal !== null || $dps->ibsCbsCodigoIndicadorOperacao !== '' From 125becfc7786caa82f66411febaca8cfecff17cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:21:48 +0000 Subject: [PATCH 04/29] feat: serialize NT009 vAjusteBC modes without legacy vDedRed Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009BaseAdjustmentPreviewGroup.php | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/Xml/Nt009BaseAdjustmentPreviewGroup.php diff --git a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php new file mode 100644 index 0000000..4e6db51 --- /dev/null +++ b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php @@ -0,0 +1,56 @@ +percentualIssqn === null) === ($input->valorIssqn === null)) { + throw new \InvalidArgumentException( + 'NT009 vAjusteBC requires exactly one of pAjusteBCISSQN or vAjusteBCISSQN' + ); + } + + $group = $doc->createElement('vAjusteBC'); + if ($input->percentualIssqn !== null) { + $this->assertDecimal($input->percentualIssqn, 3, 'pAjusteBCISSQN'); + $this->append($doc, $group, 'pAjusteBCISSQN', $input->percentualIssqn); + } + if ($input->valorIssqn !== null) { + $this->assertDecimal($input->valorIssqn, 15, 'vAjusteBCISSQN'); + $this->append($doc, $group, 'vAjusteBCISSQN', $input->valorIssqn); + } + if ($input->valorIbsCbsComExterior !== null) { + $this->assertDecimal($input->valorIbsCbsComExterior, 15, 'vAjusteBCIBSCBSComExt'); + $this->append($doc, $group, 'vAjusteBCIBSCBSComExt', $input->valorIbsCbsComExterior); + } + + return $group; + } + + private function assertDecimal(string $value, int $integralDigits, string $field): void + { + if (preg_match('/^[0-9]{1,' . $integralDigits . '}\\.[0-9]{2}$/D', $value) !== 1) { + throw new \InvalidArgumentException('Invalid NT009 decimal field ' . $field); + } + } + + private function append(\DOMDocument $doc, \DOMElement $group, string $name, string $value): void + { + $element = $doc->createElement($name); + $element->appendChild($doc->createTextNode($value)); + $group->appendChild($element); + } +} From 0ccb073b2e5d8f14ea1cb6d36a0ebdf5609a290c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:22:03 +0000 Subject: [PATCH 05/29] feat: validate bounded NT009 payment linkage records and uniqueness Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009LinkedPaymentsPreviewGroup.php | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/Xml/Nt009LinkedPaymentsPreviewGroup.php diff --git a/src/Xml/Nt009LinkedPaymentsPreviewGroup.php b/src/Xml/Nt009LinkedPaymentsPreviewGroup.php new file mode 100644 index 0000000..47c3879 --- /dev/null +++ b/src/Xml/Nt009LinkedPaymentsPreviewGroup.php @@ -0,0 +1,66 @@ + $payments + */ + public function build(\DOMDocument $doc, array $payments): \DOMElement + { + if ($payments === [] || count($payments) > 99) { + throw new \InvalidArgumentException('NT009 gPgtoVinc requires 1-99 payments'); + } + + $usedNumbers = []; + $usedTransactions = []; + $group = $doc->createElement('gPgtoVinc'); + foreach ($payments as $payment) { + if (!$payment instanceof Nt009LinkedPaymentData + || preg_match('/^[0-9]{1,3}$/D', $payment->numeroPagamento) !== 1 + || (int) $payment->numeroPagamento < 1 + || mb_strlen($payment->identificadorTransacao) < 2 + || mb_strlen($payment->identificadorTransacao) > 35 + || preg_match('/\p{Cc}/u', $payment->identificadorTransacao) !== 0 + || !in_array($payment->tipoMeioPagamento, ['15', '17', '18', '20', '23', '24'], true) + || preg_match('/^[A-Z0-9]{12}[0-9]{2}$/D', $payment->cnpjRecebedor) !== 1 + || preg_match('/^[A-Z0-9]{8}$/D', $payment->cnpjBasePsp) !== 1) { + throw new \InvalidArgumentException('Invalid NT009 gPgtoVinc payment fields'); + } + $number = (int) $payment->numeroPagamento; + if (isset($usedNumbers[$number]) + || isset($usedTransactions[$payment->identificadorTransacao])) { + throw new \InvalidArgumentException('NT009 payment numbers and transaction IDs must be unique'); + } + $usedNumbers[$number] = true; + $usedTransactions[$payment->identificadorTransacao] = true; + $record = $doc->createElement('pgto'); + $this->append($doc, $record, 'nPag', $payment->numeroPagamento); + $this->append($doc, $record, 'idTransacao', $payment->identificadorTransacao); + $this->append($doc, $record, 'tpMeioPgto', $payment->tipoMeioPagamento); + $this->append($doc, $record, 'CNPJReceb', $payment->cnpjRecebedor); + $this->append($doc, $record, 'CNPJBasePSP', $payment->cnpjBasePsp); + $group->appendChild($record); + } + + return $group; + } + + private function append(\DOMDocument $doc, \DOMElement $parent, string $name, string $value): void + { + $node = $doc->createElement($name); + $node->appendChild($doc->createTextNode($value)); + $parent->appendChild($node); + } +} From 9ce5efda053d66cd2ad46a8fee90ecb05b2ac613 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:22:16 +0000 Subject: [PATCH 06/29] feat: validate and serialize bounded NT009 movable-rental records Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009MovableAssetsPreviewGroup.php | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/Xml/Nt009MovableAssetsPreviewGroup.php diff --git a/src/Xml/Nt009MovableAssetsPreviewGroup.php b/src/Xml/Nt009MovableAssetsPreviewGroup.php new file mode 100644 index 0000000..446153e --- /dev/null +++ b/src/Xml/Nt009MovableAssetsPreviewGroup.php @@ -0,0 +1,57 @@ + $assets + * @return list<\DOMElement> + */ + public function build(\DOMDocument $doc, array $assets, string $nationalTaxCode): array + { + if ($assets === []) { + return []; + } + if (count($assets) > 1000) { + throw new \InvalidArgumentException('NT009 bensMoveis supports at most 1000 records'); + } + if ($nationalTaxCode !== '990401') { + throw new \InvalidArgumentException('NT009 bensMoveis is exclusive to cTribNac 99.04.01'); + } + + $result = []; + foreach ($assets as $asset) { + if (!$asset instanceof Nt009MovableAssetData + || preg_match('/^[0-9]{8}$/D', $asset->ncm) !== 1 + || mb_strlen($asset->descricao) < 1 || mb_strlen($asset->descricao) > 150 + || preg_match('/\p{Cc}/u', $asset->descricao) !== 0 + || $asset->quantidade < 1 || $asset->quantidade > 999) { + throw new \InvalidArgumentException('Invalid NT009 bensMoveis NCM, description or quantity'); + } + $record = $doc->createElement('bensMoveis'); + foreach ([ + 'cNCMBemMovel' => $asset->ncm, + 'xNCMBemMovel' => $asset->descricao, + 'qtdNCMBemMovel' => (string) $asset->quantidade, + ] as $tag => $value) { + $element = $doc->createElement($tag); + $element->appendChild($doc->createTextNode($value)); + $record->appendChild($element); + } + $result[] = $record; + } + + return $result; + } +} From 9939e1e992868512208b900b072934ca560c1167 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:22:38 +0000 Subject: [PATCH 07/29] feat: add explicit and escaped NT009 recipient identity and addresses Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009RecipientPreviewGroup.php | 122 +++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/Xml/Nt009RecipientPreviewGroup.php diff --git a/src/Xml/Nt009RecipientPreviewGroup.php b/src/Xml/Nt009RecipientPreviewGroup.php new file mode 100644 index 0000000..54f3d24 --- /dev/null +++ b/src/Xml/Nt009RecipientPreviewGroup.php @@ -0,0 +1,122 @@ +text($input->nome, 150)) { + throw new \InvalidArgumentException('NT009 dest/xNome must have 1-150 characters'); + } + $identities = (int) ($input->cnpj !== null) + (int) ($input->cpf !== null) + + (int) ($input->nif !== null) + (int) ($input->codigoNaoNif !== null); + if ($identities !== 1 + || ($input->cnpj !== null && preg_match('/^[A-Z0-9]{12}[0-9]{2}$/D', $input->cnpj) !== 1) + || ($input->cpf !== null && preg_match('/^[0-9]{11}$/D', $input->cpf) !== 1) + || ($input->nif !== null && !$this->text($input->nif, 40)) + || ($input->codigoNaoNif !== null && !in_array($input->codigoNaoNif, [1, 2], true))) { + throw new \InvalidArgumentException('NT009 dest requires exactly one valid tax identity'); + } + + $dest = $doc->createElement('dest'); + foreach (['CNPJ' => $input->cnpj, 'CPF' => $input->cpf, 'NIF' => $input->nif] as $tag => $value) { + if ($value !== null) { + $this->add($doc, $dest, $tag, $value); + } + } + if ($input->codigoNaoNif !== null) { + $this->add($doc, $dest, 'cNaoNIF', (string) $input->codigoNaoNif); + } + $this->add($doc, $dest, 'xNome', $input->nome); + if ($input->endereco !== null) { + $dest->appendChild($this->buildAddress($doc, $input->endereco)); + } + if ($input->telefone !== null) { + if (preg_match('/^[0-9]{6,20}$/D', $input->telefone) !== 1) { + throw new \InvalidArgumentException('Invalid NT009 dest/fone'); + } + $this->add($doc, $dest, 'fone', $input->telefone); + } + if ($input->email !== null) { + if (!$this->text($input->email, 80)) { + throw new \InvalidArgumentException('Invalid NT009 dest/email'); + } + $this->add($doc, $dest, 'email', $input->email); + } + + return $dest; + } + + private function buildAddress( + \DOMDocument $doc, + Nt009RecipientAddressData $input, + ): \DOMElement { + if (!$this->text($input->logradouro, 255) + || !$this->text($input->numero, 60) || !$this->text($input->bairro, 60) + || ($input->complemento !== null && !$this->text($input->complemento, 156))) { + throw new \InvalidArgumentException('Invalid NT009 recipient address'); + } + $national = $input->municipioIbge !== null || $input->cep !== null; + $foreign = $input->paisIso2 !== null || $input->codigoPostalExterior !== null + || $input->cidadeExterior !== null || $input->estadoExterior !== null; + if ($national === $foreign) { + throw new \InvalidArgumentException('NT009 recipient address must be national or foreign, not both'); + } + $end = $doc->createElement('end'); + if ($national) { + if (preg_match('/^[0-9]{7}$/D', (string) $input->municipioIbge) !== 1 + || preg_match('/^[0-9]{8}$/D', (string) $input->cep) !== 1) { + throw new \InvalidArgumentException('NT009 dest/endNac requires cMun and CEP'); + } + $group = $doc->createElement('endNac'); + $this->add($doc, $group, 'cMun', (string) $input->municipioIbge); + $this->add($doc, $group, 'CEP', (string) $input->cep); + } else { + if (preg_match('/^[A-Z]{2}$/D', (string) $input->paisIso2) !== 1 + || !$this->text((string) $input->codigoPostalExterior, 11) + || !$this->text((string) $input->cidadeExterior, 60) + || !$this->text((string) $input->estadoExterior, 60)) { + throw new \InvalidArgumentException('Incomplete NT009 foreign recipient address'); + } + $group = $doc->createElement('endExt'); + $this->add($doc, $group, 'cPais', (string) $input->paisIso2); + $this->add($doc, $group, 'cEndPost', (string) $input->codigoPostalExterior); + $this->add($doc, $group, 'xCidade', (string) $input->cidadeExterior); + $this->add($doc, $group, 'xEstProvReg', (string) $input->estadoExterior); + } + $end->appendChild($group); + $this->add($doc, $end, 'xLgr', $input->logradouro); + $this->add($doc, $end, 'nro', $input->numero); + if ($input->complemento !== null) { + $this->add($doc, $end, 'xCpl', $input->complemento); + } + $this->add($doc, $end, 'xBairro', $input->bairro); + + return $end; + } + + private function add(\DOMDocument $doc, \DOMElement $parent, string $tag, string $text): void + { + $node = $doc->createElement($tag); + $node->appendChild($doc->createTextNode($text)); + $parent->appendChild($node); + } + + private function text(string $value, int $maxLength): bool + { + return mb_strlen($value) >= 1 && mb_strlen($value) <= $maxLength + && preg_match('/\p{Cc}/u', $value) === 0; + } +} From 303172c6d23ff3f9e56585570db485e310b432a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:23:02 +0000 Subject: [PATCH 08/29] feat: wire structured NT009 preview groups in official element order Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009DpsPreviewBuilder.php | 50 +++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Xml/Nt009DpsPreviewBuilder.php b/src/Xml/Nt009DpsPreviewBuilder.php index bf6b43e..281336b 100644 --- a/src/Xml/Nt009DpsPreviewBuilder.php +++ b/src/Xml/Nt009DpsPreviewBuilder.php @@ -73,13 +73,35 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi } } - // In Annex VI, indDest and dest are outside IBSCBS, after toma and - // before interm/serv. No speculative recipient identity is inferred. - $beforeService = $this->child($infDps, 'serv'); - if ($beforeService === null) { - throw new \LogicException('Missing legacy service group'); + // indDest and dest precede the intermediary and service groups. + $beforeRecipient = $this->child($infDps, 'interm') ?? $this->child($infDps, 'serv'); + if (!$beforeRecipient instanceof \DOMElement) { + throw new \LogicException('Missing NT009 recipient insertion anchor'); + } + $infDps->insertBefore( + $document->createElement('indDest', (string) $preview->indicadorDestinatario), + $beforeRecipient + ); + if ($preview->destinatario !== null) { + if ($preview->indicadorDestinatario !== 1) { + throw new \InvalidArgumentException('NT009 dest is not allowed when indDest=0'); + } + $recipient = (new Nt009RecipientPreviewGroup())->build($document, $preview->destinatario); + $infDps->insertBefore($recipient, $beforeRecipient); + } + + // The new vAjusteBC replaces the old vDedRed, which is rejected above. + if ($preview->ajusteBase !== null) { + $rootValues = $this->child($infDps, 'valores'); + $municipalTax = $rootValues instanceof \DOMElement ? $this->child($rootValues, 'trib') : null; + if (!$rootValues instanceof \DOMElement || !$municipalTax instanceof \DOMElement) { + throw new \LogicException('Missing NT009 valores/trib insertion anchor'); + } + $rootValues->insertBefore( + (new Nt009BaseAdjustmentPreviewGroup())->build($document, $preview->ajusteBase), + $municipalTax + ); } - $infDps->insertBefore($document->createElement('indDest', (string) $preview->indicadorDestinatario), $beforeService); if ($preview->cst !== null) { $ibs = $document->createElement('IBSCBS'); @@ -89,6 +111,13 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi if ($preview->codigoIndicadorOperacao !== null) { $ibs->appendChild($document->createElement('cIndOp', $preview->codigoIndicadorOperacao)); } + foreach ((new Nt009MovableAssetsPreviewGroup())->build( + $document, + $preview->bensMoveis, + $dps->codigoTributacaoNacional + ) as $asset) { + $ibs->appendChild($asset); + } $valores = $document->createElement('valores'); $trib = $document->createElement('trib'); // NT009: CST and cClassTrib moved directly beneath trib. @@ -109,6 +138,11 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi } $valores->appendChild($trib); $ibs->appendChild($valores); + if ($preview->pagamentosVinculados !== []) { + $ibs->appendChild( + (new Nt009LinkedPaymentsPreviewGroup())->build($document, $preview->pagamentosVinculados) + ); + } $infDps->appendChild($ibs); } @@ -156,7 +190,9 @@ private function validatePreview(Nt009DpsPreviewData $preview): void || $preview->codigoCreditoPresumido !== null || $preview->valorAjusteIbs !== null || $preview->valorAjusteCbs !== null - || $preview->exigeGrupoIbsCbs !== null; + || $preview->exigeGrupoIbsCbs !== null + || $preview->bensMoveis !== [] + || $preview->pagamentosVinculados !== []; if (!$hasIbs) { return; From 9e1d9048a427a5dfa0384db8ce5a20cc2af564cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:23:39 +0000 Subject: [PATCH 09/29] test: cover NT009 group ordering, escaping, invalid input and legacy isolation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 215 +++++++++++++++++- 1 file changed, 213 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 12e100a..dd52d9b 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -7,7 +7,13 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Dto\Nt009BaseAdjustmentData; +use LibreCodeCoop\NfsePHP\Dto\Nt009LinkedPaymentData; +use LibreCodeCoop\NfsePHP\Dto\Nt009MovableAssetData; +use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; +use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientData; use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreview; use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreviewData; use LibreCodeCoop\NfsePHP\Tests\TestCase; @@ -249,7 +255,211 @@ public function testAllPublishedVersionSensitiveFieldsAreOrdered(): void self::assertLessThan(strpos($xml, ''), strpos($xml, '')); } - private function makeDps(?int $ibsCbsFinalidade = null): DpsData + public function testNewRecipientIsExplicitlyRepresentedAndXmlEscaped(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 1, + destinatario: new Nt009RecipientData( + nome: 'Ana & Associados', + cnpj: '11222333000181', + endereco: new Nt009RecipientAddressData( + logradouro: 'Rua A & B', + numero: '42', + bairro: 'Centro', + municipioIbge: '3304557', + cep: '20000000', + ), + ), + ) + )->xml; + + self::assertSame('Ana & Associados', $this->first( + $xml, + '/n:DPS/n:infDPS/n:dest/n:xNome' + )->textContent); + self::assertSame('Rua A & B', $this->first( + $xml, + '/n:DPS/n:infDPS/n:dest/n:end/n:xLgr' + )->textContent); + self::assertStringContainsString('Ana & Associados', $xml); + self::assertTrue(strpos($xml, '') < strpos($xml, '')); + self::assertTrue(strpos($xml, '') < strpos($xml, '')); + } + + public function testRecipientCannotBeSeparateWhenIndDestIsZero(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('not allowed when indDest=0'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + destinatario: new Nt009RecipientData(nome: 'Teste', cpf: '12345678901'), + ) + ); + } + + public function testRecipientIdentityAndAddressChoiceAreValidated(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one valid tax identity'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 1, + destinatario: new Nt009RecipientData( + nome: 'Duplicated', + cpf: '12345678901', + cnpj: '11222333000181', + ), + ) + ); + } + + public function testNewBaseAdjustmentDoesNotEmitLegacyDeductionGroup(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData(valorIssqn: '10.25'), + ) + )->xml; + + self::assertSame('10.25', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:vAjusteBCISSQN' + )->textContent); + self::assertStringNotContainsString('', $xml); + self::assertTrue(strpos($xml, '') < strpos($xml, '')); + } + + public function testLegacyDeductionIsRejectedInsteadOfPassingThrough(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cannot reuse legacy vDedRed'); + $this->builder->previewNt009Dps( + $this->makeDps( + deducaoReducao: new DeductionReductionData(valor: '15.00') + ), + new Nt009DpsPreviewData(finalidade: 0, indicadorDestinatario: 0) + ); + } + + public function testTwoBaseAdjustmentAlternativesAreRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires exactly one'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + percentualIssqn: '5.00', + valorIssqn: '5.00', + ), + ) + ); + } + + public function testLinkedPaymentsUseSourceOrderAndEscapeTransactionText(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + pagamentosVinculados: [ + new Nt009LinkedPaymentData( + numeroPagamento: '001', + identificadorTransacao: 'PIX&TX-01', + tipoMeioPagamento: '17', + cnpjRecebedor: '11222333000181', + cnpjBasePsp: '11222333', + ), + ], + ) + )->xml; + + self::assertSame('PIX&TX-01', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:gPgtoVinc/n:pgto/n:idTransacao' + )->textContent); + self::assertStringContainsString('PIX&TX-01', $xml); + self::assertTrue(strpos($xml, '') < strpos($xml, '')); + } + + public function testDuplicatedPaymentNumbersAreRejectedEvenWithLeadingZeroes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be unique'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + pagamentosVinculados: [ + new Nt009LinkedPaymentData('1', 'TX-A', '17', '11222333000181', '11222333'), + new Nt009LinkedPaymentData('001', 'TX-B', '17', '11222333000181', '11222333'), + ], + ) + ); + } + + public function testMovableItemsRequireRelevantTaxCodeAndHaveBoundedQuantity(): void + { + $item = new Nt009MovableAssetData('12345678', 'Bem móvel', 2); + $draft = $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990401'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + bensMoveis: [$item], + ) + ); + self::assertSame('12345678', $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:bensMoveis/n:cNCMBemMovel' + )->textContent); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exclusive to cTribNac'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + bensMoveis: [$item], + ) + ); + } + + private function makeDps( + ?int $ibsCbsFinalidade = null, + string $codigoTributacaoNacional = '010701', + ?DeductionReductionData $deducaoReducao = null, + ): DpsData { return new DpsData( cnpjPrestador: '11222333000181', @@ -260,8 +470,9 @@ private function makeDps(?int $ibsCbsFinalidade = null): DpsData discriminacao: 'Servico de tecnologia', serie: '00001', numeroDps: '17', - codigoTributacaoNacional: '010701', + codigoTributacaoNacional: $codigoTributacaoNacional, ibsCbsFinalidade: $ibsCbsFinalidade, + deducaoReducao: $deducaoReducao, ); } From 58eeaf696d41d88f32d60dea730244908503f676 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:24:26 +0000 Subject: [PATCH 10/29] style: format NT009 preview regression tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index dd52d9b..74bdece 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -10,12 +10,12 @@ use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\Nt009BaseAdjustmentData; +use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreview; +use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreviewData; use LibreCodeCoop\NfsePHP\Dto\Nt009LinkedPaymentData; use LibreCodeCoop\NfsePHP\Dto\Nt009MovableAssetData; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientData; -use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreview; -use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreviewData; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\DpsSchemaValidator; use LibreCodeCoop\NfsePHP\Xml\XmlBuilder; @@ -459,8 +459,7 @@ private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701', ?DeductionReductionData $deducaoReducao = null, - ): DpsData - { + ): DpsData { return new DpsData( cnpjPrestador: '11222333000181', municipioIbge: '3303302', From 5cb6a86c500bca9917f90bf4de235cf35c8e66c3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:24:59 +0000 Subject: [PATCH 11/29] docs: identify implemented draft groups and remaining NT009 production gates Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-CONTRACT-IMPLEMENTATION.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index 120a701..4dbd786 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -112,9 +112,30 @@ verified `ind_gIBSCBS` flag is false, and required in the appropriate conditional form if it is true. **The preview deliberately cannot model the complete 450-row new contract.** -In particular the new `vAjusteBC` structure, detailed `dest` group, -`gPgtoVinc`, `imovel`, `bensMoveis`, `condominios` and other conditional -groups are not implemented for production serialization. The builder rejects +It now includes explicitly typed, separately validated structural drafts for: + +- `infDPS/dest` (Annex VI rows 180-200): one identity, optional national or + foreign address; identifiers and textual values are validated before + insertion, with DOM text nodes used for safe XML escaping. +- `infDPS/valores/vAjusteBC` (rows 293-296, 336): the mutually exclusive + ISSQN percentage or monetary mode, with optional externally justified + `vAjusteBCIBSCBSComExt`. The full document-backed adjustment mode + (rows 297-335) is not yet modeled. +- `IBSCBS/bensMoveis` (rows 404-407): up to 1,000 records, restricted to + `cTribNac=99.04.01`, preserving the 8-digit NCM and bounded quantities. +- `IBSCBS/gPgtoVinc/pgto` (rows 443-449): up to 99 records, with distinct + transaction IDs and payment numbers and the published payment method codes. + +The builder now rejects legacy `DpsData::deducaoReducao` in NT009 preview +instead of silently copying the obsolete `vDedRed` group. This prevents +mistaking old deduction rules for the new, not-yet-effective schema. +No taxpayer identity or payment values are inferred. + +Still missing are document-backed `vAjusteBC`, `imovel`, +`condominios`, more specialized adjustment branches, other NT009 conditional +groups and — most importantly — validation against a published, +environment-specific **effective NT009 XSD**. None of these review-only +structures is enabled in the real emission path. The builder rejects mixing legacy IBS fields and the new preview options. It removes the obsolete `xsi:schemaLocation` reference, without claiming that it matches a new schema. The existing `DpsSchemaValidator` remains bound to the February From 902101a372612797fc04758cf58c02376c6e05da Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:26:39 +0000 Subject: [PATCH 12/29] feat: model optional NT009 real estate and lease information explicitly Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009LeaseData.php | 21 +++++++++++++++++++ src/Dto/Nt009PropertyAdjustmentData.php | 19 +++++++++++++++++ src/Dto/Nt009RealEstateData.php | 22 ++++++++++++++++++++ src/Dto/Nt009RealEstateUnitData.php | 27 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 src/Dto/Nt009LeaseData.php create mode 100644 src/Dto/Nt009PropertyAdjustmentData.php create mode 100644 src/Dto/Nt009RealEstateData.php create mode 100644 src/Dto/Nt009RealEstateUnitData.php diff --git a/src/Dto/Nt009LeaseData.php b/src/Dto/Nt009LeaseData.php new file mode 100644 index 0000000..c50beb0 --- /dev/null +++ b/src/Dto/Nt009LeaseData.php @@ -0,0 +1,21 @@ + $unidades + */ + public function __construct( + public string $municipioIbge, + public ?Nt009LeaseData $locacao = null, + public array $unidades = [], + ) { + } +} diff --git a/src/Dto/Nt009RealEstateUnitData.php b/src/Dto/Nt009RealEstateUnitData.php new file mode 100644 index 0000000..eec1198 --- /dev/null +++ b/src/Dto/Nt009RealEstateUnitData.php @@ -0,0 +1,27 @@ + $ajustes + */ + public function __construct( + public string $cib, + public string $cep, + public string $logradouro, + public string $numero, + public ?string $bairro = null, + public ?string $complemento = null, + public ?string $inscricaoFiscal = null, + public array $ajustes = [], + ) { + } +} From 5785bd68a9ecc5c339c13760ae8af5ebce8c630b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:27:01 +0000 Subject: [PATCH 13/29] feat: serialize bounded NT009 property units and lease groups for preview Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009RealEstatePreviewGroup.php | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/Xml/Nt009RealEstatePreviewGroup.php diff --git a/src/Xml/Nt009RealEstatePreviewGroup.php b/src/Xml/Nt009RealEstatePreviewGroup.php new file mode 100644 index 0000000..b83c06d --- /dev/null +++ b/src/Xml/Nt009RealEstatePreviewGroup.php @@ -0,0 +1,128 @@ +municipioIbge) !== 1 + || count($input->unidades) > 99) { + throw new \InvalidArgumentException('Invalid NT009 imovel municipality or unit cardinality'); + } + + $group = $doc->createElement('imovel'); + $this->add($doc, $group, 'cMun', $input->municipioIbge); + if ($input->locacao !== null) { + if (!in_array($nationalTaxCode, ['990301', '990302', '990303', '990304', '990305'], true)) { + throw new \InvalidArgumentException('NT009 gLocacao requires cTribNac 99.03.01-99.03.05'); + } + $lease = $input->locacao; + $this->decimal($lease->percentualCopropriedade, 2, 'pCopropriedade'); + $this->decimal($lease->valorTotalOperacao, 15, 'vTotOper'); + $element = $doc->createElement('gLocacao'); + $this->add($doc, $element, 'pCopropriedade', $lease->percentualCopropriedade); + $this->add($doc, $element, 'vTotOper', $lease->valorTotalOperacao); + foreach ([ + 'vDescIncondTot' => $lease->descontoIncondicional, + 'vDescCondTot' => $lease->descontoCondicional, + ] as $name => $value) { + if ($value !== null) { + $this->decimal($value, 15, $name); + $this->add($doc, $element, $name, $value); + } + } + if ($lease->dataVencimentoOriginal !== null) { + $this->date($lease->dataVencimentoOriginal, 'dVencOrig'); + $this->add($doc, $element, 'dVencOrig', $lease->dataVencimentoOriginal); + } + $group->appendChild($element); + } + + foreach ($input->unidades as $unit) { + if (preg_match('/^[A-Z0-9]{8}$/D', $unit->cib) !== 1 + || preg_match('/^[0-9]{8}$/D', $unit->cep) !== 1 + || !$this->text($unit->logradouro, 255) + || !$this->text($unit->numero, 60) + || ($unit->bairro !== null && !$this->text($unit->bairro, 60)) + || ($unit->complemento !== null && !$this->text($unit->complemento, 156)) + || ($unit->inscricaoFiscal !== null && !$this->text($unit->inscricaoFiscal, 30)) + || count($unit->ajustes) > 10) { + throw new \InvalidArgumentException('Invalid NT009 gUnidImob identification, address or adjustments'); + } + + $element = $doc->createElement('gUnidImob'); + if ($unit->inscricaoFiscal !== null) { + $this->add($doc, $element, 'inscImobFisc', $unit->inscricaoFiscal); + } + $this->add($doc, $element, 'cCIB', $unit->cib); + $address = $doc->createElement('end'); + $this->add($doc, $address, 'CEP', $unit->cep); + $this->add($doc, $address, 'xLgr', $unit->logradouro); + $this->add($doc, $address, 'nro', $unit->numero); + if ($unit->complemento !== null) { + $this->add($doc, $address, 'xCpl', $unit->complemento); + } + if ($unit->bairro !== null) { + $this->add($doc, $address, 'xBairro', $unit->bairro); + } + $element->appendChild($address); + foreach ($unit->ajustes as $adjustment) { + if (preg_match('/^[0-9]{2}$/D', $adjustment->tipo) !== 1 + || ($adjustment->descricaoTipo !== null && !$this->text($adjustment->descricaoTipo, 150))) { + throw new \InvalidArgumentException('Invalid NT009 property adjustment type'); + } + $this->decimal($adjustment->valor, 15, 'vAjusteBCLocImoveis'); + $one = $doc->createElement('gAjusteBCLocImoveis'); + $this->add($doc, $one, 'tpAjusteBCLocImoveis', $adjustment->tipo); + if ($adjustment->descricaoTipo !== null) { + $this->add($doc, $one, 'xTpAjusteBCLocImoveis', $adjustment->descricaoTipo); + } + $this->add($doc, $one, 'vAjusteBCLocImoveis', $adjustment->valor); + $element->appendChild($one); + } + $group->appendChild($element); + } + + return $group; + } + + private function decimal(string $value, int $digits, string $name): void + { + if (preg_match('/^[0-9]{1,' . $digits . '}\\.[0-9]{2}$/D', $value) !== 1) { + throw new \InvalidArgumentException('Invalid NT009 decimal ' . $name); + } + } + + private function date(string $value, string $name): void + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) { + throw new \InvalidArgumentException('Invalid NT009 date ' . $name); + } + } + + private function text(string $value, int $max): bool + { + return mb_strlen($value) >= 1 && mb_strlen($value) <= $max + && preg_match('/\p{Cc}/u', $value) === 0; + } + + private function add(\DOMDocument $doc, \DOMElement $parent, string $tag, string $value): void + { + $element = $doc->createElement($tag); + $element->appendChild($doc->createTextNode($value)); + $parent->appendChild($element); + } +} From f4aa1601502a6c1c0a8c6eb743a9849657cfc015 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:27:20 +0000 Subject: [PATCH 14/29] feat: model NT009 condominium charges, details and discounts Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009CondominiumChargeData.php | 23 +++++++++++++++++++++++ src/Dto/Nt009CondominiumData.php | 23 +++++++++++++++++++++++ src/Dto/Nt009CondominiumDetailData.php | 19 +++++++++++++++++++ src/Dto/Nt009CondominiumDiscountData.php | 19 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/Dto/Nt009CondominiumChargeData.php create mode 100644 src/Dto/Nt009CondominiumData.php create mode 100644 src/Dto/Nt009CondominiumDetailData.php create mode 100644 src/Dto/Nt009CondominiumDiscountData.php diff --git a/src/Dto/Nt009CondominiumChargeData.php b/src/Dto/Nt009CondominiumChargeData.php new file mode 100644 index 0000000..8922984 --- /dev/null +++ b/src/Dto/Nt009CondominiumChargeData.php @@ -0,0 +1,23 @@ + $detalhes + */ + public function __construct( + public string $tipo, + public string $valor, + public ?string $descricaoTipo = null, + public array $detalhes = [], + ) { + } +} diff --git a/src/Dto/Nt009CondominiumData.php b/src/Dto/Nt009CondominiumData.php new file mode 100644 index 0000000..e45f2a2 --- /dev/null +++ b/src/Dto/Nt009CondominiumData.php @@ -0,0 +1,23 @@ + $cobrancas + * @param list $descontos + */ + public function __construct( + public string $vencimentoOriginal, + public array $cobrancas, + public array $descontos = [], + ) { + } +} diff --git a/src/Dto/Nt009CondominiumDetailData.php b/src/Dto/Nt009CondominiumDetailData.php new file mode 100644 index 0000000..434421f --- /dev/null +++ b/src/Dto/Nt009CondominiumDetailData.php @@ -0,0 +1,19 @@ + Date: Fri, 9 Oct 2026 04:27:41 +0000 Subject: [PATCH 15/29] feat: bound and reconcile condominium preview charge groups Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009CondominiumPreviewGroup.php | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/Xml/Nt009CondominiumPreviewGroup.php diff --git a/src/Xml/Nt009CondominiumPreviewGroup.php b/src/Xml/Nt009CondominiumPreviewGroup.php new file mode 100644 index 0000000..011f2ca --- /dev/null +++ b/src/Xml/Nt009CondominiumPreviewGroup.php @@ -0,0 +1,109 @@ +vencimentoOriginal); + if ($date === false || $date->format('Y-m-d') !== $input->vencimentoOriginal) { + throw new \InvalidArgumentException('Invalid NT009 condominios/dVencOrig date'); + } + if ($input->cobrancas === [] || count($input->cobrancas) > 20 || count($input->descontos) > 10) { + throw new \InvalidArgumentException('NT009 condominios requires 1-20 charges and at most 10 discounts'); + } + $group = $doc->createElement('condominios'); + $this->add($doc, $group, 'dVencOrig', $input->vencimentoOriginal); + $total = 0; + foreach ($input->cobrancas as $charge) { + if (!in_array($charge->tipo, ['01', '02', '03', '04', '05', '99'], true) + || ($charge->tipo === '99') !== ($charge->descricaoTipo !== null) + || ($charge->descricaoTipo !== null && !$this->text($charge->descricaoTipo, 150)) + || count($charge->detalhes) > 20) { + throw new \InvalidArgumentException('Invalid NT009 gCobranca type or detail cardinality'); + } + $total += $this->cents($charge->valor, 'vCobranca'); + $record = $doc->createElement('gCobranca'); + $this->add($doc, $record, 'tpCobranca', $charge->tipo); + if ($charge->descricaoTipo !== null) { + $this->add($doc, $record, 'xTpCobranca', $charge->descricaoTipo); + } + $this->add($doc, $record, 'vCobranca', $charge->valor); + $detailedTotal = 0; + foreach ($charge->detalhes as $detail) { + if (preg_match('/^[0-9]{3}$/D', $detail->tipo) !== 1 + || ($detail->tipo === '999') !== ($detail->descricaoTipo !== null) + || ($detail->descricaoTipo !== null && !$this->text($detail->descricaoTipo, 150))) { + throw new \InvalidArgumentException('Invalid NT009 gDetCobranca type'); + } + $detailedTotal += $this->cents($detail->valor, 'vDetCobranca'); + $one = $doc->createElement('gDetCobranca'); + $this->add($doc, $one, 'tpDetCobranca', $detail->tipo); + if ($detail->descricaoTipo !== null) { + $this->add($doc, $one, 'xTpDetCobranca', $detail->descricaoTipo); + } + $this->add($doc, $one, 'vDetCobranca', $detail->valor); + $record->appendChild($one); + } + if ($charge->detalhes !== [] && $detailedTotal !== $this->cents($charge->valor, 'vCobranca')) { + throw new \InvalidArgumentException('NT009 detailed condominium charges must sum to vCobranca'); + } + $group->appendChild($record); + } + if ($total !== $this->cents($serviceValue, 'vServ')) { + throw new \InvalidArgumentException('NT009 condominium charges must sum to service value'); + } + foreach ($input->descontos as $discount) { + if (preg_match('/^[0-9]$/D', $discount->tipo) !== 1 + || !$this->text($discount->descricaoTipo, 150)) { + throw new \InvalidArgumentException('Invalid NT009 condominium discount'); + } + $this->cents($discount->valor, 'vDesconto'); + $element = $doc->createElement('gDesconto'); + $this->add($doc, $element, 'tpDesconto', $discount->tipo); + $this->add($doc, $element, 'vDesconto', $discount->valor); + $this->add($doc, $element, 'xTpDesconto', $discount->descricaoTipo); + $group->appendChild($element); + } + + return $group; + } + + private function cents(string $number, string $field): int + { + if (preg_match('/^([0-9]{1,15})\\.([0-9]{2})$/D', $number, $matches) !== 1) { + throw new \InvalidArgumentException('NT009 ' . $field . ' must be a decimal with two places'); + } + + return (int) $matches[1] * 100 + (int) $matches[2]; + } + + private function add(\DOMDocument $doc, \DOMElement $parent, string $tag, string $value): void + { + $child = $doc->createElement($tag); + $child->appendChild($doc->createTextNode($value)); + $parent->appendChild($child); + } + + private function text(string $value, int $max): bool + { + return mb_strlen($value) >= 1 && mb_strlen($value) <= $max + && preg_match('/\p{Cc}/u', $value) === 0; + } +} From d0b049c7ce80a8cb120c3f86ca130010ff2c255b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:28:01 +0000 Subject: [PATCH 16/29] feat: expose NT009 property and condominium groups in opt-in preview Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009DpsPreviewData.php | 4 ++++ src/Xml/Nt009DpsPreviewBuilder.php | 23 ++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Dto/Nt009DpsPreviewData.php b/src/Dto/Nt009DpsPreviewData.php index 140314d..91fa762 100644 --- a/src/Dto/Nt009DpsPreviewData.php +++ b/src/Dto/Nt009DpsPreviewData.php @@ -55,6 +55,10 @@ public function __construct( public array $bensMoveis = [], /** Up to 99 transaction records under IBSCBS/gPgtoVinc. */ public array $pagamentosVinculados = [], + /** Optional property operation details, Annex VI rows 383-403. */ + public ?Nt009RealEstateData $imovel = null, + /** Optional condominium billing, Annex VI rows 408-421. */ + public ?Nt009CondominiumData $condominios = null, ) { } } diff --git a/src/Xml/Nt009DpsPreviewBuilder.php b/src/Xml/Nt009DpsPreviewBuilder.php index 281336b..3211633 100644 --- a/src/Xml/Nt009DpsPreviewBuilder.php +++ b/src/Xml/Nt009DpsPreviewBuilder.php @@ -111,6 +111,15 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi if ($preview->codigoIndicadorOperacao !== null) { $ibs->appendChild($document->createElement('cIndOp', $preview->codigoIndicadorOperacao)); } + if ($preview->imovel !== null) { + $ibs->appendChild( + (new Nt009RealEstatePreviewGroup())->build( + $document, + $preview->imovel, + $dps->codigoTributacaoNacional + ) + ); + } foreach ((new Nt009MovableAssetsPreviewGroup())->build( $document, $preview->bensMoveis, @@ -118,6 +127,16 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi ) as $asset) { $ibs->appendChild($asset); } + if ($preview->condominios !== null) { + $ibs->appendChild( + (new Nt009CondominiumPreviewGroup())->build( + $document, + $preview->condominios, + $dps->codigoTributacaoNacional, + $dps->valorServico + ) + ); + } $valores = $document->createElement('valores'); $trib = $document->createElement('trib'); // NT009: CST and cClassTrib moved directly beneath trib. @@ -192,7 +211,9 @@ private function validatePreview(Nt009DpsPreviewData $preview): void || $preview->valorAjusteCbs !== null || $preview->exigeGrupoIbsCbs !== null || $preview->bensMoveis !== [] - || $preview->pagamentosVinculados !== []; + || $preview->pagamentosVinculados !== [] + || $preview->imovel !== null + || $preview->condominios !== null; if (!$hasIbs) { return; From 5927c8355522f37f8473b9ec4b9c8180592fdfd9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:28:27 +0000 Subject: [PATCH 17/29] test: validate NT009 real estate and condominium preview structures Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 74bdece..dc09b76 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -10,11 +10,18 @@ use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\Nt009BaseAdjustmentData; +use LibreCodeCoop\NfsePHP\Dto\Nt009CondominiumChargeData; +use LibreCodeCoop\NfsePHP\Dto\Nt009CondominiumData; +use LibreCodeCoop\NfsePHP\Dto\Nt009CondominiumDetailData; use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreview; use LibreCodeCoop\NfsePHP\Dto\Nt009DpsPreviewData; +use LibreCodeCoop\NfsePHP\Dto\Nt009LeaseData; use LibreCodeCoop\NfsePHP\Dto\Nt009LinkedPaymentData; use LibreCodeCoop\NfsePHP\Dto\Nt009MovableAssetData; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; +use LibreCodeCoop\NfsePHP\Dto\Nt009PropertyAdjustmentData; +use LibreCodeCoop\NfsePHP\Dto\Nt009RealEstateData; +use LibreCodeCoop\NfsePHP\Dto\Nt009RealEstateUnitData; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientData; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\DpsSchemaValidator; @@ -455,6 +462,118 @@ classificacaoTributaria: '000001', ); } + public function testPropertyLeaseAndUnitsFollowOfficialGroupOrder(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990301'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + imovel: new Nt009RealEstateData( + municipioIbge: '3304557', + locacao: new Nt009LeaseData('40.00', '150.00'), + unidades: [ + new Nt009RealEstateUnitData( + cib: '12345678', + cep: '20000000', + logradouro: 'Rua Um', + numero: '42', + ajustes: [new Nt009PropertyAdjustmentData('01', '10.00')], + ), + ], + ), + ) + )->xml; + self::assertSame('150.00', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/n:gLocacao/n:vTotOper' + )->textContent); + self::assertSame('12345678', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/n:gUnidImob/n:cCIB' + )->textContent); + self::assertSame('10.00', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/n:gUnidImob/n:gAjusteBCLocImoveis/n:vAjusteBCLocImoveis' + )->textContent); + self::assertTrue(strpos($xml, '') < strpos($xml, '')); + } + + public function testPropertyLeaseRejectsUnrelatedNationalServiceCode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('gLocacao requires cTribNac'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + imovel: new Nt009RealEstateData( + municipioIbge: '3304557', + locacao: new Nt009LeaseData('30.00', '150.00'), + ), + ) + ); + } + + public function testCondominiumChargesAndDetailsUseExactCentArithmetic(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990501'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + condominios: new Nt009CondominiumData( + vencimentoOriginal: '2026-11-10', + cobrancas: [ + new Nt009CondominiumChargeData( + tipo: '01', + valor: '150.00', + detalhes: [new Nt009CondominiumDetailData('001', '150.00')], + ), + ], + ), + ) + )->xml; + self::assertSame('150.00', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:condominios/n:gCobranca/n:gDetCobranca/n:vDetCobranca' + )->textContent); + self::assertSame('2026-11-10', $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:condominios/n:dVencOrig' + )->textContent); + } + + public function testCondominiumAmountsMustReconcileWithServiceValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('charges must sum to service value'); + $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990501'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + condominios: new Nt009CondominiumData( + '2026-11-10', + [new Nt009CondominiumChargeData('01', '149.99')], + ), + ) + ); + } + private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701', From 177163b4b9dff6d9edc178d2b4b6920061a4f266 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:28:42 +0000 Subject: [PATCH 18/29] docs: record draft-only real estate and condominium contracts and limitations Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-CONTRACT-IMPLEMENTATION.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index 4dbd786..703c3fa 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -125,15 +125,21 @@ It now includes explicitly typed, separately validated structural drafts for: `cTribNac=99.04.01`, preserving the 8-digit NCM and bounded quantities. - `IBSCBS/gPgtoVinc/pgto` (rows 443-449): up to 99 records, with distinct transaction IDs and payment numbers and the published payment method codes. +- `IBSCBS/imovel` (rows 383-403): explicit municipality, the + classification-restricted optional `gLocacao`, and up to 99 units with + bounded address and per-unit adjustment data. +- `IBSCBS/condominios` (rows 408-421): source-coded charge categories and + optional detail and discount records, with exact-cent reconciliation of + `gDetCobranca` to the charge and total charge values to `vServ`. The builder now rejects legacy `DpsData::deducaoReducao` in NT009 preview instead of silently copying the obsolete `vDedRed` group. This prevents mistaking old deduction rules for the new, not-yet-effective schema. No taxpayer identity or payment values are inferred. -Still missing are document-backed `vAjusteBC`, `imovel`, -`condominios`, more specialized adjustment branches, other NT009 conditional -groups and — most importantly — validation against a published, +Still missing are document-backed `vAjusteBC`, externally verified +tax-code correlations, other specialized adjustment branches, other NT009 +conditional groups and — most importantly — validation against a published, environment-specific **effective NT009 XSD**. None of these review-only structures is enabled in the real emission path. The builder rejects mixing legacy IBS fields and the new preview options. It removes the obsolete From 540e0dd7a541be13cf3d428a76227efc2569b306 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:29:30 +0000 Subject: [PATCH 19/29] feat: model three mutually exclusive NT009 adjustment document references Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009AdjustmentDocumentData.php | 30 +++++++++++++++++++++++ src/Dto/Nt009NationalInvoiceReference.php | 19 ++++++++++++++ src/Dto/Nt009OtherDocumentReference.php | 18 ++++++++++++++ src/Dto/Nt009OtherFiscalReference.php | 19 ++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 src/Dto/Nt009AdjustmentDocumentData.php create mode 100644 src/Dto/Nt009NationalInvoiceReference.php create mode 100644 src/Dto/Nt009OtherDocumentReference.php create mode 100644 src/Dto/Nt009OtherFiscalReference.php diff --git a/src/Dto/Nt009AdjustmentDocumentData.php b/src/Dto/Nt009AdjustmentDocumentData.php new file mode 100644 index 0000000..6ad1b67 --- /dev/null +++ b/src/Dto/Nt009AdjustmentDocumentData.php @@ -0,0 +1,30 @@ + Date: Fri, 9 Oct 2026 04:29:44 +0000 Subject: [PATCH 20/29] feat: add document-based adjustment mode and reuse vetted identity serializer Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009BaseAdjustmentData.php | 5 +++++ src/Xml/Nt009RecipientPreviewGroup.php | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Dto/Nt009BaseAdjustmentData.php b/src/Dto/Nt009BaseAdjustmentData.php index 6acf41f..49b21d5 100644 --- a/src/Dto/Nt009BaseAdjustmentData.php +++ b/src/Dto/Nt009BaseAdjustmentData.php @@ -13,6 +13,9 @@ */ final readonly class Nt009BaseAdjustmentData { + /** + * @param list $documentos + */ public function __construct( /** pAjusteBCISSQN, mutually exclusive with valorIssqn. */ public ?string $percentualIssqn = null, @@ -20,6 +23,8 @@ public function __construct( public ?string $valorIssqn = null, /** vAjusteBCIBSCBSComExt, only when separately applicable. */ public ?string $valorIbsCbsComExterior = null, + /** Document-based alternative to percentage or monetary adjustment. */ + public array $documentos = [], ) { } } diff --git a/src/Xml/Nt009RecipientPreviewGroup.php b/src/Xml/Nt009RecipientPreviewGroup.php index 54f3d24..00529af 100644 --- a/src/Xml/Nt009RecipientPreviewGroup.php +++ b/src/Xml/Nt009RecipientPreviewGroup.php @@ -15,8 +15,14 @@ */ final class Nt009RecipientPreviewGroup { - public function build(\DOMDocument $doc, Nt009RecipientData $input): \DOMElement - { + public function build( + \DOMDocument $doc, + Nt009RecipientData $input, + string $groupName = 'dest', + ): \DOMElement { + if (!in_array($groupName, ['dest', 'fornec'], true)) { + throw new \InvalidArgumentException('Unsupported NT009 recipient group name'); + } if (!$this->text($input->nome, 150)) { throw new \InvalidArgumentException('NT009 dest/xNome must have 1-150 characters'); } @@ -30,7 +36,7 @@ public function build(\DOMDocument $doc, Nt009RecipientData $input): \DOMElement throw new \InvalidArgumentException('NT009 dest requires exactly one valid tax identity'); } - $dest = $doc->createElement('dest'); + $dest = $doc->createElement($groupName); foreach (['CNPJ' => $input->cnpj, 'CPF' => $input->cpf, 'NIF' => $input->nif] as $tag => $value) { if ($value !== null) { $this->add($doc, $dest, $tag, $value); From 1dcf7113c28c242071368b81eca7684939f90d57 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:30:11 +0000 Subject: [PATCH 21/29] feat: serialize NT009 adjustment documents with typed reference choices Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Nt009AdjustmentDocumentPreviewGroup.php | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/Xml/Nt009AdjustmentDocumentPreviewGroup.php diff --git a/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php new file mode 100644 index 0000000..472f318 --- /dev/null +++ b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php @@ -0,0 +1,120 @@ +tipo) !== 1 + || ($data->descricaoTipo !== null && !$this->text($data->descricaoTipo, 150, 0))) { + throw new \InvalidArgumentException('Invalid NT009 tpAjusteBC or xTpAjusteBC'); + } + $this->decimal($data->valorTotalDocumento, 'vTotDoc'); + $this->decimal($data->valorAjustado, 'vAjusteAplic'); + foreach ([ + 'dtEmiDoc' => $data->dataEmissao, + 'dtCompDoc' => $data->dataCompetencia, + ] as $name => $date) { + if ($date !== null) { + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + if ($parsed === false || $parsed->format('Y-m-d') !== $date) { + throw new \InvalidArgumentException('Invalid NT009 date field ' . $name); + } + } + } + + $element = $doc->createElement('docAjusteBC'); + $this->add($doc, $element, 'tpAjusteBC', $data->tipo); + if ($data->descricaoTipo !== null) { + $this->add($doc, $element, 'xTpAjusteBC', $data->descricaoTipo); + } + $this->add($doc, $element, 'vTotDoc', $data->valorTotalDocumento); + $this->add($doc, $element, 'vAjusteAplic', $data->valorAjustado); + if ($data->dataEmissao !== null) { + $this->add($doc, $element, 'dtEmiDoc', $data->dataEmissao); + } + if ($data->dataCompetencia !== null) { + $this->add($doc, $element, 'dtCompDoc', $data->dataCompetencia); + } + + $reference = $data->referencia; + if ($reference instanceof Nt009NationalInvoiceReference) { + if (preg_match('/^[0-9]$/D', $reference->tipoChaveDfe) !== 1 + || !$this->text($reference->chaveDfe, 50) + || ($reference->descricaoTipoChaveDfe !== null + && !$this->text($reference->descricaoTipoChaveDfe, 255))) { + throw new \InvalidArgumentException('Invalid NT009 dFeNacional reference'); + } + $group = $doc->createElement('dFeNacional'); + $this->add($doc, $group, 'tipoChaveDFe', $reference->tipoChaveDfe); + if ($reference->descricaoTipoChaveDfe !== null) { + $this->add($doc, $group, 'xTipoChaveDFe', $reference->descricaoTipoChaveDfe); + } + $this->add($doc, $group, 'chaveDFe', $reference->chaveDfe); + } elseif ($reference instanceof Nt009OtherFiscalReference) { + if (preg_match('/^[0-9]{7}$/D', $reference->municipioIbge) !== 1 + || !$this->text($reference->numeroDocumento, 255) + || !$this->text($reference->descricaoDocumento, 255)) { + throw new \InvalidArgumentException('Invalid NT009 docFiscalOutro reference'); + } + $group = $doc->createElement('docFiscalOutro'); + $this->add($doc, $group, 'cMunDocFiscal', $reference->municipioIbge); + $this->add($doc, $group, 'nDocFiscal', $reference->numeroDocumento); + $this->add($doc, $group, 'xDocFiscal', $reference->descricaoDocumento); + } elseif ($reference instanceof Nt009OtherDocumentReference) { + if (!$this->text($reference->numero, 255) + || !$this->text($reference->descricao, 255)) { + throw new \InvalidArgumentException('Invalid NT009 docOutro reference'); + } + $group = $doc->createElement('docOutro'); + $this->add($doc, $group, 'nDoc', $reference->numero); + $this->add($doc, $group, 'xDoc', $reference->descricao); + } else { + throw new \InvalidArgumentException('Unsupported NT009 document reference type'); + } + $element->appendChild($group); + if ($data->fornecedor !== null) { + $element->appendChild( + (new Nt009RecipientPreviewGroup())->build($doc, $data->fornecedor, 'fornec') + ); + } + + return $element; + } + + private function decimal(string $value, string $field): void + { + if (preg_match('/^[0-9]{1,15}\\.[0-9]{2}$/D', $value) !== 1) { + throw new \InvalidArgumentException('Invalid NT009 decimal field ' . $field); + } + } + + private function text(string $value, int $max, int $min = 1): bool + { + return mb_strlen($value) >= $min && mb_strlen($value) <= $max + && preg_match('/\p{Cc}/u', $value) === 0; + } + + private function add(\DOMDocument $doc, \DOMElement $parent, string $name, string $value): void + { + $element = $doc->createElement($name); + $element->appendChild($doc->createTextNode($value)); + $parent->appendChild($element); + } +} From aa376a58ee5e08a9c75fe90db3ea964ad8775604 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:31:25 +0000 Subject: [PATCH 22/29] feat: implement mutually exclusive document-backed NT009 adjustment mode Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Dto/Nt009BaseAdjustmentData.php | 4 ++-- src/Xml/Nt009BaseAdjustmentPreviewGroup.php | 23 +++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Dto/Nt009BaseAdjustmentData.php b/src/Dto/Nt009BaseAdjustmentData.php index 49b21d5..cceca13 100644 --- a/src/Dto/Nt009BaseAdjustmentData.php +++ b/src/Dto/Nt009BaseAdjustmentData.php @@ -8,8 +8,8 @@ namespace LibreCodeCoop\NfsePHP\Dto; /** - * Review-only NT009 valores/vAjusteBC. Document-based reductions require a - * separate official record structure and are deliberately not synthesized. + * Review-only NT009 valores/vAjusteBC. Monetary, percentage and explicit + * document-backed alternatives are mutually exclusive and never inferred. */ final readonly class Nt009BaseAdjustmentData { diff --git a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php index 4e6db51..053aa35 100644 --- a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php +++ b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php @@ -10,18 +10,24 @@ use LibreCodeCoop\NfsePHP\Dto\Nt009BaseAdjustmentData; /** - * Draft-only NT009 valores/vAjusteBC: monetary and percentage alternatives. - * Document-based deductions require a separate, verified contract. + * Draft-only NT009 valores/vAjusteBC: monetary, percentage or document-based + * alternatives. These structures do not prove fiscal approval by SEFIN. */ final class Nt009BaseAdjustmentPreviewGroup { public function build(\DOMDocument $doc, Nt009BaseAdjustmentData $input): \DOMElement { - if (($input->percentualIssqn === null) === ($input->valorIssqn === null)) { + $modes = (int) ($input->percentualIssqn !== null) + + (int) ($input->valorIssqn !== null) + + (int) ($input->documentos !== []); + if ($modes !== 1) { throw new \InvalidArgumentException( - 'NT009 vAjusteBC requires exactly one of pAjusteBCISSQN or vAjusteBCISSQN' + 'NT009 vAjusteBC requires exactly one percentage, value or documents mode' ); } + if (count($input->documentos) > 1000) { + throw new \InvalidArgumentException('NT009 vAjusteBC supports at most 1000 adjustment documents'); + } $group = $doc->createElement('vAjusteBC'); if ($input->percentualIssqn !== null) { @@ -32,6 +38,15 @@ public function build(\DOMDocument $doc, Nt009BaseAdjustmentData $input): \DOMEl $this->assertDecimal($input->valorIssqn, 15, 'vAjusteBCISSQN'); $this->append($doc, $group, 'vAjusteBCISSQN', $input->valorIssqn); } + if ($input->documentos !== []) { + $documents = $doc->createElement('documentos'); + foreach ($input->documentos as $entry) { + $documents->appendChild( + (new Nt009AdjustmentDocumentPreviewGroup())->build($doc, $entry) + ); + } + $group->appendChild($documents); + } if ($input->valorIbsCbsComExterior !== null) { $this->assertDecimal($input->valorIbsCbsComExterior, 15, 'vAjusteBCIBSCBSComExt'); $this->append($doc, $group, 'vAjusteBCIBSCBSComExt', $input->valorIbsCbsComExterior); From 0c3f37d66e4f50cf3b29c2f8b5f59653e662b6b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:31:47 +0000 Subject: [PATCH 23/29] test: cover all NT009 document-backed adjustment choices and invalid modes Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index dc09b76..6fa47dc 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -9,6 +9,7 @@ use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Dto\Nt009AdjustmentDocumentData; use LibreCodeCoop\NfsePHP\Dto\Nt009BaseAdjustmentData; use LibreCodeCoop\NfsePHP\Dto\Nt009CondominiumChargeData; use LibreCodeCoop\NfsePHP\Dto\Nt009CondominiumData; @@ -18,6 +19,9 @@ use LibreCodeCoop\NfsePHP\Dto\Nt009LeaseData; use LibreCodeCoop\NfsePHP\Dto\Nt009LinkedPaymentData; use LibreCodeCoop\NfsePHP\Dto\Nt009MovableAssetData; +use LibreCodeCoop\NfsePHP\Dto\Nt009NationalInvoiceReference; +use LibreCodeCoop\NfsePHP\Dto\Nt009OtherDocumentReference; +use LibreCodeCoop\NfsePHP\Dto\Nt009OtherFiscalReference; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; use LibreCodeCoop\NfsePHP\Dto\Nt009PropertyAdjustmentData; use LibreCodeCoop\NfsePHP\Dto\Nt009RealEstateData; @@ -574,6 +578,159 @@ classificacaoTributaria: '000001', ); } + public function testDocumentBasedAdjustmentRendersEachExclusiveReferenceType(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + tipo: '1', + valorTotalDocumento: '120.00', + valorAjustado: '25.00', + referencia: new Nt009NationalInvoiceReference('1', 'NATIONAL-KEY-123'), + dataEmissao: '2026-10-07', + ), + new Nt009AdjustmentDocumentData( + tipo: '2', + valorTotalDocumento: '200.00', + valorAjustado: '15.00', + referencia: new Nt009OtherFiscalReference( + '3304557', + 'DOC-55', + 'Serviço público & fiscal' + ), + ), + new Nt009AdjustmentDocumentData( + tipo: '3', + valorTotalDocumento: '80.00', + valorAjustado: '10.00', + referencia: new Nt009OtherDocumentReference('OTHER-01', 'Contrato & aviso'), + ), + ], + ), + ) + )->xml; + + self::assertSame(3, $this->xpath($xml)->query( + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC' + )?->length); + self::assertSame('NATIONAL-KEY-123', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:dFeNacional/n:chaveDFe' + )->textContent); + self::assertSame('Serviço público & fiscal', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:docFiscalOutro/n:xDocFiscal' + )->textContent); + self::assertSame('Contrato & aviso', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:docOutro/n:xDoc' + )->textContent); + self::assertStringContainsString('Contrato & aviso', $xml); + self::assertStringNotContainsString('', $xml); + } + + public function testDocumentAdjustmentCanPreserveVettedSupplierDetails(): void + { + $xml = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + tipo: '1', + valorTotalDocumento: '150.00', + valorAjustado: '50.00', + referencia: new Nt009OtherDocumentReference('C-7', 'Contract'), + fornecedor: new Nt009RecipientData(nome: 'Fornecedor & Filhos', cpf: '12345678901'), + ), + ], + ), + ) + )->xml; + self::assertSame('Fornecedor & Filhos', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:fornec/n:xNome' + )->textContent); + } + + public function testDocumentAdjustmentCannotBeCombinedWithValueMode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires exactly one'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + valorIssqn: '5.00', + documentos: [ + new Nt009AdjustmentDocumentData( + tipo: '1', + valorTotalDocumento: '150.00', + valorAjustado: '50.00', + referencia: new Nt009OtherDocumentReference('C-7', 'Contract'), + ), + ], + ), + ) + ); + } + + public function testDocumentAdjustmentRejectsInvalidDate(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 date field'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + tipo: '1', + valorTotalDocumento: '150.00', + valorAjustado: '50.00', + referencia: new Nt009OtherDocumentReference('C-7', 'Contract'), + dataEmissao: '2026-02-30', + ), + ], + ), + ) + ); + } + + public function testDocumentAdjustmentRejectsInvalidFinancialPrecision(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 decimal field'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + tipo: '1', + valorTotalDocumento: '150.00', + valorAjustado: '50.001', + referencia: new Nt009OtherDocumentReference('C-7', 'Contract'), + ), + ], + ), + ) + ); + } + private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701', From 5b10c063d5429710f7b537cb07827536451b8cdf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:32:01 +0000 Subject: [PATCH 24/29] docs: describe explicit NT009 document adjustment alternatives and remaining rules Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-CONTRACT-IMPLEMENTATION.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index 703c3fa..d343cd8 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -117,10 +117,13 @@ It now includes explicitly typed, separately validated structural drafts for: - `infDPS/dest` (Annex VI rows 180-200): one identity, optional national or foreign address; identifiers and textual values are validated before insertion, with DOM text nodes used for safe XML escaping. -- `infDPS/valores/vAjusteBC` (rows 293-296, 336): the mutually exclusive - ISSQN percentage or monetary mode, with optional externally justified - `vAjusteBCIBSCBSComExt`. The full document-backed adjustment mode - (rows 297-335) is not yet modeled. +- `infDPS/valores/vAjusteBC` (rows 293-336): exactly one ISSQN + percentage, monetary or document-backed mode. The document mode accepts + up to 1,000 typed `docAjusteBC` entries with a choice of + `dFeNacional`, `docFiscalOutro` or `docOutro`, optional dates and + explicit `fornec` identity. An optional externally justified + `vAjusteBCIBSCBSComExt` may be provided; fiscal applicability is not + inferred by the SDK. - `IBSCBS/bensMoveis` (rows 404-407): up to 1,000 records, restricted to `cTribNac=99.04.01`, preserving the 8-digit NCM and bounded quantities. - `IBSCBS/gPgtoVinc/pgto` (rows 443-449): up to 99 records, with distinct @@ -137,9 +140,10 @@ instead of silently copying the obsolete `vDedRed` group. This prevents mistaking old deduction rules for the new, not-yet-effective schema. No taxpayer identity or payment values are inferred. -Still missing are document-backed `vAjusteBC`, externally verified -tax-code correlations, other specialized adjustment branches, other NT009 -conditional groups and — most importantly — validation against a published, +Still missing are independently confirmed adjustment-type domains +and tax-code repercussions, externally verified tax-code correlations, +specialized conditional groups not yet modeled, and — most importantly — +validation against a published, environment-specific **effective NT009 XSD**. None of these review-only structures is enabled in the real emission path. The builder rejects mixing legacy IBS fields and the new preview options. It removes the obsolete From 63d03bda73c7c2bb88ee6b001dd471e07079b6a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:32:58 +0000 Subject: [PATCH 25/29] fix: preserve distinct Annex VI supplier address XML hierarchy Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009RecipientPreviewGroup.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Xml/Nt009RecipientPreviewGroup.php b/src/Xml/Nt009RecipientPreviewGroup.php index 00529af..6bf80e0 100644 --- a/src/Xml/Nt009RecipientPreviewGroup.php +++ b/src/Xml/Nt009RecipientPreviewGroup.php @@ -47,7 +47,20 @@ public function build( } $this->add($doc, $dest, 'xNome', $input->nome); if ($input->endereco !== null) { - $dest->appendChild($this->buildAddress($doc, $input->endereco)); + $address = $this->buildAddress($doc, $input->endereco); + $dest->appendChild($address); + if ($groupName === 'fornec') { + // Anexo VI rows 330-333 place supplier street fields directly + // under fornec, unlike destination address fields (rows 195-198). + foreach (['xLgr', 'nro', 'xCpl', 'xBairro'] as $name) { + foreach ($address->childNodes as $child) { + if ($child instanceof \DOMElement && $child->localName === $name) { + $dest->appendChild($child); + break; + } + } + } + } } if ($input->telefone !== null) { if (preg_match('/^[0-9]{6,20}$/D', $input->telefone) !== 1) { From fb38eca57912151cb9f50c5d2678fe8403c90c94 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:33:12 +0000 Subject: [PATCH 26/29] test: verify distinct Annex VI supplier address nesting and XML escaping Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 6fa47dc..3dbd482 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -648,7 +648,17 @@ public function testDocumentAdjustmentCanPreserveVettedSupplierDetails(): void valorTotalDocumento: '150.00', valorAjustado: '50.00', referencia: new Nt009OtherDocumentReference('C-7', 'Contract'), - fornecedor: new Nt009RecipientData(nome: 'Fornecedor & Filhos', cpf: '12345678901'), + fornecedor: new Nt009RecipientData( + nome: 'Fornecedor & Filhos', + cpf: '12345678901', + endereco: new Nt009RecipientAddressData( + logradouro: 'Rua & Filiais', + numero: '10', + bairro: 'Centro', + municipioIbge: '3304557', + cep: '20000000', + ), + ), ), ], ), @@ -658,6 +668,14 @@ public function testDocumentAdjustmentCanPreserveVettedSupplierDetails(): void $xml, '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:fornec/n:xNome' )->textContent); + self::assertSame('Rua & Filiais', $this->first( + $xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:fornec/n:xLgr' + )->textContent); + self::assertSame(0, $this->xpath($xml)->query( + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:fornec/n:end/n:xLgr' + )?->length); + self::assertStringContainsString('Rua & Filiais', $xml); } public function testDocumentAdjustmentCannotBeCombinedWithValueMode(): void From b08e5ceea96ffc36459c19a3aed0181db32b0a5c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:33:28 +0000 Subject: [PATCH 27/29] fix: reconcile large condominium amounts without integer overflow or floats Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009CondominiumPreviewGroup.php | 37 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Xml/Nt009CondominiumPreviewGroup.php b/src/Xml/Nt009CondominiumPreviewGroup.php index 011f2ca..b523b91 100644 --- a/src/Xml/Nt009CondominiumPreviewGroup.php +++ b/src/Xml/Nt009CondominiumPreviewGroup.php @@ -30,7 +30,7 @@ public function build( } $group = $doc->createElement('condominios'); $this->add($doc, $group, 'dVencOrig', $input->vencimentoOriginal); - $total = 0; + $total = '0'; foreach ($input->cobrancas as $charge) { if (!in_array($charge->tipo, ['01', '02', '03', '04', '05', '99'], true) || ($charge->tipo === '99') !== ($charge->descricaoTipo !== null) @@ -38,21 +38,21 @@ public function build( || count($charge->detalhes) > 20) { throw new \InvalidArgumentException('Invalid NT009 gCobranca type or detail cardinality'); } - $total += $this->cents($charge->valor, 'vCobranca'); + $total = $this->addCents($total, $this->cents($charge->valor, 'vCobranca')); $record = $doc->createElement('gCobranca'); $this->add($doc, $record, 'tpCobranca', $charge->tipo); if ($charge->descricaoTipo !== null) { $this->add($doc, $record, 'xTpCobranca', $charge->descricaoTipo); } $this->add($doc, $record, 'vCobranca', $charge->valor); - $detailedTotal = 0; + $detailedTotal = '0'; foreach ($charge->detalhes as $detail) { if (preg_match('/^[0-9]{3}$/D', $detail->tipo) !== 1 || ($detail->tipo === '999') !== ($detail->descricaoTipo !== null) || ($detail->descricaoTipo !== null && !$this->text($detail->descricaoTipo, 150))) { throw new \InvalidArgumentException('Invalid NT009 gDetCobranca type'); } - $detailedTotal += $this->cents($detail->valor, 'vDetCobranca'); + $detailedTotal = $this->addCents($detailedTotal, $this->cents($detail->valor, 'vDetCobranca')); $one = $doc->createElement('gDetCobranca'); $this->add($doc, $one, 'tpDetCobranca', $detail->tipo); if ($detail->descricaoTipo !== null) { @@ -85,13 +85,38 @@ public function build( return $group; } - private function cents(string $number, string $field): int + private function cents(string $number, string $field): string { if (preg_match('/^([0-9]{1,15})\\.([0-9]{2})$/D', $number, $matches) !== 1) { throw new \InvalidArgumentException('NT009 ' . $field . ' must be a decimal with two places'); } - return (int) $matches[1] * 100 + (int) $matches[2]; + return ltrim($matches[1] . $matches[2], '0') ?: '0'; + } + + /** + * Decimal-as-string cent addition avoids integer overflow for 15-digit + * amounts, and never converts fiscal values through floating point. + */ + private function addCents(string $left, string $right): string + { + $carry = 0; + $digits = ''; + $i = strlen($left) - 1; + $j = strlen($right) - 1; + while ($i >= 0 || $j >= 0 || $carry > 0) { + $sum = $carry; + if ($i >= 0) { + $sum += (int) $left[$i--]; + } + if ($j >= 0) { + $sum += (int) $right[$j--]; + } + $digits = (string) ($sum % 10) . $digits; + $carry = intdiv($sum, 10); + } + + return ltrim($digits, '0') ?: '0'; } private function add(\DOMDocument $doc, \DOMElement $parent, string $tag, string $value): void From eb14419c2ed1d4dcfddc1d20c8b741c7dd2bd990 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:33:46 +0000 Subject: [PATCH 28/29] test: prove 15-digit condominium amounts reconcile without overflow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 3dbd482..b5ff63a 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -558,6 +558,29 @@ classificacaoTributaria: '000001', )->textContent); } + public function testLargeCondominiumAmountsReconcileWithoutIntegerOverflow(): void + { + $amount = '999999999999999.99'; + $xml = $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990501', valorServico: $amount), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + condominios: new Nt009CondominiumData( + '2026-11-10', + [new Nt009CondominiumChargeData('01', $amount)], + ), + ) + )->xml; + self::assertSame($amount, $this->first( + $xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:condominios/n:gCobranca/n:vCobranca' + )->textContent); + } + public function testCondominiumAmountsMustReconcileWithServiceValue(): void { $this->expectException(\InvalidArgumentException::class); @@ -753,12 +776,13 @@ private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701', ?DeductionReductionData $deducaoReducao = null, + string $valorServico = '150.00', ): DpsData { return new DpsData( cnpjPrestador: '11222333000181', municipioIbge: '3303302', itemListaServico: '0107', - valorServico: '150.00', + valorServico: $valorServico, aliquota: '5.00', discriminacao: 'Servico de tecnologia', serie: '00001', From d0d1dd84ca207ae7bf1ba7bb69147b7015e958c5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:35:01 +0000 Subject: [PATCH 29/29] fix: resolve preview XPath regression, Psalm union branch and style Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Xml/Nt009AdjustmentDocumentPreviewGroup.php | 6 +++--- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php index 472f318..16416cd 100644 --- a/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php +++ b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php @@ -77,7 +77,9 @@ public function build(\DOMDocument $doc, Nt009AdjustmentDocumentData $data): \DO $this->add($doc, $group, 'cMunDocFiscal', $reference->municipioIbge); $this->add($doc, $group, 'nDocFiscal', $reference->numeroDocumento); $this->add($doc, $group, 'xDocFiscal', $reference->descricaoDocumento); - } elseif ($reference instanceof Nt009OtherDocumentReference) { + } else { + // The DTO's discriminated union guarantees the final reference + // is Nt009OtherDocumentReference, not an arbitrary XML payload. if (!$this->text($reference->numero, 255) || !$this->text($reference->descricao, 255)) { throw new \InvalidArgumentException('Invalid NT009 docOutro reference'); @@ -85,8 +87,6 @@ public function build(\DOMDocument $doc, Nt009AdjustmentDocumentData $data): \DO $group = $doc->createElement('docOutro'); $this->add($doc, $group, 'nDoc', $reference->numero); $this->add($doc, $group, 'xDoc', $reference->descricao); - } else { - throw new \InvalidArgumentException('Unsupported NT009 document reference type'); } $element->appendChild($group); if ($data->fornecedor !== null) { diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index b5ff63a..d41ccd8 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -22,10 +22,10 @@ use LibreCodeCoop\NfsePHP\Dto\Nt009NationalInvoiceReference; use LibreCodeCoop\NfsePHP\Dto\Nt009OtherDocumentReference; use LibreCodeCoop\NfsePHP\Dto\Nt009OtherFiscalReference; -use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; use LibreCodeCoop\NfsePHP\Dto\Nt009PropertyAdjustmentData; use LibreCodeCoop\NfsePHP\Dto\Nt009RealEstateData; use LibreCodeCoop\NfsePHP\Dto\Nt009RealEstateUnitData; +use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientAddressData; use LibreCodeCoop\NfsePHP\Dto\Nt009RecipientData; use LibreCodeCoop\NfsePHP\Tests\TestCase; use LibreCodeCoop\NfsePHP\Xml\DpsSchemaValidator; @@ -503,7 +503,9 @@ classificacaoTributaria: '000001', $xml, '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/n:gUnidImob/n:gAjusteBCLocImoveis/n:vAjusteBCLocImoveis' )->textContent); - self::assertTrue(strpos($xml, '') < strpos($xml, '')); + self::assertSame(1, $this->xpath($xml)->query( + '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/following-sibling::n:valores' + )?->length); } public function testPropertyLeaseRejectsUnrelatedNationalServiceCode(): void