From 35ff62a548f4e5aba9d162a2596d7a4a6fd7f224 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:50:01 +0000 Subject: [PATCH 1/2] fix: enforce published NT009 preview field constraints (#95) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-CONTRACT-IMPLEMENTATION.md | 21 ++ .../Nt009AdjustmentDocumentPreviewGroup.php | 31 ++- src/Xml/Nt009BaseAdjustmentPreviewGroup.php | 7 +- src/Xml/Nt009RealEstatePreviewGroup.php | 8 +- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 231 ++++++++++++++++++ 5 files changed, 291 insertions(+), 7 deletions(-) diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index d343cd8..df02ccb 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -177,6 +177,27 @@ commit or tagged release**, adds a compatible version-selector or emitter, and retests the module's emission and recovery flows with the applicable staging environment. Do not pin this unmerged PR's SHA in the consumer. + +## Post-PR #101 published-layout guards (review only) + +The NT009 preview additionally checks the exact published adjustment type +domain (Annex VI row 298), the conditional descriptions for types 99/199 +(row 299), and the strict \`vAjusteAplic <= vTotDoc\` constraint (row 301). +The amount comparison uses decimal strings as integer cents; it never rounds +or converts fiscal amounts to floating point or platform integers. DF-e +reference types are limited to 1/2/3/9, with \`xTipoChaveDFe\` permitted only +for 9 (rows 305-306). Property adjustment types are limited to +01/02/03/04/99, with descriptions permitted for 99 (rows 401-402). +The independently optional export IBS/CBS adjustment (row 336) can appear +without an unrelated ISSQN adjustment (rows 294-296). + +These are source-supported **structural** constraints only. They do not +implement municipal eligibility, taxpayer classification, the official +adjustment-repercussion matrix or downstream emission. In particular, no +restrictions are inferred from consultative Annex VIII, and the preview +remains non-emitting until an applicable officially effective XSD, environment +activation and SEFIN acceptance tests have been confirmed. + ## Outstanding normative gates 1. Obtain authoritative NT009-compatible XSD packages for each intended diff --git a/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php index 16416cd..9b01a33 100644 --- a/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php +++ b/src/Xml/Nt009AdjustmentDocumentPreviewGroup.php @@ -21,12 +21,27 @@ final class Nt009AdjustmentDocumentPreviewGroup { public function build(\DOMDocument $doc, Nt009AdjustmentDocumentData $data): \DOMElement { - if (preg_match('/^[0-9]{1,3}$/D', $data->tipo) !== 1 - || ($data->descricaoTipo !== null && !$this->text($data->descricaoTipo, 150, 0))) { + // Exact values published in Annex VI v1.04.01, row 298. + // Whether a type affects ISSQN, IBS/CBS or Simples remains external. + if (!in_array($data->tipo, [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', + '99', '101', '102', '103', '104', '105', '199', + ], true) + || ($data->descricaoTipo !== null + && (!in_array($data->tipo, ['99', '199'], true) + || !$this->text($data->descricaoTipo, 150, 0)))) { throw new \InvalidArgumentException('Invalid NT009 tpAjusteBC or xTpAjusteBC'); } $this->decimal($data->valorTotalDocumento, 'vTotDoc'); $this->decimal($data->valorAjustado, 'vAjusteAplic'); + // Annex VI row 301 requires vAjusteAplic <= vTotDoc. Compare + // canonical integer cents rather than floats or platform integers. + $total = $this->cents($data->valorTotalDocumento); + $applied = $this->cents($data->valorAjustado); + if (strlen($applied) > strlen($total) + || (strlen($applied) === strlen($total) && strcmp($applied, $total) > 0)) { + throw new \InvalidArgumentException('NT009 vAjusteAplic cannot exceed vTotDoc'); + } foreach ([ 'dtEmiDoc' => $data->dataEmissao, 'dtCompDoc' => $data->dataCompetencia, @@ -55,10 +70,13 @@ public function build(\DOMDocument $doc, Nt009AdjustmentDocumentData $data): \DO $reference = $data->referencia; if ($reference instanceof Nt009NationalInvoiceReference) { - if (preg_match('/^[0-9]$/D', $reference->tipoChaveDfe) !== 1 + // Annex VI rows 305-306: 1 NFS-e, 2 NF-e, 3 CT-e, 9 other. + // xTipoChaveDFe may only describe the "other" type. + if (!in_array($reference->tipoChaveDfe, ['1', '2', '3', '9'], true) || !$this->text($reference->chaveDfe, 50) || ($reference->descricaoTipoChaveDfe !== null - && !$this->text($reference->descricaoTipoChaveDfe, 255))) { + && ($reference->tipoChaveDfe !== '9' + || !$this->text($reference->descricaoTipoChaveDfe, 255)))) { throw new \InvalidArgumentException('Invalid NT009 dFeNacional reference'); } $group = $doc->createElement('dFeNacional'); @@ -98,6 +116,11 @@ public function build(\DOMDocument $doc, Nt009AdjustmentDocumentData $data): \DO return $element; } + private function cents(string $value): string + { + return ltrim(str_replace('.', '', $value), '0') ?: '0'; + } + private function decimal(string $value, string $field): void { if (preg_match('/^[0-9]{1,15}\\.[0-9]{2}$/D', $value) !== 1) { diff --git a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php index 053aa35..6bd89d6 100644 --- a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php +++ b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php @@ -20,11 +20,16 @@ public function build(\DOMDocument $doc, Nt009BaseAdjustmentData $input): \DOMEl $modes = (int) ($input->percentualIssqn !== null) + (int) ($input->valorIssqn !== null) + (int) ($input->documentos !== []); - if ($modes !== 1) { + if ($modes > 1) { throw new \InvalidArgumentException( 'NT009 vAjusteBC requires exactly one percentage, value or documents mode' ); } + // Annex VI rows 294-296 are optional ISSQN alternatives; row 336 + // permits an independent IBS/CBS exterior adjustment. + if ($modes === 0 && $input->valorIbsCbsComExterior === null) { + throw new \InvalidArgumentException('NT009 vAjusteBC requires at least one adjustment'); + } if (count($input->documentos) > 1000) { throw new \InvalidArgumentException('NT009 vAjusteBC supports at most 1000 adjustment documents'); } diff --git a/src/Xml/Nt009RealEstatePreviewGroup.php b/src/Xml/Nt009RealEstatePreviewGroup.php index b83c06d..c24391c 100644 --- a/src/Xml/Nt009RealEstatePreviewGroup.php +++ b/src/Xml/Nt009RealEstatePreviewGroup.php @@ -79,8 +79,12 @@ public function build( } $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))) { + // Annex VI rows 401-402 enumerate five adjustment types. + // A free-text description belongs only to type 99. + if (!in_array($adjustment->tipo, ['01', '02', '03', '04', '99'], true) + || ($adjustment->descricaoTipo !== null + && ($adjustment->tipo !== '99' + || !$this->text($adjustment->descricaoTipo, 150)))) { throw new \InvalidArgumentException('Invalid NT009 property adjustment type'); } $this->decimal($adjustment->valor, 15, 'vAjusteBCLocImoveis'); diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index d41ccd8..635df97 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -774,6 +774,237 @@ public function testDocumentAdjustmentRejectsInvalidFinancialPrecision(): void ); } + + public function testDocumentAdjustmentCannotExceedTotalAtLargePrecision(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('vAjusteAplic cannot exceed vTotDoc'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '1', + '999999999999998.99', + '999999999999999.00', + new Nt009OtherDocumentReference('CON-1', 'Contrato'), + ), + ], + ), + ) + ); + } + + public function testDocumentAdjustmentCanEqualLargeTotal(): void + { + $amount = '999999999999999.99'; + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '199', + $amount, + $amount, + new Nt009OtherDocumentReference('CON-1', 'Contrato'), + descricaoTipo: 'Reembolso', + ), + ], + ), + ) + ); + self::assertSame($amount, $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:vAjusteAplic' + )->textContent); + } + + public function testDocumentAdjustmentRejectsUnlistedPublishedType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 tpAjusteBC'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '100', '50.00', '20.00', + new Nt009OtherDocumentReference('CON-1', 'Contrato'), + ), + ], + ), + ) + ); + } + + public function testDocumentAdjustmentDescriptionCannotAccompanyOrdinaryType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 tpAjusteBC'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '1', '50.00', '20.00', + new Nt009OtherDocumentReference('CON-1', 'Contrato'), + descricaoTipo: 'Descrição não prevista', + ), + ], + ), + ) + ); + } + + public function testNationalDfeReferenceRejectsUnknownType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 dFeNacional reference'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '1', '50.00', '20.00', + new Nt009NationalInvoiceReference('4', 'KEY-123'), + ), + ], + ), + ) + ); + } + + public function testNationalDfeDescriptionRequiresOtherType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 dFeNacional reference'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '1', '50.00', '20.00', + new Nt009NationalInvoiceReference('1', 'KEY-123', 'Outra espécie'), + ), + ], + ), + ) + ); + } + + public function testNationalDfeOtherTypeAcceptsDescription(): void + { + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData( + documentos: [ + new Nt009AdjustmentDocumentData( + '1', '50.00', '20.00', + new Nt009NationalInvoiceReference('9', 'KEY-123', 'Outro documento'), + ), + ], + ), + ) + ); + self::assertSame('Outro documento', $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:documentos/n:docAjusteBC/n:dFeNacional/n:xTipoChaveDFe' + )->textContent); + } + + public function testPropertyAdjustmentRejectsUnknownCode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 property adjustment type'); + $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990301'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + imovel: new Nt009RealEstateData( + municipioIbge: '3304557', + unidades: [ + new Nt009RealEstateUnitData( + cib: '12345678', + cep: '20000000', + logradouro: 'Rua Um', + numero: '42', + ajustes: [new Nt009PropertyAdjustmentData('98', '10.00')], + ), + ], + ), + ) + ); + } + + public function testPropertyAdjustmentDescriptionRequiresOtherCode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid NT009 property adjustment type'); + $this->builder->previewNt009Dps( + $this->makeDps(codigoTributacaoNacional: '990301'), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + imovel: new Nt009RealEstateData( + municipioIbge: '3304557', + unidades: [ + new Nt009RealEstateUnitData( + cib: '12345678', + cep: '20000000', + logradouro: 'Rua Um', + numero: '42', + ajustes: [new Nt009PropertyAdjustmentData('01', '10.00', 'Indevido')], + ), + ], + ), + ) + ); + } + + public function testExteriorIbsCbsAdjustmentDoesNotRequireIssqnMode(): void + { + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + ajusteBase: new Nt009BaseAdjustmentData(valorIbsCbsComExterior: '12.50'), + ) + ); + self::assertSame('12.50', $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:valores/n:vAjusteBC/n:vAjusteBCIBSCBSComExt' + )->textContent); + } + private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701', From f5158a175880c63da1c8f281118c3942a598e75e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:52:12 +0000 Subject: [PATCH 2/2] style: format NT009 preview regression tests (#95) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 635df97..78c9171 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -837,7 +837,9 @@ public function testDocumentAdjustmentRejectsUnlistedPublishedType(): void ajusteBase: new Nt009BaseAdjustmentData( documentos: [ new Nt009AdjustmentDocumentData( - '100', '50.00', '20.00', + '100', + '50.00', + '20.00', new Nt009OtherDocumentReference('CON-1', 'Contrato'), ), ], @@ -858,7 +860,9 @@ public function testDocumentAdjustmentDescriptionCannotAccompanyOrdinaryType(): ajusteBase: new Nt009BaseAdjustmentData( documentos: [ new Nt009AdjustmentDocumentData( - '1', '50.00', '20.00', + '1', + '50.00', + '20.00', new Nt009OtherDocumentReference('CON-1', 'Contrato'), descricaoTipo: 'Descrição não prevista', ), @@ -880,7 +884,9 @@ public function testNationalDfeReferenceRejectsUnknownType(): void ajusteBase: new Nt009BaseAdjustmentData( documentos: [ new Nt009AdjustmentDocumentData( - '1', '50.00', '20.00', + '1', + '50.00', + '20.00', new Nt009NationalInvoiceReference('4', 'KEY-123'), ), ], @@ -901,7 +907,9 @@ public function testNationalDfeDescriptionRequiresOtherType(): void ajusteBase: new Nt009BaseAdjustmentData( documentos: [ new Nt009AdjustmentDocumentData( - '1', '50.00', '20.00', + '1', + '50.00', + '20.00', new Nt009NationalInvoiceReference('1', 'KEY-123', 'Outra espécie'), ), ], @@ -920,7 +928,9 @@ public function testNationalDfeOtherTypeAcceptsDescription(): void ajusteBase: new Nt009BaseAdjustmentData( documentos: [ new Nt009AdjustmentDocumentData( - '1', '50.00', '20.00', + '1', + '50.00', + '20.00', new Nt009NationalInvoiceReference('9', 'KEY-123', 'Outro documento'), ), ],