From fcb49764cb52b003697ab17ae14afc950d251016 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:37:43 +0000 Subject: [PATCH 1/6] fix(danfse): render conditional municipal rows and regime 9 (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/danfse-v2-rendering.md | 1 + src/Danfse/DanfseTemplate.php | 91 ++++++++++++++++++- src/Danfse/Enum/RegimeEspecialTributacao.php | 2 + src/Danfse/HTMLTemplate.php | 25 ++++- tests/Unit/Danfse/DanfseTemplateTest.php | 77 ++++++++++++++++ .../Danfse/RegimeEspecialTributacaoTest.php | 1 + 6 files changed, 193 insertions(+), 4 deletions(-) diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index bc2fbd1..f906b55 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -14,6 +14,7 @@ The stable contract covered by tests is: - authorized NFS-e identity, access key and QR-code presence; - 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 d3f49bd..2c6b1af 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -93,6 +93,25 @@ public function buildData(array $nfse): array $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + // 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', 'vRedBCBM'), + ); + $totalDeducoes = $this->firstPresent( + $this->val($valores, 'vDedRed', 'vDR'), + $this->val($authorizedValues, 'vCalcDR'), + ); + $descontoIncondicionado = $this->val($valores, 'vDescCondIncond', 'vDescIncond'); + $id = $this->val($infNfse, 'Id'); $chaveAcesso = str_starts_with($id, 'NFS') ? substr($id, 3) : $id; @@ -170,7 +189,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' => $this->currencyOrDash($totalDeducoes), + 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), + 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $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')), @@ -368,6 +399,64 @@ 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) { + '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 266ef91..8317313 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -339,15 +339,33 @@ - +
+ +
TRIBUTAÇÃO MUNICIPAL (ISSQN) - OPERAÇÃO NÃO SUJEITA AO ISSQN
+ - - + + + + + + + + + + + + + + + + + @@ -355,6 +373,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 f0dfabe..c7fe13d 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -162,6 +162,83 @@ public function testTomadorAddressKeepsComplementAndOfficialMunicipalityCode(): self::assertSame('3550308 / 04578-000', $data['tomador']['ibge_cep']); } + + 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 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'], ]; } From 4bd67ff0f970bf60c9fefa7999c3bea7a5dea55b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:02 +0000 Subject: [PATCH 2/6] fix(danfse): preserve immunity zero and authorized deductions (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 22 +++++++++---- tests/Unit/Danfse/DanfseTemplateTest.php | 39 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 2c6b1af..dd8be94 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -106,10 +106,16 @@ public function buildData(array $nfse): array $this->val($authorizedValues, 'vCalcBM'), $this->val($tribMun, 'BM', 'vRedBCBM'), ); - $totalDeducoes = $this->firstPresent( - $this->val($valores, 'vDedRed', 'vDR'), - $this->val($authorizedValues, 'vCalcDR'), - ); + // 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'); @@ -196,9 +202,9 @@ public function buildData(array $nfse): array 'mostrar_linha_regime' => $this->hasAnyPresent($regimeEspecial, $tipoImunidade, $tipoSuspensao, $numeroProcesso), 'beneficio_municipal' => $this->municipalBenefitLabel($tipoBeneficio), 'calculo_bm' => $this->currencyOrDash($calculoBeneficio), - 'total_deducoes' => $this->currencyOrDash($totalDeducoes), + 'total_deducoes' => $totalDeducoes, 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), - 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $totalDeducoes, $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', @@ -424,6 +430,10 @@ private function hasAnyPresent(string ...$values): bool 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', diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index c7fe13d..cc7a746 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -211,6 +211,45 @@ public function testMunicipalConditionalRegimeRowRemainsWhenSiblingHasData(): vo 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(); From db86df3a5e60bc2d919e94145480cb1b8c3919bc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:02:18 +0000 Subject: [PATCH 3/6] test(danfse): render synthetic conditional municipal PDF scenarios (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/bin/render-danfse-visual-fixtures.php | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tools/bin/render-danfse-visual-fixtures.php b/tools/bin/render-danfse-visual-fixtures.php index 6911bca..36f4c86 100644 --- a/tools/bin/render-danfse-visual-fixtures.php +++ b/tools/bin/render-danfse-visual-fixtures.php @@ -49,3 +49,60 @@ 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 36f149f0d9eb7b7a7f11856b3fe858f271629a5b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:36:16 +0000 Subject: [PATCH 4/6] fix(danfse): use authorized municipal benefit value when available (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index dd8be94..9f7d1eb 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -104,6 +104,7 @@ public function buildData(array $nfse): array $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 From 757bfe3a9ecfba4555e35f9d22f635a0e126406c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:40:23 +0000 Subject: [PATCH 5/6] fix(danfse): reconcile signed municipal-tax implementation with integrated participant and header changes (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 183 ++++++++++++---- tests/Unit/Danfse/DanfseTemplateTest.php | 261 +++++++++++++++++++++++ 2 files changed, 406 insertions(+), 38 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 9f7d1eb..a891572 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -48,6 +48,9 @@ public function render(array $nfse, DanfseConfig $config): string $data = $this->buildData($nfse); $logo = $config->logoDataUri; $municipality = $config->municipality; + // This is an explicitly supplied, externally verified state; it + // cannot be inferred from an unchanged authorized NFS-e XML. + $fiscalState = $config->fiscalState; $ambiente = Ambiente::fromValue((string) $data['ambiente']); $isHomologacao = $ambiente->isHomologacao(); $qrCode = $this->generateQrCode((string) $data['chave_acesso'], $ambiente); @@ -80,6 +83,7 @@ public function buildData(array $nfse): array $enderEmit = $this->node($emit, 'enderNac'); $toma = $this->node($infDps, 'toma'); $endToma = $this->node($toma, 'end'); + $dest = $this->node($infDps, 'IBSCBS', 'dest'); $interm = $this->node($infDps, 'interm'); $endInterm = $this->node($interm, 'end'); $serv = $this->node($infDps, 'serv'); @@ -93,6 +97,26 @@ public function buildData(array $nfse): array $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + // The authorized DPS is the primary source for the provider. The + // authorized infNFSe/emit enriches omitted legacy address/name data. + $provider = $this->personDetails($prest); + $provider['nome'] = $this->val($prest, 'xNome') ?: $this->val($emit, 'xNome') ?: '-'; + $provider['cnpj_cpf'] = $this->formattedDocument($prest); + $provider['im'] = $this->val($prest, 'IM') ?: $this->val($emit, 'IM') ?: '-'; + $provider['telefone'] = $this->formatter->phone($this->val($prest, 'fone') ?: $this->val($emit, 'fone')); + $provider['email'] = strtolower($this->val($prest, 'email') ?: $this->val($emit, 'email')); + if ($this->node($prest, 'end') === []) { + $provider['endereco'] = $this->address($enderEmit); + $provider['municipio'] = $this->cityWithUf( + $this->val($infNfse, 'xLocEmi'), + $this->val($enderEmit, 'UF'), + ); + $provider['cep'] = $this->formatter->cep($this->val($enderEmit, 'CEP')); + $provider['ibge_cep'] = $this->ibgeCep($this->val($enderEmit, 'cMun'), $this->val($enderEmit, 'CEP')); + } + $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. @@ -132,51 +156,42 @@ public function buildData(array $nfse): array 'emissao_dps' => $this->formatter->dateTime($this->val($infDps, 'dhEmi')), 'ambiente' => Ambiente::fromValue($this->val($infDps, 'tpAmb'))->value, 'ambiente_gerador' => $this->val($infNfse, 'ambGer') ?: '-', - 'municipio_emissao' => $this->val($infNfse, 'xLocEmi') ?: '-', + 'municipio_emissao' => $this->headerMunicipality( + $this->val($infNfse, 'xLocEmi'), + $this->val($enderEmit, 'UF'), + $this->val($cServ, 'cTribNac'), + ), 'emitente_nfse' => $this->emitterLabel($this->val($infDps, 'tpEmit')), 'situacao_nfse' => $this->statusLabel($this->val($infNfse, 'cStat')), 'finalidade_nfse' => $this->purposeLabel($this->val($infDps, 'IBSCBS', 'finNFSe')), - 'emitente' => [ - 'nome' => $this->val($emit, 'xNome') ?: '-', - 'cnpj_cpf' => $this->formattedDocument($emit), - 'im' => $this->val($prest, 'IM') ?: $this->val($emit, 'IM') ?: '-', - 'telefone' => $this->formatter->phone($this->val($prest, 'fone') ?: $this->val($emit, 'fone')), - 'email' => strtolower($this->val($prest, 'email') ?: $this->val($emit, 'email')), - 'endereco' => $this->address($enderEmit), - 'municipio' => $this->cityWithUf($this->val($infNfse, 'xLocEmi'), $this->val($enderEmit, 'UF')), - 'cep' => $this->formatter->cep($this->val($enderEmit, 'CEP')), - 'ibge_cep' => $this->ibgeCep($this->val($enderEmit, 'cMun'), $this->val($enderEmit, 'CEP')), - 'simples_nacional' => OptanteSimplesNacional::labelFor($this->val($regTrib, 'opSimpNac')), - 'regime_sn' => RegimeApuracaoTributariaSN::labelFor($this->val($regTrib, 'regApTribSN')), - ], - - 'tomador' => [ - 'nome' => $this->val($toma, 'xNome') ?: '-', - 'cnpj_cpf' => $this->formattedDocument($toma), - 'im' => $this->val($toma, 'IM') ?: '-', - 'telefone' => $this->formatter->phone($this->val($toma, 'fone')), - 'email' => strtolower($this->val($toma, 'email')), - 'endereco' => $this->address($endToma), - 'municipio' => $this->city($this->val($endToma, 'endNac', 'cMun')), - 'cep' => $this->formatter->cep($this->val($endToma, 'endNac', 'CEP')), - 'ibge_cep' => $this->ibgeCep($this->val($endToma, 'endNac', 'cMun'), $this->val($endToma, 'endNac', 'CEP')), - ], - - 'intermediario' => $interm === [] ? null : [ - 'nome' => $this->val($interm, 'xNome') ?: '-', - 'cnpj_cpf' => $this->formattedDocument($interm), - 'im' => $this->val($interm, 'IMPrestMun') ?: '-', - 'telefone' => $this->formatter->phone($this->val($interm, 'fone')), - 'email' => strtolower($this->val($interm, 'email')), - 'endereco' => $this->address($endInterm), - 'municipio' => $this->city($this->val($endInterm, 'endNac', 'cMun')), - 'cep' => $this->formatter->cep($this->val($endInterm, 'endNac', 'CEP')), - ], + 'emitente' => $provider, + 'tomador' => $toma === [] ? null : $this->personDetails($toma), + 'destinatario' => $dest === [] ? null : array_merge( + $this->personDetails($dest), + ['mesmo_tomador' => $this->samePerson($dest, $toma)], + ), + // The official XSD's indDest=0 is an explicit declaration + // that dest=toma, even if there is no separate dest group. + // When indDest=1, never override that with a name or inferred + // equality. Only legacy XML lacking indDest uses identity + // comparison as a presentation fallback. + 'destinatario_mesmo_tomador' => match ($this->val($infDps, 'IBSCBS', 'indDest')) { + '0' => true, + '1' => false, + default => $dest !== [] && $this->samePerson($dest, $toma), + }, + 'intermediario' => $interm === [] ? null : $this->personDetails($interm), 'servico' => [ 'codigo_trib_nacional' => $this->formatter->codTribNacional($this->val($cServ, 'cTribNac')), - 'desc_trib_nacional' => $this->formatter->limit(trim($this->val($infNfse, 'xTribNac')), 60), + // NT008 2.4.5 prefers an authorized municipal description + // over the national one. The length is suggested, not a new + // tax-code validation or data mutation. + 'desc_trib_nacional' => $this->formatter->limit( + $this->val($infNfse, 'xTribMun') ?: $this->val($infNfse, 'xTribNac'), + 167, + ), 'codigo_trib_municipal' => $this->val($cServ, 'cTribMun') ?: '-', 'codigo_nbs' => $this->formattedNbs($this->val($cServ, 'cNBS')), 'desc_trib_municipal' => $this->formatter->limit(trim($this->val($infNfse, 'xTribMun')), 60), @@ -217,6 +232,11 @@ public function buildData(array $nfse): array ], 'tributacao_federal' => [ + // NT008 note 6: optional PIS/COFINS line is present only + // for actual issue competence through calendar year 2026. + 'mostrar_linha_2026' => $this->showFederalDetailsForCompetence( + $this->val($infDps, 'dCompet'), + ), 'irrf' => $this->currencyOrDash($this->val($tribFed, 'vRetIRRF')), 'cp' => $this->currencyOrDash($this->val($tribFed, 'vRetCP')), 'csll' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), @@ -288,6 +308,69 @@ public function buildData(array $nfse): array ]; } + /** + * Presentation fields for people in the authorized DPS, keeping national + * IBGE/CEP and foreign city/postal codes distinct. Missing values stay + * missing: no municipality, state or identity is guessed. + * + * @param array $person + * @return array + */ + private function personDetails(array $person): array + { + $address = $this->node($person, 'end'); + $national = $this->node($address, 'endNac'); + $foreign = $this->node($address, 'endExt'); + + $municipio = '-'; + $ibgeCep = '-'; + $cep = '-'; + + if ($national !== []) { + $municipio = $this->city($this->val($national, 'cMun')); + $ibgeCep = $this->ibgeCep($this->val($national, 'cMun'), $this->val($national, 'CEP')); + $cep = $this->formatter->cep($this->val($national, 'CEP')); + } elseif ($foreign !== []) { + $city = $this->val($foreign, 'xCidade'); + $region = $this->val($foreign, 'xEstProvReg'); + $postal = $this->val($foreign, 'cEndPost'); + $municipio = $city !== '' ? $city . ($region !== '' ? ' - ' . $region : '') : '-'; + $ibgeCep = '- / ' . ($postal !== '' ? $postal : '-'); + $cep = $postal !== '' ? $postal : '-'; + } + + return [ + 'nome' => $this->val($person, 'xNome') ?: '-', + 'cnpj_cpf' => $this->formattedDocument($person), + 'im' => $this->val($person, 'IM') ?: $this->val($person, 'IMPrestMun') ?: '-', + 'telefone' => $this->formatter->phone($this->val($person, 'fone')), + 'email' => strtolower($this->val($person, 'email')), + 'endereco' => $this->address($address), + 'municipio' => $municipio, + 'ibge_cep' => $ibgeCep, + 'cep' => $cep, + ]; + } + + /** + * Equal verified identifiers permit the NT008 optional same-taker label. + * Names or addresses alone do not establish fiscal identity. + * + * @param array $left + * @param array $right + */ + private function samePerson(array $left, array $right): bool + { + foreach (['CNPJ', 'CPF', 'NIF'] as $key) { + $id = $this->val($left, $key); + if ($id !== '' && $id === $this->val($right, $key)) { + return true; + } + } + + return false; + } + /** * Navigate to a nested sub-array, returning [] when any step is missing. * @@ -391,6 +474,19 @@ private function formattedNbs(string $code): string . substr($code, 5, 2) . '.' . substr($code, 7, 2); } + /** + * The NT 008 municipal heading is not the service location. Its city and UF + * come only from the authorized NFS-e, and item 99 suppresses the line. + */ + private function headerMunicipality(string $city, string $uf, string $nationalTaxCode): string + { + if (str_starts_with($nationalTaxCode, '99') || ($city === '' && $uf === '')) { + return ''; + } + + return ($city !== '' ? $city : '-') . ' / ' . ($uf !== '' ? $uf : '-'); + } + private function cityWithUf(string $city, string $uf): string { return ($city !== '' && $uf !== '') ? $city . ' - ' . $uf : '-'; @@ -517,6 +613,17 @@ private function purposeLabel(string $value): string }; } + private function showFederalDetailsForCompetence(string $competence): bool + { + // Do not use today's date: printing an older authorized invoice + // must reproduce the display rules for its original competence. + if (preg_match('/^(\\d{4})-\\d{2}-\\d{2}$/D', $competence, $matches) !== 1) { + return false; + } + + return (int) $matches[1] <= 2026; + } + private function pisCofinsRetentionLabel(string $value): string { return match ($value) { diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index cc7a746..a87e807 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -135,6 +135,85 @@ public function testAuthorizedMunicipalTotalsUseNfseValuesNotDpsTaxFlags(): void self::assertSame('R$ 630,00', $data['tributacao_municipal']['issqn_apurado']); } + public function testMunicipalTaxDescriptionWinsButNationalIsFallback(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['xTribNac'] = 'Descricao nacional sintetica'; + $nfse['infNFSe']['xTribMun'] = 'Descricao municipal sintetica'; + + $template = new DanfseTemplate(); + self::assertSame('Descricao municipal sintetica', $template->buildData($nfse)['servico']['desc_trib_nacional']); + self::assertStringContainsString('Descricao municipal sintetica', $template->render($nfse, new DanfseConfig())); + self::assertStringNotContainsString('Descricao nacional sintetica', $template->render($nfse, new DanfseConfig())); + + unset($nfse['infNFSe']['xTribMun']); + self::assertSame('Descricao nacional sintetica', $template->buildData($nfse)['servico']['desc_trib_nacional']); + } + + public function testFederalDetailRowFollowsOriginalFiscalCompetence(): void + { + $nfse = $this->fixtureNfseData(); + $template = new DanfseTemplate(); + + $nfse['infNFSe']['DPS']['infDPS']['dCompet'] = '2026-12-31'; + self::assertTrue($template->buildData($nfse)['tributacao_federal']['mostrar_linha_2026']); + self::assertStringContainsString('class="federal-competence-2026"', $template->render($nfse, new DanfseConfig())); + + $nfse['infNFSe']['DPS']['infDPS']['dCompet'] = '2027-01-01'; + self::assertFalse($template->buildData($nfse)['tributacao_federal']['mostrar_linha_2026']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringNotContainsString('class="federal-competence-2026"', $html); + self::assertStringContainsString('Contribuições Sociais - Retidas', $html); + + unset($nfse['infNFSe']['DPS']['infDPS']['dCompet']); + self::assertFalse($template->buildData($nfse)['tributacao_federal']['mostrar_linha_2026']); + } + + public function testAuthorizedApproximateTaxValuesAreCopiedWithoutZeroFillingOrIssFallback(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['valores']['pAliqAplic'] = '2.00'; + $nfse['infNFSe']['valores']['vISSQN'] = '630.00'; + $totals = & $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']; + + $totals = ['indTotTrib' => '0']; + $unknown = (new DanfseTemplate())->buildData($nfse); + + self::assertSame('2.00%', $unknown['tributacao_municipal']['aliquota']); + self::assertSame('R$ 630,00', $unknown['tributacao_municipal']['issqn_apurado']); + self::assertSame( + ['federais' => '-', 'estaduais' => '-', 'municipais' => '-'], + $unknown['totais_tributos'], + ); + self::assertStringContainsString( + 'Federais: -; Estaduais: -; Municipais: -;', + $unknown['informacoes_complementares'], + ); + + // These are explicit source declarations, not the assessed ISS rate. + $totals = [ + 'pTotTrib' => [ + 'pTotTribFed' => '0.00', + 'pTotTribEst' => '0.00', + 'pTotTribMun' => '0.00', + ], + ]; + $documentedZero = (new DanfseTemplate())->buildData($nfse); + self::assertSame('0.00%', $documentedZero['totais_tributos']['municipais']); + self::assertStringContainsString( + 'Federais: 0.00%; Estaduais: 0.00%; Municipais: 0.00%;', + $documentedZero['informacoes_complementares'], + ); + + $totals['pTotTrib']['pTotTribFed'] = '7.45'; + $totals['pTotTrib']['pTotTribMun'] = '4.10'; + $sourced = (new DanfseTemplate())->buildData($nfse); + self::assertSame('7.45%', $sourced['totais_tributos']['federais']); + self::assertSame('4.10%', $sourced['totais_tributos']['municipais']); + self::assertSame('2.00%', $sourced['tributacao_municipal']['aliquota']); + self::assertSame('R$ 630,00', $sourced['tributacao_municipal']['issqn_apurado']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); @@ -162,6 +241,136 @@ public function testTomadorAddressKeepsComplementAndOfficialMunicipalityCode(): self::assertSame('3550308 / 04578-000', $data['tomador']['ibge_cep']); } + public function testRecipientIsIndependentOfIntermediaryAndUsesAuthorizedDps(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + unset($dps['interm']); + $dps['IBSCBS']['dest'] = [ + 'NIF' => 'PT-TEST-998877', + 'xNome' => 'DESTINATARIO SINTETICO', + 'end' => [ + 'endExt' => [ + 'cPais' => 'PT', + 'cEndPost' => '0000-001', + 'xCidade' => 'Cidade Ficticia', + 'xEstProvReg' => 'Distrito Ficticio', + ], + 'xLgr' => 'Rua Exemplo', + 'nro' => '1', + 'xBairro' => 'Bairro Modelo', + ], + 'email' => 'recipient@example.org', + ]; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertNull($data['intermediario']); + self::assertSame('PT-TEST-998877', $data['destinatario']['cnpj_cpf']); + self::assertSame('Cidade Ficticia - Distrito Ficticio', $data['destinatario']['municipio']); + self::assertSame('- / 0000-001', $data['destinatario']['ibge_cep']); + self::assertFalse($data['destinatario']['mesmo_tomador']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('DESTINATÁRIO DA OPERAÇÃO', $html); + self::assertStringContainsString('recipient@example.org', $html); + self::assertStringContainsString('INTERMEDIÁRIO DA OPERAÇÃO NÃO IDENTIFICADO', $html); + self::assertStringNotContainsString('DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO', $html); + } + + public function testSameRecipientAndTakerUseOfficialConciseMessage(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['IBSCBS']['dest'] = [ + 'CNPJ' => $dps['toma']['CNPJ'], + 'xNome' => 'DESTINATARIO SINTETICO', + ]; + + $template = new DanfseTemplate(); + self::assertTrue($template->buildData($nfse)['destinatario']['mesmo_tomador']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('O DESTINATÁRIO É O PRÓPRIO TOMADOR/ADQUIRENTE DA OPERAÇÃO', $html); + self::assertStringNotContainsString('class="bordered-section party-destinatario"', $html); + } + + public function testOfficialIndDestZeroMeansSameTakerEvenWithoutDestGroup(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['IBSCBS']['indDest'] = '0'; + unset($dps['IBSCBS']['dest']); + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertNull($data['destinatario']); + self::assertTrue($data['destinatario_mesmo_tomador']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('O DESTINATÁRIO É O PRÓPRIO TOMADOR/ADQUIRENTE DA OPERAÇÃO', $html); + self::assertStringNotContainsString('DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO', $html); + } + + public function testOfficialIndDestOnePreventsIncorrectSameTakerSuppression(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['IBSCBS']['indDest'] = '1'; + $dps['IBSCBS']['dest'] = [ + 'CNPJ' => $dps['toma']['CNPJ'], + 'xNome' => 'DESTINATARIO AUTORIZADO DISTINTO', + ]; + + $template = new DanfseTemplate(); + self::assertFalse($template->buildData($nfse)['destinatario_mesmo_tomador']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('class="bordered-section party-destinatario"', $html); + self::assertStringNotContainsString('O DESTINATÁRIO É O PRÓPRIO TOMADOR', $html); + } + + public function testMissingTakerAndRecipientHaveSeparateCondensedBlocks(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['toma']); + $template = new DanfseTemplate(); + self::assertNull($template->buildData($nfse)['tomador']); + self::assertNull($template->buildData($nfse)['destinatario']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('TOMADOR/ADQUIRENTE DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e', $html); + self::assertStringContainsString('DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e', $html); + self::assertStringContainsString('class="bordered-section party-intermediario"', $html); + } + + public function testProviderPrefersAuthorizedDpsAddressAndPreservesLeadingZeroCep(): void + { + $nfse = $this->fixtureNfseData(); + $provider = & $nfse['infNFSe']['DPS']['infDPS']['prest']; + $provider['xNome'] = 'FORNECEDOR SINTETICO DA DPS'; + $provider['end'] = [ + 'endNac' => ['cMun' => '3550308', 'CEP' => '01001000'], + 'xLgr' => 'Rua da Amostra', + 'nro' => '10', + 'xBairro' => 'Centro', + ]; + + $data = (new DanfseTemplate())->buildData($nfse); + self::assertSame('FORNECEDOR SINTETICO DA DPS', $data['emitente']['nome']); + self::assertSame('São Paulo - SP', $data['emitente']['municipio']); + self::assertSame('3550308 / 01001-000', $data['emitente']['ibge_cep']); + self::assertStringContainsString('Rua da Amostra', $data['emitente']['endereco']); + } + + public function testIntermediaryKeepsIbgeAndCepInSameCell(): void + { + $nfse = $this->fixtureNfseData(); + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('3518800 / 07095-130', $data['intermediario']['ibge_cep']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('class="bordered-section party-intermediario"', $html); + self::assertStringContainsString('3518800 / 07095-130', $html); + } + public function testMunicipalRegimeZeroIsPresentButMissingCodeIsNot(): void { @@ -299,6 +508,58 @@ public function testTomadorNifIsPreserved(): void self::assertSame('AB-123', $data['tomador']['cnpj_cpf']); } + + public function testHeaderMunicipalityFollowsAuthorizedXmlAndItem99Rule(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['xLocEmi'] = 'Município de Teste'; + $nfse['infNFSe']['emit']['enderNac']['UF'] = 'MG'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('Município de Teste / MG', $data['municipio_emissao']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Município: Município de Teste / MG', $html); + self::assertStringContainsString('.municipality-name { font-size: 8pt;', $html); + self::assertStringContainsString('.municipality-environment { font-size: 6pt;', $html); + + $nfse['infNFSe']['DPS']['infDPS']['serv']['cServ']['cTribNac'] = '990000'; + self::assertSame('', $template->buildData($nfse)['municipio_emissao']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringNotContainsString('Município: Município de Teste', $html); + self::assertStringContainsString('Ambiente Gerador:', $html); + self::assertStringContainsString('Tipo de Ambiente:', $html); + } + + public function testHeaderDoesNotInventAbsentMunicipalityOrUf(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['xLocEmi'], $nfse['infNFSe']['emit']['enderNac']['UF']); + $template = new DanfseTemplate(); + self::assertSame('', $template->buildData($nfse)['municipio_emissao']); + self::assertStringNotContainsString('Município:', $template->render($nfse, new DanfseConfig())); + + $nfse['infNFSe']['xLocEmi'] = 'Cidade Sintética'; + self::assertSame('Cidade Sintética / -', $template->buildData($nfse)['municipio_emissao']); + + unset($nfse['infNFSe']['xLocEmi']); + $nfse['infNFSe']['emit']['enderNac']['UF'] = 'SP'; + self::assertSame('- / SP', $template->buildData($nfse)['municipio_emissao']); + } + + public function testHomologationHeaderRetainsWarningAndEnvironmentWhenMunicipalitySuppressed(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['DPS']['infDPS']['tpAmb'] = '2'; + $nfse['infNFSe']['DPS']['infDPS']['serv']['cServ']['cTribNac'] = '990000'; + + $html = (new DanfseTemplate())->render($nfse, new DanfseConfig()); + self::assertStringContainsString('NFS-e SEM VALIDADE JURÍDICA', $html); + self::assertStringContainsString('Tipo de Ambiente:', $html); + self::assertStringNotContainsString('Município: Niterói / RJ', $html); + } + public function testRenderProducesHtmlWithQrCodeAndAccessKey(): void { $html = (new DanfseTemplate())->render($this->fixtureNfseData(), new DanfseConfig()); From fe0f976151fb048a8483b592b83e2b4ae1743030 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:41:43 +0000 Subject: [PATCH 6/6] fix(danfse): reconcile NT008 municipal presentation with current HTML layout (#120) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/danfse-v2-rendering.md | 2 + src/Danfse/HTMLTemplate.php | 140 +++++++++++++++++++----------------- 2 files changed, 78 insertions(+), 64 deletions(-) diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index f906b55..32f6e06 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -12,6 +12,7 @@ uses synthetic/public-schema data rather than copyrighted screenshots. The stable contract covered by tests is: - authorized NFS-e identity, access key and QR-code presence; +- 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; @@ -21,6 +22,7 @@ The stable contract covered by tests is: - IBS/CBS values displayed from authorized totals without recalculation; - 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; - 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; diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 8317313..adc5a44 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -6,6 +6,7 @@ /** @var ?string $logo */ /** @var string $qrCode */ /** @var bool $isHomologacao */ +/** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState $fiscalState */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\MunicipalityBranding $municipality */ ?> @@ -46,7 +47,9 @@ .header-table td { padding: 2pt 3pt 3pt; vertical-align: middle; } .logo-cell { width: 32%; text-align: left; } .title-cell { width: 42%; text-align: center; } - .municipality-cell { width: 26%; font-size: 5.7pt; text-align: left; } + .municipality-cell { width: 26%; font-family: "Microsoft Sans Serif", Arial, sans-serif; text-align: left; } + .municipality-name { font-size: 8pt; font-weight: normal; } + .municipality-environment { font-size: 6pt; font-weight: normal; } .qr-container { text-align: center; } .qr-notice { font-size: 5.6pt; line-height: 1.15; margin-top: 2pt; text-align: left; } .access-key { white-space: nowrap; font-size: 6.6pt; } @@ -60,6 +63,18 @@ .footer-table td { border: 0.8pt solid #000; padding: 2pt 3pt; } .footer-table .label { font-size: 5.8pt; } .footer-table .value { font-size: 6pt; } + .fiscal-state-watermark { + position: fixed; + top: 46%; + left: 50%; + transform: translate(-50%, -50%) rotate(-45deg); + font-family: Arial, Helvetica, sans-serif; + font-size: 50pt; + font-weight: normal; + color: #a6a6a6; + z-index: -1; + white-space: nowrap; + } .watermark { position: fixed; top: 50%; @@ -74,6 +89,9 @@ + +
value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
+
HOMOLOGAÇÃO
@@ -97,9 +115,11 @@ logoDataUri): ?> Prefeitura
- Município:
- Ambiente Gerador:
- Tipo de Ambiente: + +
Município:
+ +
Ambiente Gerador:
+
Tipo de Ambiente:
@@ -167,7 +187,7 @@ -
+
- - + + + - - - + + - + + +
@@ -187,28 +207,13 @@
- Nome / Nome Empresarial - - - E-mail - - Nome / Nome EmpresarialMunicípio / Sigla UFCódigo IBGE / CEP
- Endereço - - - Município - - - Código IBGE / CEP - - EndereçoE-mail
@@ -224,7 +229,10 @@ -
+ +
TOMADOR/ADQUIRENTE DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
+ +
- + + - - - + + + +
@@ -248,38 +256,54 @@ Nome / Nome Empresarial - E-mail - - Município / Sigla UFCódigo IBGE / CEP
- Endereço - - - Município - - - Código IBGE / CEP - - EndereçoE-mail
+
+ + + + +
O DESTINATÁRIO É O PRÓPRIO TOMADOR/ADQUIRENTE DA OPERAÇÃO
+ +
DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
+ +
+ + + + + + + + + + + + + + +
DESTINATÁRIO DA OPERAÇÃOCNPJ / CPF / NIFTelefone
Nome / Nome EmpresarialMunicípio / Sigla UFCódigo IBGE / CEP
EndereçoE-mail
+ -
+
- + + - - - + +
INTERMEDIÁRIO DO SERVIÇO - CNPJ / CPF + CNPJ / CPF / NIF @@ -296,31 +320,17 @@ Nome / Nome Empresarial - E-mail - - Município / Sigla UFCódigo IBGE / CEP
- Endereço - - - Município - - - CEP - - EndereçoE-mail
-
DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e -
INTERMEDIÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
-
+
INTERMEDIÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
@@ -385,11 +395,13 @@
Contribuição Previdenciária - Retida Contribuições Sociais - Retidas
PIS - Débito Apuração Própria COFINS - Débito Apuração Própria Tipo de retenção PIS/COFINS/CSLL