From 3932a256b9e0ae8641c0b9f6c5394023b27cde87 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:57:12 +0000 Subject: [PATCH] feat: extend NT009 review preview with credit reversal and advance-payment references (#95) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-CONTRACT-IMPLEMENTATION.md | 15 +- src/Dto/Nt009DpsPreviewData.php | 9 + src/Xml/Nt009DpsPreviewBuilder.php | 51 +++- tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php | 223 ++++++++++++++++++ 4 files changed, 295 insertions(+), 3 deletions(-) diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index df02ccb..f710071 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -135,13 +135,24 @@ It now includes explicitly typed, separately validated structural drafts for: optional detail and discount records, with exact-cent reconciliation of `gDetCobranca` to the charge and total charge values to `vServ`. +The preview also models `IBSCBS/valores/trib/gIBSCBS/gEstornoCred` +(Annex VI rows 438-440), containing both decimal `vIBSEstCred` and +`vCBSEstCred`. The caller must separately verify the official cClassTrib +attribute `ind_gEstornoCred`; it is never inferred from the classification +code or the advisory Annex VIII. A verified true flag requires both values, +while a false flag forbids the group. An omitted flag cannot authorize an +estorno. The optional `gPagAntecipado` (rows 441-442) accepts 1-99 +50-character `refNFSe` references to previously issued advance-payment +invoices; no prior invoice is inferred or retrieved by this library. Both +groups remain review-only, with the same unresolved NT009 XSD gate. + 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 independently confirmed adjustment-type domains -and tax-code repercussions, externally verified tax-code correlations, +Still missing are independently confirmed adjustment-type 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 diff --git a/src/Dto/Nt009DpsPreviewData.php b/src/Dto/Nt009DpsPreviewData.php index 91fa762..cc3c3f9 100644 --- a/src/Dto/Nt009DpsPreviewData.php +++ b/src/Dto/Nt009DpsPreviewData.php @@ -19,6 +19,7 @@ /** * @param list $pagamentosVinculados * @param list $bensMoveis + * @param list $notasPagamentoAntecipado */ public function __construct( /** finNFSe: 0 regular, 1 credit, 2 debit. */ @@ -59,6 +60,14 @@ public function __construct( public ?Nt009RealEstateData $imovel = null, /** Optional condominium billing, Annex VI rows 408-421. */ public ?Nt009CondominiumData $condominios = null, + /** Official cClassTrib ind_gEstornoCred, supplied by the caller. */ + public ?bool $exigeEstornoCredito = null, + /** IBS credit reversal amount (Anexo VI, row 439). */ + public ?string $valorEstornoIbs = null, + /** CBS credit reversal amount (Anexo VI, row 440). */ + public ?string $valorEstornoCbs = null, + /** 1-99 prior NFS-e payment-reference keys, 50 characters each. */ + public array $notasPagamentoAntecipado = [], ) { } } diff --git a/src/Xml/Nt009DpsPreviewBuilder.php b/src/Xml/Nt009DpsPreviewBuilder.php index 3211633..ed192cf 100644 --- a/src/Xml/Nt009DpsPreviewBuilder.php +++ b/src/Xml/Nt009DpsPreviewBuilder.php @@ -153,6 +153,25 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi $adjustment->appendChild($document->createElement('vCBS', (string) $preview->valorAjusteCbs)); $group->appendChild($adjustment); } + if ($preview->valorEstornoIbs !== null) { + // Annex VI rows 438-440: allowed/required exclusively + // by the caller-verified cClassTrib.ind_gEstornoCred. + $reversal = $document->createElement('gEstornoCred'); + $reversal->appendChild($document->createElement('vIBSEstCred', $preview->valorEstornoIbs)); + $reversal->appendChild($document->createElement('vCBSEstCred', (string) $preview->valorEstornoCbs)); + $group->appendChild($reversal); + } + if ($preview->notasPagamentoAntecipado !== []) { + // Annex VI rows 441-442: explicit references only; + // the library never invents a prior paid invoice. + $advance = $document->createElement('gPagAntecipado'); + foreach ($preview->notasPagamentoAntecipado as $key) { + $ref = $document->createElement('refNFSe'); + $ref->appendChild($document->createTextNode($key)); + $advance->appendChild($ref); + } + $group->appendChild($advance); + } $trib->appendChild($group); } $valores->appendChild($trib); @@ -209,6 +228,10 @@ private function validatePreview(Nt009DpsPreviewData $preview): void || $preview->codigoCreditoPresumido !== null || $preview->valorAjusteIbs !== null || $preview->valorAjusteCbs !== null + || $preview->exigeEstornoCredito !== null + || $preview->valorEstornoIbs !== null + || $preview->valorEstornoCbs !== null + || $preview->notasPagamentoAntecipado !== [] || $preview->exigeGrupoIbsCbs !== null || $preview->bensMoveis !== [] || $preview->pagamentosVinculados !== [] @@ -241,7 +264,10 @@ private function validatePreview(Nt009DpsPreviewData $preview): void } if (!$preview->exigeGrupoIbsCbs && ($preview->codigoCreditoPresumido !== null - || $preview->valorAjusteIbs !== null || $preview->valorAjusteCbs !== null)) { + || $preview->valorAjusteIbs !== null || $preview->valorAjusteCbs !== null + || $preview->exigeEstornoCredito === true + || $preview->valorEstornoIbs !== null || $preview->valorEstornoCbs !== null + || $preview->notasPagamentoAntecipado !== [])) { throw new \InvalidArgumentException('NT009 forbids gIBSCBS children when ind_gIBSCBS is false'); } if ($preview->codigoCreditoPresumido !== null @@ -262,6 +288,29 @@ private function validatePreview(Nt009DpsPreviewData $preview): void throw new \InvalidArgumentException('NT009 gIBSCBSAjuste requires decimal vIBS and vCBS strings'); } } + if (($preview->valorEstornoIbs === null) !== ($preview->valorEstornoCbs === null)) { + throw new \InvalidArgumentException('NT009 gEstornoCred requires both vIBSEstCred and vCBSEstCred'); + } + if ($preview->exigeEstornoCredito === true && $preview->valorEstornoIbs === null) { + throw new \InvalidArgumentException('NT009 caller-verified ind_gEstornoCred requires both reversal amounts'); + } + if ($preview->valorEstornoIbs !== null) { + if ($preview->exigeEstornoCredito !== true) { + throw new \InvalidArgumentException('NT009 gEstornoCred requires caller-verified ind_gEstornoCred'); + } + if (preg_match('/^[0-9]{1,15}\.[0-9]{2}$/D', $preview->valorEstornoIbs) !== 1 + || preg_match('/^[0-9]{1,15}\.[0-9]{2}$/D', (string) $preview->valorEstornoCbs) !== 1) { + throw new \InvalidArgumentException('NT009 gEstornoCred requires decimal vIBSEstCred and vCBSEstCred strings'); + } + } + if (count($preview->notasPagamentoAntecipado) > 99) { + throw new \InvalidArgumentException('NT009 gPagAntecipado allows at most 99 refNFSe keys'); + } + foreach ($preview->notasPagamentoAntecipado as $key) { + if (!is_string($key) || mb_strlen($key) !== 50 || preg_match('/\p{Cc}/u', $key) !== 0) { + throw new \InvalidArgumentException('NT009 gPagAntecipado/refNFSe must contain 50 valid characters'); + } + } } private function rejectLegacyIbsCbs(DpsData $dps): void diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 78c9171..aa6cfe7 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -1015,6 +1015,229 @@ public function testExteriorIbsCbsAdjustmentDoesNotRequireIssqnMode(): void )->textContent); } + public function testNt009CreditReversalAndAdvanceReferencesFollowAnnexOrder(): void + { + $first = str_repeat('1', 50); + $second = str_repeat('2', 50); + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: true, + valorEstornoIbs: '123456789012345.67', + valorEstornoCbs: '0.05', + notasPagamentoAntecipado: [$first, $second], + ) + ); + self::assertSame('123456789012345.67', $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gEstornoCred/n:vIBSEstCred' + )->textContent); + self::assertSame('0.05', $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gEstornoCred/n:vCBSEstCred' + )->textContent); + self::assertSame(2, $this->xpath($draft->xml)->query( + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gPagAntecipado/n:refNFSe' + )?->length); + self::assertSame($first, $this->first( + $draft->xml, + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gPagAntecipado/n:refNFSe[1]' + )->textContent); + self::assertLessThan(strpos($draft->xml, ''), strpos($draft->xml, '')); + } + + public function testCreditReversalRejectsMissingOfficialClassificationEvidence(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('caller-verified ind_gEstornoCred'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + valorEstornoIbs: '10.00', + valorEstornoCbs: '20.00', + ) + ); + } + + public function testCreditReversalRequiresAmountsWhenOfficialIndicatorIsTrue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('ind_gEstornoCred requires both'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: true, + ) + ); + } + + public function testCreditReversalRejectsIncompletePair(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires both vIBSEstCred and vCBSEstCred'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: true, + valorEstornoIbs: '10.00', + ) + ); + } + + public function testCreditReversalRejectsFalseOfficialIndicator(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires caller-verified ind_gEstornoCred'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: false, + valorEstornoIbs: '10.00', + valorEstornoCbs: '20.00', + ) + ); + } + + public function testCreditReversalRejectsMalformedAmount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires decimal'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: true, + valorEstornoIbs: '10.001', + valorEstornoCbs: '20.00', + ) + ); + } + + public function testCreditReversalIsAbsentWithVerifiedFalseIndicator(): void + { + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + exigeEstornoCredito: false, + ) + ); + self::assertSame(0, $this->xpath($draft->xml)->query( + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gEstornoCred' + )?->length); + } + + public function testAdvanceReferencesRejectIncompleteKeys(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('refNFSe must contain 50'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + notasPagamentoAntecipado: [str_repeat('9', 49)], + ) + ); + } + + public function testAdvanceReferencesRejectMoreThan99Keys(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('at most 99'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + notasPagamentoAntecipado: array_fill(0, 100, str_repeat('9', 50)), + ) + ); + } + + public function testAdvanceReferencesCannotAppearWhenGIBSCBSIsForbidden(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('forbids gIBSCBS children'); + $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: false, + notasPagamentoAntecipado: [str_repeat('9', 50)], + ) + ); + } + + public function testAdvanceReferencesAreOmittedWhenEmpty(): void + { + $draft = $this->builder->previewNt009Dps( + $this->makeDps(), + new Nt009DpsPreviewData( + finalidade: 0, + indicadorDestinatario: 0, + codigoIndicadorOperacao: '010101', + cst: '000', + classificacaoTributaria: '000001', + exigeGrupoIbsCbs: true, + ) + ); + self::assertSame(0, $this->xpath($draft->xml)->query( + '/n:DPS/n:infDPS/n:IBSCBS/n:valores/n:trib/n:gIBSCBS/n:gPagAntecipado' + )?->length); + } + private function makeDps( ?int $ibsCbsFinalidade = null, string $codigoTributacaoNacional = '010701',