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
+
| Tipo de Tributação do ISSQN= $data['tributacao_municipal']['tributacao_issqn'] ?> |
- Município / Sigla UF / País de Incidência do ISSQN= $data['tributacao_municipal']['municipio_incidencia'] ?> |
- Regime Especial de Tributação= $data['tributacao_municipal']['regime_especial'] ?> |
+ Município / Sigla UF / País de Incidência do ISSQN= $data['tributacao_municipal']['municipio_incidencia'] ?> |
+
+
+ | Regime Especial de Tributação do ISSQN= $data['tributacao_municipal']['regime_especial'] ?> |
+ Tipo de Imunidade do ISSQN= $data['tributacao_municipal']['tipo_imunidade'] ?> |
+ Suspensão da Exigibilidade do ISSQN= $data['tributacao_municipal']['suspensao_issqn'] ?> |
+ Número Processo Suspensão= $data['tributacao_municipal']['numero_processo'] ?> |
+
+
+
+
+ | Benefício Municipal= $data['tributacao_municipal']['beneficio_municipal'] ?> |
+ Cálculo do BM= $data['tributacao_municipal']['calculo_bm'] ?> |
+ Total Deduções/Reduções= $data['tributacao_municipal']['total_deducoes'] ?> |
+ Desconto Incondicionado= $data['tributacao_municipal']['desconto_incondicionado'] ?> |
+
+
| BC ISSQN= $data['tributacao_municipal']['bc_issqn'] ?> |
Alíquota Aplicada= $data['tributacao_municipal']['aliquota'] ?> |
@@ -355,6 +373,7 @@
ISSQN Apurado= $data['tributacao_municipal']['issqn_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 @@
+
+ = htmlspecialchars($fiscalState->value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
+
HOMOLOGAÇÃO
@@ -97,9 +115,11 @@
logoDataUri): ?>
 ?>)
- Município: = $data['municipio_emissao'] ?>
- Ambiente Gerador: = $data['ambiente_gerador'] ?>
- Tipo de Ambiente: = $data['ambiente'] ?>
+
+ Município: = $data['municipio_emissao'] ?>
+
+ Ambiente Gerador: = $data['ambiente_gerador'] ?>
+ Tipo de Ambiente: = $data['ambiente'] ?>
@@ -167,7 +187,7 @@
-
+
- |
- Nome / Nome Empresarial
- = $data['emitente']['nome'] ?>
- |
-
- E-mail
- = $data['emitente']['email'] ?>
- |
+ Nome / Nome Empresarial= $data['emitente']['nome'] ?> |
+ Município / Sigla UF= $data['emitente']['municipio'] ?> |
+ Código IBGE / CEP= $data['emitente']['ibge_cep'] ?> |
- |
- Endereço
- = $data['emitente']['endereco'] ?>
- |
-
- Município
- = $data['emitente']['municipio'] ?>
- |
-
- Código IBGE / CEP
- = $data['emitente']['ibge_cep'] ?>
- |
+ Endereço= $data['emitente']['endereco'] ?> |
+ E-mail= $data['emitente']['email'] ?> |
@@ -224,7 +229,10 @@
-
+
+ TOMADOR/ADQUIRENTE DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
+
+
- |
- E-mail
- = $data['tomador']['email'] ?>
- |
+ Município / Sigla UF= $data['tomador']['municipio'] ?> |
+ Código IBGE / CEP= $data['tomador']['ibge_cep'] ?> |
- |
- Endereço
- = $data['tomador']['endereco'] ?>
- |
-
- Município
- = $data['tomador']['municipio'] ?>
- |
-
- Código IBGE / CEP
- = $data['tomador']['ibge_cep'] ?>
- |
+ Endereço= $data['tomador']['endereco'] ?> |
+ E-mail= $data['tomador']['email'] ?> |
+
+
+
+
+
+
+
+ O DESTINATÁRIO É O PRÓPRIO TOMADOR/ADQUIRENTE DA OPERAÇÃO
+
+ DESTINATÁRIO DA OPERAÇÃO NÃO IDENTIFICADO NA NFS-e
+
+
+
+
+
+ | CNPJ / CPF / NIF= $data['destinatario']['cnpj_cpf'] ?> |
+ |
+ Telefone= $data['destinatario']['telefone'] ?> |
+
+
+ | Nome / Nome Empresarial= $data['destinatario']['nome'] ?> |
+ Município / Sigla UF= $data['destinatario']['municipio'] ?> |
+ Código IBGE / CEP= $data['destinatario']['ibge_cep'] ?> |
+
+
+ | Endereço= $data['destinatario']['endereco'] ?> |
+ E-mail= $data['destinatario']['email'] ?> |
+
-
+
- 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= $data['tributacao_federal']['cp'] ?> |
Contribuições Sociais - Retidas= $data['tributacao_federal']['csll'] ?> |
|
-
+
+
| PIS - Débito Apuração Própria= $data['tributacao_federal']['pis'] ?> |
COFINS - Débito Apuração Própria= $data['tributacao_federal']['cofins'] ?> |
Tipo de retenção PIS/COFINS/CSLL= $data['tributacao_federal']['descricao_retencao'] ?> |
+