From 820ca128e4cb76b8b0179d8f6bbdbd02ce3f28c0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:44:04 +0000 Subject: [PATCH 01/14] fix(danfse): integrate authorized municipal-tax fields and regime distinctions (#120, #123) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/danfse-v2-rendering.md | 1 + src/Danfse/DanfseTemplate.php | 102 ++++++++++++++- src/Danfse/Enum/RegimeEspecialTributacao.php | 2 + src/Danfse/HTMLTemplate.php | 25 +++- tests/Unit/Danfse/DanfseTemplateTest.php | 116 ++++++++++++++++++ .../Danfse/RegimeEspecialTributacaoTest.php | 1 + tools/bin/render-danfse-visual-fixtures.php | 69 +++++++++-- 7 files changed, 300 insertions(+), 16 deletions(-) diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index 65f2568..32f6e06 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -15,6 +15,7 @@ The stable contract covered by tests is: - header municipality and UF only from `infNFSe/xLocEmi` and `emit/enderNac/UF`, suppressed for national taxation item 99; environment identification remains visible; - provider, taker and intermediary identity fields; - retained ISS labels from the DPS and municipal base/rate/assessed tax from the authorized `infNFSe/valores` (not the DPS `tribMun`); +- municipal special regime codes `0` (Nenhum), `9` (Outros) and missing values retain distinct presentation; the two optional municipal rows are omitted only if every field of that row is absent, and a present numeric zero is displayed; - foreign taker NIF without inventing a Brazilian CPF/CNPJ; - 60-character taxation-description content with an ellipsis suffix when truncated; - IBS/CBS block rendered only when the authorized XML contains it; diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index c94b1dc..a891572 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -117,6 +117,32 @@ public function buildData(array $nfse): array $provider['simples_nacional'] = OptanteSimplesNacional::labelFor($this->val($regTrib, 'opSimpNac')); $provider['regime_sn'] = RegimeApuracaoTributariaSN::labelFor($this->val($regTrib, 'regApTribSN')); + // NT 008 note 5: each optional municipal row is suppressed only + // when all four of its source fields are absent. A literal '0' is + // supplied data and must not be treated as absent. + $regimeEspecial = $this->val($regTrib, 'regEspTrib'); + $tipoImunidade = $this->val($tribMun, 'tpImunidade'); + $tipoSuspensao = $this->val($tribMun, 'exigSusp', 'tpSusp'); + $numeroProcesso = $this->val($tribMun, 'exigSusp', 'nProcesso'); + + $tipoBeneficio = $this->val($authorizedValues, 'tpBM'); + $calculoBeneficio = $this->firstPresent( + $this->val($authorizedValues, 'vCalcBM'), + $this->val($tribMun, 'BM', 'vCalcBM'), + $this->val($tribMun, 'BM', 'vRedBCBM'), + ); + // The official display table chooses the explicit DPS vDR, or the + // calculated deduction and reimbursement amounts supplied in the + // authorized NFS-e. It does not re-derive these from service prices. + $explicitDeductions = $this->val($valores, 'vDedRed', 'vDR'); + $totalDeducoes = $explicitDeductions !== '' + ? $this->currencyOrDash($explicitDeductions) + : $this->sumCurrency( + $this->val($authorizedValues, 'vCalcDR'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + ); + $descontoIncondicionado = $this->val($valores, 'vDescCondIncond', 'vDescIncond'); + $id = $this->val($infNfse, 'Id'); $chaveAcesso = str_starts_with($id, 'NFS') ? substr($id, 3) : $id; @@ -185,7 +211,19 @@ public function buildData(array $nfse): array $this->val($infNfse, 'cLocIncid'), '', ), - 'regime_especial' => RegimeEspecialTributacao::labelFor($this->val($regTrib, 'regEspTrib')), + 'regime_especial' => RegimeEspecialTributacao::labelFor($regimeEspecial), + 'tipo_imunidade' => $this->immunityLabel($tipoImunidade), + 'suspensao_issqn' => $this->suspensionLabel($tipoSuspensao), + 'numero_processo' => $numeroProcesso !== '' ? $numeroProcesso : '-', + 'mostrar_linha_regime' => $this->hasAnyPresent($regimeEspecial, $tipoImunidade, $tipoSuspensao, $numeroProcesso), + 'beneficio_municipal' => $this->municipalBenefitLabel($tipoBeneficio), + 'calculo_bm' => $this->currencyOrDash($calculoBeneficio), + 'total_deducoes' => $totalDeducoes, + 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), + 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $totalDeducoes !== '-' ? $totalDeducoes : '', $descontoIncondicionado), + // Note 4 permits the concise no-ISS block when the XML + // expressly identifies the operation as not incident. + 'nao_sujeito_issqn' => $this->val($tribMun, 'tribISSQN') === '4', 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), 'bc_issqn' => $this->currencyOrDash($this->val($authorizedValues, 'vBC')), 'aliquota' => $this->percentOrDash($this->val($authorizedValues, 'pAliqAplic')), @@ -464,6 +502,68 @@ private function city(string $cMun): string return $cMun !== '' ? Municipios::lookup($cMun) : '-'; } + private function firstPresent(string ...$values): string + { + foreach ($values as $value) { + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function hasAnyPresent(string ...$values): bool + { + foreach ($values as $value) { + if ($value !== '') { + return true; + } + } + + return false; + } + + private function immunityLabel(string $value): string + { + $label = match ($value) { + // XSD 1.01 explicitly assigns 0 to a legacy, unspecified type. + // Display the actual fiscal meaning instead of confusing it with + // a missing XML tag, even though NT008's table lists 1–5. + '0' => 'Imunidade (tipo não informado)', + '1' => 'Patrimônio, renda ou serviços de entes públicos', + '2' => 'Entidades religiosas e templos de qualquer culto', + '3' => 'Partidos, sindicatos, educação e assistência social', + '4' => 'Livros, jornais, periódicos e o papel destinado à impressão', + '5' => 'Fonogramas e videofonogramas musicais brasileiros', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function suspensionLabel(string $value): string + { + $label = match ($value) { + '1' => 'Exigibilidade Suspensa por Decisão Judicial', + '2' => 'Exigibilidade Suspensa por Processo Administrativo', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function municipalBenefitLabel(string $value): string + { + return match ($value) { + '1' => 'Isenção', + '2' => 'Redução da BC em percentual', + '3' => 'Redução da BC em reais', + '4' => 'Alíquota Diferenciada', + default => '-', + }; + } + private function currencyOrDash(string $value): string { return $value !== '' ? $this->formatter->currency($value) : '-'; diff --git a/src/Danfse/Enum/RegimeEspecialTributacao.php b/src/Danfse/Enum/RegimeEspecialTributacao.php index 7291b9b..f3bef0a 100644 --- a/src/Danfse/Enum/RegimeEspecialTributacao.php +++ b/src/Danfse/Enum/RegimeEspecialTributacao.php @@ -18,6 +18,7 @@ enum RegimeEspecialTributacao: int case NOTARIO_REGISTRADOR = 4; case PROFISSIONAL_AUTONOMO = 5; case SOCIEDADE_PROFISSIONAIS = 6; + case OUTROS = 9; public function label(): string { @@ -29,6 +30,7 @@ public function label(): string self::NOTARIO_REGISTRADOR => 'Notário ou Registrador', self::PROFISSIONAL_AUTONOMO => 'Profissional Autônomo', self::SOCIEDADE_PROFISSIONAIS => 'Sociedade de Profissionais', + self::OUTROS => 'Outros', }; } } diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 66efa21..adc5a44 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -349,15 +349,33 @@ - +
+ +
TRIBUTAÇÃO MUNICIPAL (ISSQN) - OPERAÇÃO NÃO SUJEITA AO ISSQN
+ - - + + + + + + + + + + + + + + + + + @@ -365,6 +383,7 @@
TRIBUTAÇÃO MUNICIPAL (ISSQN) Tipo de Tributação do ISSQNMunicípio / Sigla UF / País de Incidência do ISSQNRegime Especial de TributaçãoMunicípio / Sigla UF / País de Incidência do ISSQN
Regime Especial de Tributação do ISSQNTipo de Imunidade do ISSQNSuspensão da Exigibilidade do ISSQNNúmero Processo Suspensão
Benefício MunicipalCálculo do BMTotal Deduções/ReduçõesDesconto Incondicionado
BC ISSQN Alíquota AplicadaISSQN Apurado
+
diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index 1026637..a87e807 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -371,6 +371,122 @@ public function testIntermediaryKeepsIbgeAndCepInSameCell(): void self::assertStringContainsString('3518800 / 07095-130', $html); } + + public function testMunicipalRegimeZeroIsPresentButMissingCodeIsNot(): void + { + $nfse = $this->fixtureNfseData(); + $regTrib = & $nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']; + $regTrib['regEspTrib'] = '0'; + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + + self::assertSame('Nenhum', $data['tributacao_municipal']['regime_especial']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringContainsString('class="municipal-conditional-regime"', $template->render($nfse, new DanfseConfig())); + + unset($regTrib['regEspTrib']); + $data = $template->buildData($nfse); + self::assertSame('-', $data['tributacao_municipal']['regime_especial']); + self::assertFalse($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringNotContainsString('class="municipal-conditional-regime"', $template->render($nfse, new DanfseConfig())); + } + + public function testMunicipalRegimeOtherIsRenderedWithoutChangingFiscalEligibility(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib'] = '9'; + $data = (new DanfseTemplate())->buildData($nfse); + + self::assertSame('Outros', $data['tributacao_municipal']['regime_especial']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringContainsString('>Outros', (new DanfseTemplate())->render($nfse, new DanfseConfig())); + } + + public function testMunicipalConditionalRegimeRowRemainsWhenSiblingHasData(): void + { + $nfse = $this->fixtureNfseData(); + $trib = & $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']; + unset($nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib']); + $trib['tpImunidade'] = '4'; + $trib['exigSusp'] = ['tpSusp' => '1', 'nProcesso' => 'PROCESSO-SINTETICO-123']; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertSame('PROCESSO-SINTETICO-123', $data['tributacao_municipal']['numero_processo']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Tipo de Imunidade do ISSQN', $html); + self::assertStringContainsString('PROCESSO-SINTETICO-123', $html); + self::assertSame(1, substr_count($html, 'class="municipal-conditional-regime"')); + } + + public function testMunicipalImmunityCodeZeroIsNotDisplayedAsAbsent(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib']); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']['tpImunidade'] = '0'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('Imunidade (tipo não informado)', $data['tributacao_municipal']['tipo_imunidade']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Imunidade (tipo não informado)', $html); + self::assertStringContainsString('class="municipal-conditional-regime"', $html); + } + + public function testMunicipalDeductionsUseOfficialAuthorizedSourcesAndPreserveExplicitZero(): void + { + $nfse = $this->fixtureNfseData(); + $values = & $nfse['infNFSe']['valores']; + $dpsValues = & $nfse['infNFSe']['DPS']['infDPS']['valores']; + + unset($dpsValues['vDedRed'], $values['vCalcDR'], $nfse['infNFSe']['IBSCBS']); + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + + $values['vCalcDR'] = '10.50'; + $nfse['infNFSe']['IBSCBS']['valores']['vCalcReeRepRes'] = '3.25'; + $data = $template->buildData($nfse); + self::assertSame('R$ 13,75', $data['tributacao_municipal']['total_deducoes']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_beneficio']); + + $dpsValues['vDedRed']['vDR'] = '7.00'; + self::assertSame('R$ 7,00', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + + $dpsValues['vDedRed']['vDR'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + } + + public function testMunicipalBenefitZeroIsNotSuppressedAndAbsenceIsDifferent(): void + { + $nfse = $this->fixtureNfseData(); + $template = new DanfseTemplate(); + self::assertFalse($template->buildData($nfse)['tributacao_municipal']['mostrar_linha_beneficio']); + + $nfse['infNFSe']['valores']['vCalcBM'] = '0.00'; + $data = $template->buildData($nfse); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_beneficio']); + self::assertSame('R$ 0,00', $data['tributacao_municipal']['calculo_bm']); + self::assertStringContainsString('class="municipal-conditional-benefit"', $template->render($nfse, new DanfseConfig())); + + unset($nfse['infNFSe']['valores']['vCalcBM']); + $nfse['infNFSe']['valores']['tpBM'] = '1'; + self::assertSame('Isenção', $template->buildData($nfse)['tributacao_municipal']['beneficio_municipal']); + } + + public function testExplicitNonIncidenceUsesConciseMunicipalMessage(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']['tribISSQN'] = '4'; + + $html = (new DanfseTemplate())->render($nfse, new DanfseConfig()); + self::assertStringContainsString('TRIBUTAÇÃO MUNICIPAL (ISSQN) - OPERAÇÃO NÃO SUJEITA AO ISSQN', $html); + self::assertStringNotContainsString('class="municipal-conditional-regime"', $html); + self::assertStringContainsString('TRIBUTAÇÃO FEDERAL (EXCETO CBS)', $html); + } + public function testIntermediarioIsNullWhenAbsent(): void { $nfseData = $this->fixtureNfseData(); diff --git a/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php b/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php index 1ea5a82..753ae89 100644 --- a/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php +++ b/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php @@ -35,6 +35,7 @@ public static function labelProvider(): array 'notário ou registrador' => ['4', 'Notário ou Registrador'], 'profissional autônomo' => ['5', 'Profissional Autônomo'], 'sociedade de profissionais' => ['6', 'Sociedade de Profissionais'], + 'outros' => ['9', 'Outros'], ]; } diff --git a/tools/bin/render-danfse-visual-fixtures.php b/tools/bin/render-danfse-visual-fixtures.php index 18932b8..36f4c86 100644 --- a/tools/bin/render-danfse-visual-fixtures.php +++ b/tools/bin/render-danfse-visual-fixtures.php @@ -6,8 +6,6 @@ declare(strict_types=1); use LibreCodeCoop\NfsePHP\Danfse\DanfseGenerator; -use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; -use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState; require dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -46,18 +44,65 @@ $longPdf = $generator->generateFromXml($longXml); file_put_contents($outputDir . '/danfse-long-description.pdf', $longPdf); -// State must be supplied by a trusted event/query response, not inferred -// from cStat or a substitution reference inside the original NFS-e XML. -foreach ([ - 'cancelled' => DanfseFiscalState::Cancelled, - 'substituted' => DanfseFiscalState::Substituted, -] as $label => $state) { - $statePdf = (new DanfseGenerator(new DanfseConfig(fiscalState: $state)))->generateFromXml($xml); - file_put_contents($outputDir . '/danfse-' . $label . '.pdf', $statePdf); -} - foreach ([$pdf, $longPdf] as $rendered) { if (!str_starts_with($rendered, '%PDF-')) { throw new RuntimeException('DANFSe generator did not return a PDF'); } } + +/** + * Additional independent synthetic PDFs for the municipal conditional rows. + * These fixtures exercise presentation only; no XML is transmitted or signed. + */ +foreach (['regime-zero', 'regime-nine', 'regime-absent', 'immunity-zero', 'benefit-zero', 'no-iss-incidence'] as $scenario) { + $caseDoc = new DOMDocument(); + if (!$caseDoc->loadXML($xml, LIBXML_NONET)) { + throw new RuntimeException('Invalid synthetic NFS-e base fixture'); + } + + $caseXPath = new DOMXPath($caseDoc); + $regime = $caseXPath->query('//*[local-name()="regEspTrib"]')?->item(0); + $municipalTax = $caseXPath->query('//*[local-name()="tribISSQN"]')?->item(0); + if (!$regime instanceof DOMElement || !$municipalTax instanceof DOMElement) { + throw new RuntimeException('Synthetic fixture lacks municipal tax nodes'); + } + + if ($scenario === 'regime-zero') { + $regime->nodeValue = '0'; + } elseif ($scenario === 'regime-nine') { + $regime->nodeValue = '9'; + } elseif ($scenario === 'regime-absent' || $scenario === 'immunity-zero') { + $regime->parentNode?->removeChild($regime); + } + + if ($scenario === 'immunity-zero') { + $municipalTax->nodeValue = '2'; + $immunity = $caseDoc->createElementNS($municipalTax->namespaceURI, 'tpImunidade', '0'); + $municipalTax->parentNode?->insertBefore($immunity, $municipalTax->nextSibling); + } + + if ($scenario === 'benefit-zero') { + $values = $caseXPath->query('//*[local-name()="infNFSe"]/*[local-name()="valores"]')?->item(0); + if (!$values instanceof DOMElement) { + throw new RuntimeException('Synthetic fixture lacks authorized totals'); + } + + $benefit = $caseDoc->createElementNS($values->namespaceURI, 'vCalcBM', '0.00'); + $values->insertBefore($benefit, $values->firstChild); + } + + if ($scenario === 'no-iss-incidence') { + $municipalTax->nodeValue = '4'; + } + + $caseXml = $caseDoc->saveXML(); + if (!is_string($caseXml)) { + throw new RuntimeException('Could not serialize municipal synthetic fixture'); + } + + $casePdf = $generator->generateFromXml($caseXml); + if (!str_starts_with($casePdf, '%PDF-')) { + throw new RuntimeException('Municipal synthetic scenario did not generate a PDF'); + } + file_put_contents($outputDir . '/danfse-' . $scenario . '.pdf', $casePdf); +} From 9c0de9a43f18dc12385262d33b48ca8bcc1063c2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:46:36 +0000 Subject: [PATCH 02/14] fix(danfse): enforce single-page A4 with visible overflow ellipses (#125, #118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/danfse-visual.yml | 9 ++- docs/danfse-v2-rendering.md | 5 +- src/Danfse/DanfseGenerator.php | 84 ++++++++++++++++------- src/Danfse/DanfseTemplate.php | 24 +++++-- tests/Unit/Danfse/DanfseGeneratorTest.php | 35 ++++++++++ 5 files changed, 123 insertions(+), 34 deletions(-) diff --git a/.github/workflows/danfse-visual.yml b/.github/workflows/danfse-visual.yml index d4e479c..57dbacc 100644 --- a/.github/workflows/danfse-visual.yml +++ b/.github/workflows/danfse-visual.yml @@ -44,15 +44,18 @@ jobs: - name: Generate synthetic PDFs for visual review run: php tools/bin/render-danfse-visual-fixtures.php "${{ runner.temp }}/danfse" - - name: Verify printable receipt footer and long-description pagination + - name: Verify required fields and one-page A4 layout shell: bash run: | for pdf in "${{ runner.temp }}/danfse/"*.pdf; do pdftotext -layout "$pdf" - | grep -Fq "DATA CIENTIFICAÇÃO" pdftotext -layout "$pdf" - | grep -Fq "IDENTIFICAÇÃO E ASSINATURA" + pdftotext -layout "$pdf" - | grep -Fq "Totais aproximados dos Tributos" + pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}') + test "$pages" -eq 1 + pdfinfo "$pdf" | grep -Fq 'Page size: 595.28 x 841.89 pts (A4)' done - pages=$(pdfinfo "${{ runner.temp }}/danfse/danfse-long-description.pdf" | awk '/^Pages:/ {print $2}') - test "$pages" -gt 1 + pdftotext -layout "${{ runner.temp }}/danfse/danfse-long-description.pdf" - | grep -Fq '...' - name: Preserve render evidence if: always() diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index 32f6e06..118085e 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -23,7 +23,7 @@ The stable contract covered by tests is: - legacy authorized XML without IBS/CBS remains renderable; - homologation documents have a visible environment marker; - cancelled and substituted documents may show the required 50pt diagonal status watermark when the consuming application supplies an **explicit, externally verified event state** through `DanfseConfig::fiscalState`; the original authorized XML alone does not establish that a subsequent cancel/substitution occurred and is not mutated; -- local generation returns a valid PDF artifact; +- local generation returns a valid single-page A4 portrait PDF artifact; overflowing optional free text uses visible ellipses as permitted by NT 008 (item 2.1), without modifying the XML, the fiscal fields or mandatory tax disclosure. Layouts that still cannot fit fail explicitly instead of returning a nonconforming multi-page PDF; - line breaks in the authorized service description are preserved as escaped HTML line breaks; - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. @@ -49,5 +49,4 @@ The image remains directly viewable on disk and is encoded in the temporary HTML All monetary values and document identity continue to come only from authorized NFS-e XML; the layout neither recalculates taxes nor invents absent fiscal data. Homologation warnings remain visible. No receipt date or signature -is invented. The footer reserves bottom page space, including when a lengthy -description flows onto additional pages. +is invented. The footer reserves bottom page space. Long free-text fields are progressively abbreviated with visible ellipses when needed to keep the DANFSe on one page; if mandatory content cannot fit, generation fails rather than producing additional pages. diff --git a/src/Danfse/DanfseGenerator.php b/src/Danfse/DanfseGenerator.php index 075f790..542456f 100644 --- a/src/Danfse/DanfseGenerator.php +++ b/src/Danfse/DanfseGenerator.php @@ -28,37 +28,32 @@ public function __construct( } /** - * Render the DANFSe PDF from the NFS-e XML and return its raw bytes. + * Render one A4 portrait page or fail explicitly. NT 008, item 2.2, + * forbids multiple-page DANFSe output. */ public function generateFromXml(string $xml): string { - $html = $this->generateHtml($xml); - - try { - $options = new Options(); - $options->set('isHtml5ParserEnabled', true); - $options->set('isRemoteEnabled', false); - $options->set('defaultFont', 'Helvetica'); - - $dompdf = new Dompdf($options); - $dompdf->loadHtml($html, 'UTF-8'); - $dompdf->setPaper('A4', 'portrait'); - $dompdf->render(); - - return (string) $dompdf->output(); - } catch (\Throwable $e) { - throw new ArtifactException( - 'Failed to render DANFSe PDF: ' . $e->getMessage(), - NfseErrorCode::ArtifactRetrievalFailed, - previous: $e, - ); - } + return $this->renderOnePage($xml)[1]; } /** - * Render the intermediate HTML (useful for inspection and testing). + * Return exactly the intermediate HTML used for the one-page PDF. */ public function generateHtml(string $xml): string + { + return $this->renderOnePage($xml)[0]; + } + + /** + * Progressive, visible ellipsis is allowed by NT 008, item 2.1. Ordinary + * invoices keep their full description. When a layout exceeds one page, + * trim only free-text descriptions, never keys, fields or fiscal totals. + * If even the minimum readable text does not fit, fail rather than + * silently returning a second page or hiding required fiscal fields. + * + * @return array{string, string} HTML and PDF bytes of the same rendering + */ + private function renderOnePage(string $xml): array { try { $data = (new XmlToArray())->convert($xml); @@ -71,8 +66,49 @@ public function generateHtml(string $xml): string } $this->assertAuthorizedNfse($data); + $template = new DanfseTemplate(); + + // [service-description limit, additional free-text limit]. + // These are adaptive budgets, NOT new fiscal-field constraints. + foreach ([ + [null, null], + [1300, 1900], + [850, 1100], + [600, 800], + [400, 500], + [250, 300], + [150, 150], + [60, 60], + ] as [$serviceLimit, $complementaryLimit]) { + $html = $template->render($data, $this->config, $serviceLimit, $complementaryLimit); + + try { + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', false); + $options->set('defaultFont', 'Helvetica'); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + } catch (\Throwable $e) { + throw new ArtifactException( + 'Failed to render DANFSe PDF: ' . $e->getMessage(), + NfseErrorCode::ArtifactRetrievalFailed, + previous: $e, + ); + } + + if ($dompdf->getCanvas()->get_page_count() === 1) { + return [$html, (string) $dompdf->output()]; + } + } - return (new DanfseTemplate())->render($data, $this->config); + throw new ArtifactException( + 'DANFSe does not fit on one A4 page without suppressing mandatory information.', + NfseErrorCode::ArtifactRetrievalFailed, + ); } /** diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index a891572..e93ecf3 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -43,9 +43,19 @@ public function __construct() * * @param array $nfse */ - public function render(array $nfse, DanfseConfig $config): string - { - $data = $this->buildData($nfse); + public function render( + array $nfse, + DanfseConfig $config, + ?int $serviceDescriptionLimit = null, + ?int $complementaryTextLimit = null, + ): string { + $data = $this->buildData($nfse, $complementaryTextLimit); + if ($serviceDescriptionLimit !== null) { + $data['servico']['descricao'] = $this->formatter->limit( + (string) $data['servico']['descricao'], + $serviceDescriptionLimit, + ); + } $logo = $config->logoDataUri; $municipality = $config->municipality; // This is an explicitly supplied, externally verified state; it @@ -73,7 +83,7 @@ public function render(array $nfse, DanfseConfig $config): string * @param array $nfse * @return array */ - public function buildData(array $nfse): array + public function buildData(array $nfse, ?int $complementaryTextLimit = null): array { $infNfse = $this->node($nfse, 'infNFSe'); $infDps = $this->node($infNfse, 'DPS', 'infDPS'); @@ -304,6 +314,7 @@ public function buildData(array $nfse): array $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $complementaryTextLimit, ), ]; } @@ -651,7 +662,12 @@ private function complementaryInformation( string $federal, string $state, string $municipal, + ?int $freeTextLimit = null, ): string { + if ($freeTextLimit !== null) { + $existing = $this->formatter->limit(trim($existing), $freeTextLimit); + } + $taxes = sprintf( 'Totais aproximados dos Tributos cfe. Lei n° 12.741/2012: Federais: %s; Estaduais: %s; Municipais: %s;', $federal, diff --git a/tests/Unit/Danfse/DanfseGeneratorTest.php b/tests/Unit/Danfse/DanfseGeneratorTest.php index 83eadc0..970a48d 100644 --- a/tests/Unit/Danfse/DanfseGeneratorTest.php +++ b/tests/Unit/Danfse/DanfseGeneratorTest.php @@ -87,6 +87,41 @@ public function testSubstitutedFiscalStateRendersExplicitWatermark(): void self::assertStringContainsString('class="fiscal-state-watermark">SUBSTITUÍDA', $html); } + public function testLongSyntheticServiceKeepsExplicitEllipsisAndOnePageHtml(): void + { + $longDescription = str_repeat('DESCRICAO SINTETICA DE SERVICO E ETAPAS. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longDescription . '', + $this->xml, + ); + self::assertIsString($xml); + + $generator = new DanfseGenerator(); + $html = $generator->generateHtml($xml); + self::assertStringContainsString('DESCRICAO SINTETICA DE SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringStartsWith('%PDF-', $generator->generateFromXml($xml)); + } + + public function testLongSyntheticComplementaryInformationPreservesTaxDisclosure(): void + { + $longNotes = str_repeat('OBSERVACAO SINTETICA DO SERVICO. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longNotes . '', + $this->xml, + ); + self::assertIsString($xml); + + $html = (new DanfseGenerator())->generateHtml($xml); + self::assertStringContainsString('OBSERVACAO SINTETICA DO SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringContainsString('Federais: 4.50%', $html); + } + public function testGenerateHtmlContainsHomologacaoWatermark(): void { $xml = str_replace('1', '2', $this->xml); From d17e6b54c2bc6e69f9ae0366ee5aa64b6a3f04ae Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:48:05 +0000 Subject: [PATCH 03/14] fix(danfse): integrate NT007 retention labels and authorized country display (#116) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 20 +++++++++++++++++--- src/Danfse/HTMLTemplate.php | 2 +- src/Dto/DpsData.php | 5 ++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index e93ecf3..903d99d 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -219,7 +219,7 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr 'municipio_incidencia' => $this->locationWithUfAndCountry( $this->val($infNfse, 'xLocIncid'), $this->val($infNfse, 'cLocIncid'), - '', + $this->val($tribMun, 'cPaisResult'), ), 'regime_especial' => RegimeEspecialTributacao::labelFor($regimeEspecial), 'tipo_imunidade' => $this->immunityLabel($tipoImunidade), @@ -249,6 +249,8 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr ), 'irrf' => $this->currencyOrDash($this->val($tribFed, 'vRetIRRF')), 'cp' => $this->currencyOrDash($this->val($tribFed, 'vRetCP')), + 'contribuicoes_sociais' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), + // Alias de compatibilidade para consumidores de buildData(). 'csll' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), 'pis' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vPis')), 'cofins' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vCofins')), @@ -457,8 +459,11 @@ private function address(array $endereco): string } /** - * Show the municipality, UF and an optional country only when present in - * the authorized XML. The UF is resolved from the official IBGE code. + * Display ISO country BR for a recognized Brazilian IBGE municipality. + * The NT 008/2026 v1.02 DANFSe table explicitly uses BR as an example; + * this is a presentation inference from the authorized IBGE municipality, + * NOT a cPaisPrestacao XML value (which E0304 forbids for BR). + * For foreign operations, preserve the explicit XML country instead. */ private function locationWithUfAndCountry(string $city, string $ibge, string $country): string { @@ -471,6 +476,11 @@ private function locationWithUfAndCountry(string $city, string $ibge, string $co } } + $country = strtoupper(trim($country)); + if ($country === '' && $uf !== '') { + $country = 'BR'; + } + return ($city !== '' ? $city : '-') . ' / ' . ($uf !== '' ? $uf : '-') . ' / ' . ($country !== '' ? $country : '-'); } @@ -644,6 +654,10 @@ private function pisCofinsRetentionLabel(string $value): string '3' => '3 - PIS/COFINS/CSLL Retidos', '4' => '4 - PIS/COFINS Retidos, CSLL Não Retido', '5' => '5 - PIS Retido, COFINS/CSLL Não Retido', + '6' => '6 - COFINS Retido, PIS/CSLL Não Retido', + '7' => '7 - PIS Não Retido, COFINS/CSLL Retidos', + '8' => '8 - PIS/COFINS Não Retidos, CSLL Retido', + '9' => '9 - COFINS Não Retido, PIS/CSLL Retidos', default => '-', }; } diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index adc5a44..520fb35 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -393,7 +393,7 @@ TRIBUTAÇÃO FEDERAL (EXCETO CBS) IRRF Contribuição Previdenciária - Retida - Contribuições Sociais - Retidas + Contribuições Sociais - Retidas diff --git a/src/Dto/DpsData.php b/src/Dto/DpsData.php index 0835054..842d5a2 100644 --- a/src/Dto/DpsData.php +++ b/src/Dto/DpsData.php @@ -179,7 +179,10 @@ public function __construct( /** Valor do IRRF. */ public string $federalValorIrrf = '', - /** Valor da CSLL retida. */ + /** + * Soma das retenções efetivas de PIS, COFINS e CSLL (NT 007/2026). + * O nome legado acompanha a tag XML vRetCSLL. + */ public string $federalValorCsll = '', /** Valor da contribuição previdenciária retida. */ From 491898176657bd7439d5f2b0570e90dc0de4b63b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:48:48 +0000 Subject: [PATCH 04/14] test(danfse): preserve NT007 retention and location regression cases (#116) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Danfse/DanfseGoldenReferenceTest.php | 37 +++++++++++++++++-- tests/Unit/Danfse/DanfseTemplateTest.php | 21 +++++++++-- tests/Unit/Xml/XmlBuilderTest.php | 17 +++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Danfse/DanfseGoldenReferenceTest.php b/tests/Unit/Danfse/DanfseGoldenReferenceTest.php index 2c485ab..cd10981 100644 --- a/tests/Unit/Danfse/DanfseGoldenReferenceTest.php +++ b/tests/Unit/Danfse/DanfseGoldenReferenceTest.php @@ -38,6 +38,9 @@ public function testNationalPortalStyleUsesCompactGridsAndFooter(): void self::assertStringContainsString('TRIBUTAÇÃO MUNICIPAL (ISSQN)', $html); self::assertStringContainsString('BC ISSQN', $html); self::assertStringContainsString('VALOR LÍQUIDO DA NFS-e', $html); + // A Brazilian municipality authorizes deriving BR for display only; + // cPaisPrestacao remains absent from the issued domestic DPS XML. + self::assertStringContainsString('Niterói / RJ / BR', $html); self::assertStringContainsString('Nº NFS-e / CHAVE NFS-e', $html); } @@ -155,25 +158,53 @@ public function testAuthorizedXmlWithoutIbsCbsStillRendersRequiredV2Section(): v self::assertStringContainsString('DANFSe v2.0', $html); } - public function testFederalRetentionKeepsCsllSeparateFromPisAndCofins(): void + public function testFederalRetentionDisplaysConsolidatedPisCofinsWithoutCsll(): void { $nfse = $this->fixtureData(); $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['tpRetPisCofins'] = '4'; - unset($nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['vRetCSLL']); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['vRetCSLL'] = '1149.75'; $template = new DanfseTemplate(); $data = $template->buildData($nfse); $html = $template->render($nfse, new DanfseConfig()); - self::assertSame('-', $data['tributacao_federal']['csll']); + self::assertSame('R$ 1.149,75', $data['tributacao_federal']['contribuicoes_sociais']); self::assertSame( '4 - PIS/COFINS Retidos, CSLL Não Retido', $data['tributacao_federal']['descricao_retencao'], ); self::assertStringContainsString('Contribuições Sociais - Retidas', $html); + self::assertStringContainsString('R$ 1.149,75', $html); self::assertStringContainsString('Tipo de retenção PIS/COFINS/CSLL', $html); } + public function testNonRetainedSocialContributionsDoNotDisplayAnInventedAmount(): void + { + $nfse = $this->fixtureData(); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['tpRetPisCofins'] = '0'; + unset($nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['vRetCSLL']); + + $data = (new DanfseTemplate())->buildData($nfse); + + self::assertSame('-', $data['tributacao_federal']['contribuicoes_sociais']); + } + + public function testSocialRetentionLabelsSupportAllNt007Codes(): void + { + $nfse = $this->fixtureData(); + $labels = [ + '6' => '6 - COFINS Retido, PIS/CSLL Não Retido', + '7' => '7 - PIS Não Retido, COFINS/CSLL Retidos', + '8' => '8 - PIS/COFINS Não Retidos, CSLL Retido', + '9' => '9 - COFINS Não Retido, PIS/CSLL Retidos', + ]; + + foreach ($labels as $code => $label) { + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['tpRetPisCofins'] = $code; + self::assertSame($label, (new DanfseTemplate())->buildData($nfse)['tributacao_federal']['descricao_retencao']); + } + } + public function testHomologationReferenceCarriesVisibleMarker(): void { $nfse = $this->fixtureData(); diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index a87e807..a21a05e 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -61,7 +61,7 @@ public function testBuildDataMapsKeyFields(): void self::assertSame('Operação Tributável', $data['tributacao_municipal']['tributacao_issqn']); self::assertSame('Retido pelo Tomador', $data['tributacao_municipal']['retencao_issqn']); self::assertSame('Sociedade de Profissionais', $data['tributacao_municipal']['regime_especial']); - self::assertSame('Niterói / RJ / -', $data['tributacao_municipal']['municipio_incidencia']); + self::assertSame('Niterói / RJ / BR', $data['tributacao_municipal']['municipio_incidencia']); // Totais self::assertSame('R$ 1.500,00', $data['totais']['valor_servico']); @@ -89,11 +89,26 @@ public function testOfficialLocationsAndProviderContactUseOnlyDpsData(): void self::assertSame('12345678', $data['emitente']['im']); self::assertSame('(00) 90000-0000', $data['emitente']['telefone']); self::assertSame('fiscal@example.org', $data['emitente']['email']); - self::assertSame('Curitiba / PR / -', $data['servico']['local_prestacao']); - self::assertSame('Curitiba / PR / -', $data['tributacao_municipal']['municipio_incidencia']); + self::assertSame('Curitiba / PR / BR', $data['servico']['local_prestacao']); + self::assertSame('Curitiba / PR / BR', $data['tributacao_municipal']['municipio_incidencia']); self::assertSame('0.0000.00.00', $data['servico']['codigo_nbs']); } + public function testAuthorizedForeignCountryOverridesBrazilianFallback(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['xLocPrestacao'] = ''; + $nfse['infNFSe']['DPS']['infDPS']['serv']['locPrest'] = ['cPaisPrestacao' => 'PT']; + $nfse['infNFSe']['xLocIncid'] = ''; + $nfse['infNFSe']['cLocIncid'] = ''; + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']['cPaisResult'] = 'PT'; + + $data = (new DanfseTemplate())->buildData($nfse); + + self::assertSame('- / - / PT', $data['servico']['local_prestacao']); + self::assertSame('- / - / PT', $data['tributacao_municipal']['municipio_incidencia']); + } + public function testUnknownMunicipalityDoesNotInventAStateOrCountry(): void { $nfse = $this->fixtureNfseData(); diff --git a/tests/Unit/Xml/XmlBuilderTest.php b/tests/Unit/Xml/XmlBuilderTest.php index ae190c5..0b74029 100644 --- a/tests/Unit/Xml/XmlBuilderTest.php +++ b/tests/Unit/Xml/XmlBuilderTest.php @@ -471,6 +471,23 @@ public function testBuildDpsIncludesFederalTaxationBlockWhenConfigured(): void self::assertStringContainsString('5.00', $xml); } + public function testBuildDpsKeepsConsolidatedPisCofinsRetentionsWhenCsllItselfIsNotRetained(): void + { + $xml = $this->builder->buildDps($this->makeDps( + federalPiscofinsSituacaoTributaria: '1', + federalPiscofinsTipoRetencao: '4', + federalPiscofinsBaseCalculo: '31500.00', + federalValorIrrf: '472.50', + federalValorCsll: '1149.75', + )); + + self::assertStringContainsString('4', $xml); + self::assertStringContainsString('1149.75', $xml); + self::assertStringContainsString('472.50', $xml); + self::assertStringNotContainsString('', $xml); + self::assertStringNotContainsString('', $xml); + } + public function testBuildDpsOmitsZeroValuedOptionalFederalRetentions(): void { $xml = $this->builder->buildDps($this->makeDps( From cae3e7d7adda319cb1907c7ad2e9413ef60d4470 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:50:07 +0000 Subject: [PATCH 05/14] fix(danfse): present authorized IBS/CBS amounts and NFS-e totals (#127, #118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 88 +++++++++++++++++----- src/Danfse/HTMLTemplate.php | 73 +++++-------------- tests/Unit/Danfse/DanfseTemplateTest.php | 93 ++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 72 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 903d99d..14105c8 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -106,6 +106,10 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + $ibsTotal = $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'); + $cbsTotal = $this->val($ibsCbsTotais, 'gCBS', 'vCBS'); + $incidenceCode = $this->val($ibsCbs, 'cLocalidadeIncid'); + $dpsDiscounts = $this->node($valores, 'vDescCondIncond'); // The authorized DPS is the primary source for the provider. The // authorized infNFSe/emit enriches omitted legacy address/name data. @@ -260,12 +264,19 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr ], 'totais' => [ - 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), - 'desconto_condicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescCond')), - 'desconto_incondicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescIncond')), - 'issqn_retido' => ($this->val($authorizedValues, 'vISSQN') !== '' && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1') - ? $this->formatter->currency($this->val($authorizedValues, 'vISSQN')) - : '-', + 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), + 'desconto_condicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescCond')), + 'desconto_incondicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescIncond')), + // This field is calculated by SEFIN and exists in the authorized + // NFS-e. Do not recalculate it from partially provided retentions. + 'total_retencoes' => $this->currencyOrDash($this->val($authorizedValues, 'vTotalRet')), + // Historical buildData() keys are retained for consumers and + // golden contracts. The DANFSe itself prints vTotalRet as the + // authoritative total rather than any partial DPS sum. + 'issqn_retido' => ( + $this->val($authorizedValues, 'vISSQN') !== '' + && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1' + ) ? $this->currencyOrDash($this->val($authorizedValues, 'vISSQN')) : '-', 'retencoes_federais' => $this->sumCurrency( $this->val($tribFed, 'vRetIRRF'), $this->val($tribFed, 'vRetCP'), @@ -275,7 +286,7 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), - 'valor_liquido' => $this->formatter->currency($this->val($infNfse, 'valores', 'vLiq')), + 'valor_liquido' => $this->currencyOrDash($this->val($authorizedValues, 'vLiq')), ], 'totais_tributos' => [ @@ -289,25 +300,51 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'CST'), $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'cClassTrib'), ), - 'indicador_operacao' => $this->val($infDps, 'IBSCBS', 'cIndOp') ?: '-', + 'indicador_operacao' => $this->joinedOrDash( + $this->val($infDps, 'IBSCBS', 'cIndOp'), + $incidenceCode, + $this->val($ibsCbs, 'xLocalidadeIncid'), + $this->ufForIbge($incidenceCode), + ), + // Public buildData() key retained for consumers of the data + // model; HTML uses the complete official indicator field. 'localidade_incidencia' => $this->val($ibsCbs, 'xLocalidadeIncid') - ?: $this->val($ibsCbs, 'cLocalidadeIncid') + ?: $incidenceCode ?: '-', - 'exclusoes_reducoes' => $this->sumCurrency( - $this->val($infNfse, 'valores', 'vISSQN'), + // NT008 explicitly displays this sum of XML components. It + // does not authorize fresh IBS/CBS or ISS tax calculation. + 'exclusoes_reducoes' => $ibsCbs === [] ? '-' : $this->sumCurrency( + $this->val($dpsDiscounts, 'vDescIncond'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + $this->val($authorizedValues, 'vISSQN'), $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), 'base_calculo' => $this->currencyOrDash($this->val($ibsCbsValores, 'vBC')), + 'reducao_aliquotas' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pRedAliqUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pRedAliqMun')), + $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pRedAliqCBS')), + ), + 'aliquota_ibs' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + ), 'aliquota_ibs_uf' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), 'aliquota_ibs_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + 'aliquota_efetiva_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pAliqEfetMun')), + 'valor_ibs_municipal' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSMunTot', 'vIBSMun')), + 'aliquota_efetiva_estadual' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pAliqEfetUF')), + 'valor_ibs_estadual' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSUFTot', 'vIBSUF')), 'aliquota_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pCBS')), - 'total_ibs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'vIBSTot')), - 'total_cbs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gCBS', 'vCBS')), - 'total_ibs_cbs' => $ibsCbs === [] ? '-' : $this->sumCurrency( - $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'), - $this->val($ibsCbsTotais, 'gCBS', 'vCBS'), - ), + 'aliquota_efetiva_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pAliqEfetCBS')), + 'total_ibs' => $this->currencyOrDash($ibsTotal), + 'total_cbs' => $this->currencyOrDash($cbsTotal), + // An absent amount is not proof of zero. Two explicit amounts + // (including literal zero) are required for a displayed sum. + 'total_ibs_cbs' => $ibsTotal !== '' && $cbsTotal !== '' + ? $this->sumCurrency($ibsTotal, $cbsTotal) + : '-', 'valor_total_nfse' => $this->currencyOrDash($this->val($ibsCbsTotais, 'vTotNF')), ], @@ -485,6 +522,23 @@ private function locationWithUfAndCountry(string $city, string $ibge, string $co . ($country !== '' ? $country : '-'); } + /** + * Derive the UF only from an explicitly authorized, recognized IBGE code. + */ + private function ufForIbge(string $code): string + { + if (preg_match('/^\\d{7}$/D', $code) !== 1) { + return '-'; + } + + $name = Municipios::lookup($code); + if ($name === $code || !str_contains($name, ' - ')) { + return '-'; + } + + return substr($name, (int) strrpos($name, ' - ') + 3); + } + private function formattedNbs(string $code): string { if (preg_match('/^\\d{9}$/D', $code) !== 1) { diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 520fb35..7e499ce 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -405,68 +405,31 @@ - +
- - - - + + + - - - - + + + + - - - - + + + + - - + + + +
- TRIBUTAÇÃO IBS / CBS - - CST / cClassTrib - - - Indicador de Operação - - - Localidade de Incidência - - TRIBUTAÇÃO IBS / CBSCST / cClassTribIndicador de Operação / Código IBGE / Município Incidência / Sigla UF
- Exclusões e Reduções da Base de Cálculo - - - Base de Cálculo - - - Alíquota IBS UF - - - Alíquota IBS Município - - Exclusões e Reduções da Base de CálculoBase de Cálculo Após Exclusões e ReduçõesRed. Alíquota IBS / Red. Alíquota CBSAlíquota - IBS UF / IBS Mun
- Reduções de Alíquota IBS/CBS - - - - Alíquota CBS - - - Total IBS - - - Total CBS - - Alíq. Efetiva Municipal - IBSValor Apurado Municipal - IBSAlíq. Efetiva Estadual - IBSValor Apurado Estadual - IBS
- Valor Total da NFS-e c/ IBS/CBS - - Valor Total Apurado - IBSAlíquota - CBSAlíquota Efetiva - CBSValor Total Apurado - CBS
@@ -481,9 +444,9 @@ Desconto Condicionado - Retenções Federais + Total das Retenções (ISSQN / Federais) VALOR LÍQUIDO DA NFS-e - ISSQN Retido / Total IBS/CBS / + Total do IBS/CBS VALOR LÍQUIDO DA NFS-e + IBS/CBS diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index a21a05e..60f6a9a 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -229,6 +229,99 @@ public function testAuthorizedApproximateTaxValuesAreCopiedWithoutZeroFillingOrI self::assertSame('R$ 630,00', $sourced['tributacao_municipal']['issqn_apurado']); } + public function testIbsCbsFullGridUsesOnlyAuthorizedAmountsAndRates(): void + { + $nfse = $this->fixtureNfseData(); + $authorized = & $nfse['infNFSe']; + $dps = & $authorized['DPS']['infDPS']; + $dps['valores']['vDescCondIncond'] = ['vDescIncond' => '10.00', 'vDescCond' => '5.00']; + $authorized['valores']['vISSQN'] = '7.00'; + $authorized['valores']['vTotalRet'] = '123.45'; + $authorized['IBSCBS'] = [ + 'cLocalidadeIncid' => '3303302', + 'xLocalidadeIncid' => 'Niterói', + 'valores' => [ + 'vBC' => '1450.00', + 'vCalcReeRepRes' => '20.00', + 'uf' => ['pRedAliqUF' => '0.00', 'pIBSUF' => '0.10', 'pAliqEfetUF' => '0.10'], + 'mun' => ['pRedAliqMun' => '0.00', 'pIBSMun' => '0.05', 'pAliqEfetMun' => '0.05'], + 'fed' => ['pRedAliqCBS' => '0.00', 'pCBS' => '0.90', 'pAliqEfetCBS' => '0.90'], + ], + 'totCIBS' => [ + 'vTotNF' => '1515.75', + 'gIBS' => [ + 'vIBSTot' => '2.25', + 'gIBSMunTot' => ['vIBSMun' => '0.75'], + 'gIBSUFTot' => ['vIBSUF' => '1.50'], + ], + 'gCBS' => ['vCBS' => '13.50'], + ], + ]; + $dps['IBSCBS']['cIndOp'] = '010101'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('010101 / 3303302 / Niterói / RJ', $data['ibs_cbs']['indicador_operacao']); + self::assertSame('R$ 91,75', $data['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('0.00% / 0.00% / 0.00%', $data['ibs_cbs']['reducao_aliquotas']); + self::assertSame('0.10% / 0.05%', $data['ibs_cbs']['aliquota_ibs']); + self::assertSame('0.05%', $data['ibs_cbs']['aliquota_efetiva_municipal']); + self::assertSame('R$ 0,75', $data['ibs_cbs']['valor_ibs_municipal']); + self::assertSame('0.10%', $data['ibs_cbs']['aliquota_efetiva_estadual']); + self::assertSame('R$ 1,50', $data['ibs_cbs']['valor_ibs_estadual']); + self::assertSame('0.90%', $data['ibs_cbs']['aliquota_efetiva_cbs']); + self::assertSame('R$ 15,75', $data['ibs_cbs']['total_ibs_cbs']); + self::assertSame('R$ 123,45', $data['totais']['total_retencoes']); + self::assertSame('R$ 10,00', $data['totais']['desconto_incondicionado']); + self::assertSame('R$ 5,00', $data['totais']['desconto_condicionado']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Valor Apurado Municipal - IBS', $html); + self::assertStringContainsString('Valor Apurado Estadual - IBS', $html); + self::assertStringContainsString('Alíquota Efetiva - CBS', $html); + self::assertStringContainsString('Total das Retenções (ISSQN / Federais)', $html); + } + + public function testAbsentIbsOrCbsAmountIsNotTreatedAsRealZero(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['IBSCBS']['totCIBS']['gIBS']['vIBSTot'] = '0.00'; + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + + $nfse['infNFSe']['IBSCBS']['totCIBS']['gCBS']['vCBS'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + } + + public function testLegacyXmlWithoutIbsCbsDoesNotInventIbsExclusions(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['IBSCBS']); + // The old DPS can still contain PIS/COFINS amounts. Without any + // authorized IBS/CBS block they are not a new IBS base reduction. + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['vPis'] = '12.25'; + + $model = (new DanfseTemplate())->buildData($nfse); + self::assertSame('-', $model['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('-', $model['ibs_cbs']['base_calculo']); + } + + public function testAuthorizedTotalRetentionNeverComesFromPartialDpsSum(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['valores']['trib']['tribFed']['vRetIRRF'] = '99.99'; + + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '78.90'; + self::assertSame('R$ 78,90', $template->buildData($nfse)['totais']['total_retencoes']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); From a8a46074be222ede3099580d48fffb5047794e0a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:51:08 +0000 Subject: [PATCH 06/14] fix(danfse): preserve authorized complementary information and approximate tax values (#129, #118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/danfse-v2-rendering.md | 1 + src/Danfse/DanfseTemplate.php | 98 +++++++++++++++++++++--- src/Danfse/XmlToArray.php | 28 ++++++- tests/Unit/Danfse/DanfseTemplateTest.php | 58 ++++++++++++++ tests/Unit/Danfse/XmlToArrayTest.php | 14 ++++ 5 files changed, 186 insertions(+), 13 deletions(-) diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index 118085e..74675e2 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -25,6 +25,7 @@ The stable contract covered by tests is: - cancelled and substituted documents may show the required 50pt diagonal status watermark when the consuming application supplies an **explicit, externally verified event state** through `DanfseConfig::fiscalState`; the original authorized XML alone does not establish that a subsequent cancel/substitution occurred and is not mutated; - local generation returns a valid single-page A4 portrait PDF artifact; overflowing optional free text uses visible ellipses as permitted by NT 008 (item 2.1), without modifying the XML, the fiscal fields or mandatory tax disclosure. Layouts that still cannot fit fail explicitly instead of returning a nonconforming multi-page PDF; - line breaks in the authorized service description are preserved as escaped HTML line breaks; +- the supplementary text follows NT 008's field order and pipe separators (including substitution, service references, construction, events and municipality information) when they exist in the authorized XML; estimated tax disclosure accepts explicitly supplied monetary **or** percentage values, preserving missing versus numeric zero; - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 14105c8..5e97f41 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -102,7 +102,21 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr $authorizedValues = $this->node($infNfse, 'valores'); $tribMun = $this->node($valores, 'trib', 'tribMun'); $tribFed = $this->node($valores, 'trib', 'tribFed'); - $totTrib = $this->node($valores, 'trib', 'totTrib', 'pTotTrib'); + $totTribGroup = $this->node($valores, 'trib', 'totTrib'); + $approxPercent = $this->node($totTribGroup, 'pTotTrib'); + $approxMoney = $this->node($totTribGroup, 'vTotTrib'); + $approxValues = $approxMoney !== [] + ? [ + $this->currencyOrDash($this->val($approxMoney, 'vTotTribFed')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribEst')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribMun')), + ] + : [ + $this->percentOrDash($this->val($approxPercent, 'pTotTribFed')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribEst')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribMun')), + ]; + $infoComplementares = $this->node($serv, 'infoCompl'); $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); @@ -290,9 +304,9 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr ], 'totais_tributos' => [ - 'federais' => $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - 'estaduais' => $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - 'municipais' => $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + 'federais' => $approxValues[0], + 'estaduais' => $approxValues[1], + 'municipais' => $approxValues[2], ], 'ibs_cbs' => [ @@ -349,10 +363,10 @@ public function buildData(array $nfse, ?int $complementaryTextLimit = null): arr ], 'informacoes_complementares' => $this->complementaryInformation( - $this->val($serv, 'infoCompl', 'xInfComp'), - $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $this->authorizedComplementaryFields($infNfse, $infDps, $serv, $infoComplementares), + $approxValues[0], + $approxValues[1], + $approxValues[2], $complementaryTextLimit, ), ]; @@ -725,6 +739,72 @@ private function joinedOrDash(string ...$values): string return implode(' / ', array_map(static fn (string $value): string => $value !== '' ? $value : '-', $values)); } + /** + * NT008 table 2.4.5 prescribes a stable order and pipe separators. + * The values below exist exclusively in the authorized NFS-e XML. + * + * @param array $infNfse + * @param array $infDps + * @param array $service + * @param array $info + */ + private function authorizedComplementaryFields( + array $infNfse, + array $infDps, + array $service, + array $info, + ): string { + $parts = []; + $fieldSources = [ + ['Inf. Cont.:', $this->val($info, 'xInfComp')], + ['NFS-e Subst.:', $this->val($infDps, 'subst', 'chSubstda')], + ['Doc. Ref.:', $this->val($info, 'docRef')], + ['Cod. Obra:', $this->val($service, 'obra', 'cObra')], + ['Insc. Imob.:', $this->val($service, 'obra', 'inscImobFisc') + ?: $this->val($infDps, 'IBSCBS', 'imovel', 'inscImobFisc')], + ['Cod. Evt.:', $this->val($service, 'atvEvento', 'idAtvEvt')], + ['Doc. Tec.:', $this->val($info, 'idDocTec')], + ['Núm. Ped.:', $this->val($info, 'xPed')], + ['Item Ped.:', $this->listItemNumbers($this->node($info, 'gItemPed'))], + ['Inf. A. T. Mun.:', $this->val($infNfse, 'xOutInf')], + ]; + + foreach ($fieldSources as [$prefix, $content]) { + if ($content !== '') { + $parts[] = $prefix . ' ' . $content; + } + } + + return implode(' | ', $parts); + } + + /** + * Preserve all repeated xItemPed occurrences from XML without dropping + * the first items (up to 99 in the active XSD). + * + * @param array $group + */ + private function listItemNumbers(array $group): string + { + $items = $group['xItemPed'] ?? null; + if (is_scalar($items)) { + return trim((string) $items); + } + + if (!is_array($items)) { + return ''; + } + + $clean = []; + foreach ($items as $item) { + if (is_scalar($item) && trim((string) $item) !== '') { + $clean[] = trim((string) $item); + } + } + + return implode(', ', $clean); + } + private function complementaryInformation( string $existing, string $federal, @@ -743,7 +823,7 @@ private function complementaryInformation( $municipal, ); - return trim($existing) === '' ? $taxes : trim($existing) . "\n" . $taxes; + return $existing === '' ? $taxes : $existing . " | " . $taxes; } private function generateQrCode(string $chaveAcesso, Ambiente $ambiente): string diff --git a/src/Danfse/XmlToArray.php b/src/Danfse/XmlToArray.php index 02036aa..40eebba 100644 --- a/src/Danfse/XmlToArray.php +++ b/src/Danfse/XmlToArray.php @@ -55,14 +55,12 @@ private function nodeToArray(\SimpleXMLElement $node): array|string $nsChildren = $node->children(self::NFSE_NS); if ($nsChildren !== null && $nsChildren->count() > 0) { foreach ($nsChildren as $name => $child) { - $result[$name] = $this->nodeToArray($child); + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } else { // Fallback for XMLs that omit the namespace on children. foreach ($node->children() ?? [] as $name => $child) { - if (!isset($result[$name])) { - $result[$name] = $this->nodeToArray($child); - } + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } @@ -73,4 +71,26 @@ private function nodeToArray(\SimpleXMLElement $node): array|string return $result; } + + /** + * Repeating an XML element is not a replacement: preserve each distinct + * child, but leave single-occurrence fields backward compatible. + * + * @param array $out + * @param array|string $value + */ + private function appendNamedChild(array &$out, string $name, array|string $value): void + { + if (!array_key_exists($name, $out)) { + $out[$name] = $value; + + return; + } + + if (!is_array($out[$name]) || !array_is_list($out[$name])) { + $out[$name] = [$out[$name]]; + } + + $out[$name][] = $value; + } } diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index 60f6a9a..a73dafd 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -322,6 +322,64 @@ public function testAuthorizedTotalRetentionNeverComesFromPartialDpsSum(): void self::assertSame('R$ 78,90', $template->buildData($nfse)['totais']['total_retencoes']); } + public function testComplementaryInformationFollowsOfficialOrderWithoutInventingTaxZeros(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['subst'] = ['chSubstda' => str_repeat('3', 50)]; + $dps['serv']['obra'] = [ + 'cObra' => 'OBRA-FICTICIA-001', + 'inscImobFisc' => 'IMOB-TESTE-0002', + ]; + $dps['serv']['atvEvento'] = ['idAtvEvt' => 'EVENTO-TESTE-123']; + $dps['serv']['infoCompl'] = [ + 'xInfComp' => 'Observacao sintetica', + 'docRef' => 'DOC-REF-123', + 'idDocTec' => 'DOC-TEC-456', + 'xPed' => 'PED-456', + 'gItemPed' => ['xItemPed' => ['01', '02']], + ]; + $nfse['infNFSe']['xOutInf'] = 'Mensagem municipal sintetica'; + $dps['valores']['trib']['totTrib'] = [ + 'vTotTrib' => [ + 'vTotTribFed' => '0.00', + 'vTotTribEst' => '15.50', + 'vTotTribMun' => '3.25', + ], + ]; + + $data = (new DanfseTemplate())->buildData($nfse); + $actual = $data['informacoes_complementares']; + foreach (['Inf. Cont.:', 'NFS-e Subst.:', 'Doc. Ref.:', 'Cod. Obra:', + 'Insc. Imob.:', 'Cod. Evt.:', 'Doc. Tec.:', 'Núm. Ped.:', + 'Item Ped.:', 'Inf. A. T. Mun.:', 'Totais aproximados dos Tributos'] as $part) { + self::assertStringContainsString($part, $actual); + } + self::assertSame('R$ 0,00', $data['totais_tributos']['federais']); + self::assertSame('R$ 15,50', $data['totais_tributos']['estaduais']); + self::assertSame('R$ 3,25', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Item Ped.: 01, 02', $actual); + self::assertStringContainsString('Federais: R$ 0,00; Estaduais: R$ 15,50; Municipais: R$ 3,25', $actual); + self::assertLessThan(strpos($actual, 'Doc. Ref.:'), strpos($actual, 'NFS-e Subst.:')); + self::assertLessThan(strpos($actual, 'Totais aproximados'), strpos($actual, 'Inf. A. T. Mun.:')); + } + + public function testAllAbsentApproximateTaxesStayAbsentAndRealZeroIsDistinct(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']); + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('-', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: -;', $data['informacoes_complementares']); + + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']['pTotTrib']['pTotTribMun'] = '0.00'; + $data = $template->buildData($nfse); + self::assertSame('0.00%', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: 0.00%', $data['informacoes_complementares']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); diff --git a/tests/Unit/Danfse/XmlToArrayTest.php b/tests/Unit/Danfse/XmlToArrayTest.php index a187b9f..d72bd07 100644 --- a/tests/Unit/Danfse/XmlToArrayTest.php +++ b/tests/Unit/Danfse/XmlToArrayTest.php @@ -57,6 +57,20 @@ public function testRejectsDoctypeBeforeParsingExternalXml(): void (new XmlToArray())->convert($xml); } + public function testPreservesRepeatedOrderItems(): void + { + $withItems = str_replace( + '', + '010203', + $this->fixture(), + ); + $value = (new XmlToArray())->convert($withItems); + self::assertSame( + ['01', '02', '03'], + $value['infNFSe']['DPS']['infDPS']['serv']['infoCompl']['gItemPed']['xItemPed'], + ); + } + public function testIgnoresDigitalSignature(): void { $signed = str_replace( From 183d76d68aaf02508f1fba627ad3c78480a4ece1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:52:02 +0000 Subject: [PATCH 07/14] fix(danfse): restore NT008 A4 border, margins and minimum text hierarchy (#130, #118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/HTMLTemplate.php | 62 +++++++++++-------- .../Unit/Danfse/DanfseGoldenReferenceTest.php | 7 ++- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 7e499ce..84c2fd8 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -15,35 +15,46 @@ DANFSe - <?= $data['numero_nfse'] ?>