Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/NT009-CONTRACT-IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/Dto/Nt009DpsPreviewData.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
/**
* @param list<Nt009LinkedPaymentData> $pagamentosVinculados
* @param list<Nt009MovableAssetData> $bensMoveis
* @param list<string> $notasPagamentoAntecipado
*/
public function __construct(
/** finNFSe: 0 regular, 1 credit, 2 debit. */
Expand Down Expand Up @@ -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 = [],
) {
}
}
51 changes: 50 additions & 1 deletion src/Xml/Nt009DpsPreviewBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 !== []
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
223 changes: 223 additions & 0 deletions tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<gPagAntecipado>'), strpos($draft->xml, '<gEstornoCred>'));
}

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',
Expand Down
Loading