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
21 changes: 21 additions & 0 deletions docs/NT009-CONTRACT-IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions src/Xml/Nt009AdjustmentDocumentPreviewGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion src/Xml/Nt009BaseAdjustmentPreviewGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
8 changes: 6 additions & 2 deletions src/Xml/Nt009RealEstatePreviewGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
241 changes: 241 additions & 0 deletions tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,247 @@ 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',
Expand Down
Loading