diff --git a/docs/NT009-CONTRACT-IMPLEMENTATION.md b/docs/NT009-CONTRACT-IMPLEMENTATION.md index 120a701..d343cd8 100644 --- a/docs/NT009-CONTRACT-IMPLEMENTATION.md +++ b/docs/NT009-CONTRACT-IMPLEMENTATION.md @@ -112,9 +112,40 @@ 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-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 + 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 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 `xsi:schemaLocation` reference, without claiming that it matches a new schema. The existing `DpsSchemaValidator` remains bound to the February 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 @@ + $documentos + */ + public function __construct( + /** pAjusteBCISSQN, mutually exclusive with valorIssqn. */ + public ?string $percentualIssqn = null, + /** vAjusteBCISSQN, mutually exclusive with percentualIssqn. */ + 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/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 @@ + $pagamentosVinculados + * @param list $bensMoveis + */ public function __construct( /** finNFSe: 0 regular, 1 credit, 2 debit. */ public int $finalidade, @@ -43,6 +47,18 @@ 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 = [], + /** 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/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 = [], + ) { + } +} diff --git a/src/Dto/Nt009RecipientAddressData.php b/src/Dto/Nt009RecipientAddressData.php new file mode 100644 index 0000000..d5bd77d --- /dev/null +++ b/src/Dto/Nt009RecipientAddressData.php @@ -0,0 +1,29 @@ +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); + } 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'); + } + $group = $doc->createElement('docOutro'); + $this->add($doc, $group, 'nDoc', $reference->numero); + $this->add($doc, $group, 'xDoc', $reference->descricao); + } + $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); + } +} diff --git a/src/Xml/Nt009BaseAdjustmentPreviewGroup.php b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php new file mode 100644 index 0000000..053aa35 --- /dev/null +++ b/src/Xml/Nt009BaseAdjustmentPreviewGroup.php @@ -0,0 +1,71 @@ +percentualIssqn !== null) + + (int) ($input->valorIssqn !== null) + + (int) ($input->documentos !== []); + if ($modes !== 1) { + throw new \InvalidArgumentException( + '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) { + $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->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); + } + + 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); + } +} diff --git a/src/Xml/Nt009CondominiumPreviewGroup.php b/src/Xml/Nt009CondominiumPreviewGroup.php new file mode 100644 index 0000000..b523b91 --- /dev/null +++ b/src/Xml/Nt009CondominiumPreviewGroup.php @@ -0,0 +1,134 @@ +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->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'; + 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->addCents($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): 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 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 + { + $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; + } +} diff --git a/src/Xml/Nt009DpsPreviewBuilder.php b/src/Xml/Nt009DpsPreviewBuilder.php index d209488..3211633 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,32 @@ 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, + $dps->codigoTributacaoNacional + ) 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. @@ -109,6 +157,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 +209,11 @@ 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 !== [] + || $preview->imovel !== null + || $preview->condominios !== null; if (!$hasIbs) { return; @@ -209,6 +266,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 !== '' 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); + } +} 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; + } +} 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); + } +} diff --git a/src/Xml/Nt009RecipientPreviewGroup.php b/src/Xml/Nt009RecipientPreviewGroup.php new file mode 100644 index 0000000..6bf80e0 --- /dev/null +++ b/src/Xml/Nt009RecipientPreviewGroup.php @@ -0,0 +1,141 @@ +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($groupName); + 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) { + $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) { + 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; + } +} diff --git a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php index 12e100a..d41ccd8 100644 --- a/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php +++ b/tests/Unit/Xml/Nt009DpsPreviewBuilderTest.php @@ -7,9 +7,26 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +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; +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\Nt009NationalInvoiceReference; +use LibreCodeCoop\NfsePHP\Dto\Nt009OtherDocumentReference; +use LibreCodeCoop\NfsePHP\Dto\Nt009OtherFiscalReference; +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; use LibreCodeCoop\NfsePHP\Xml\XmlBuilder; @@ -249,19 +266,532 @@ 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], + ) + ); + } + + 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::assertSame(1, $this->xpath($xml)->query( + '/n:DPS/n:infDPS/n:IBSCBS/n:imovel/following-sibling::n:valores' + )?->length); + } + + 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 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); + $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')], + ), + ) + ); + } + + 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', + endereco: new Nt009RecipientAddressData( + logradouro: 'Rua & Filiais', + numero: '10', + bairro: 'Centro', + municipioIbge: '3304557', + cep: '20000000', + ), + ), + ), + ], + ), + ) + )->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); + 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 + { + $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', + ?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', numeroDps: '17', - codigoTributacaoNacional: '010701', + codigoTributacaoNacional: $codigoTributacaoNacional, ibsCbsFinalidade: $ibsCbsFinalidade, + deducaoReducao: $deducaoReducao, ); }