From e02711267b5264faccd7d231bfdf9694b4f510ed Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:17:32 +0000 Subject: [PATCH 1/3] fix(danfse): align IBS CBS and NFS-e totals with authorized XML (#118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 88 +++++++++++++++++------- src/Danfse/HTMLTemplate.php | 73 +++++--------------- tests/Unit/Danfse/DanfseTemplateTest.php | 80 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 80 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index d3f49bd..1e52133 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -92,6 +92,10 @@ public function buildData(array $nfse): array $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + $ibsTotal = $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'); + $cbsTotal = $this->val($ibsCbsTotais, 'gCBS', 'vCBS'); + $incidenceCode = $this->val($ibsCbs, 'cLocalidadeIncid'); + $dpsDiscounts = $this->node($valores, 'vDescCondIncond'); $id = $this->val($infNfse, 'Id'); $chaveAcesso = str_starts_with($id, 'NFS') ? substr($id, 3) : $id; @@ -190,22 +194,13 @@ public function buildData(array $nfse): array ], 'totais' => [ - 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), - 'desconto_condicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescCond')), - 'desconto_incondicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescIncond')), - 'issqn_retido' => ($this->val($authorizedValues, 'vISSQN') !== '' && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1') - ? $this->formatter->currency($this->val($authorizedValues, 'vISSQN')) - : '-', - 'retencoes_federais' => $this->sumCurrency( - $this->val($tribFed, 'vRetIRRF'), - $this->val($tribFed, 'vRetCP'), - $this->val($tribFed, 'vRetCSLL'), - ), - 'pis_cofins' => $this->sumCurrency( - $this->val($tribFed, 'piscofins', 'vPis'), - $this->val($tribFed, 'piscofins', 'vCofins'), - ), - 'valor_liquido' => $this->formatter->currency($this->val($infNfse, 'valores', 'vLiq')), + 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), + 'desconto_condicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescCond')), + 'desconto_incondicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescIncond')), + // This field is calculated by SEFIN and exists in the authorized + // NFS-e. Do not recalculate it from partially provided retentions. + 'total_retencoes' => $this->currencyOrDash($this->val($authorizedValues, 'vTotalRet')), + 'valor_liquido' => $this->currencyOrDash($this->val($authorizedValues, 'vLiq')), ], 'totais_tributos' => [ @@ -219,25 +214,51 @@ public function buildData(array $nfse): array $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'CST'), $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'cClassTrib'), ), - 'indicador_operacao' => $this->val($infDps, 'IBSCBS', 'cIndOp') ?: '-', + 'indicador_operacao' => $this->joinedOrDash( + $this->val($infDps, 'IBSCBS', 'cIndOp'), + $incidenceCode, + $this->val($ibsCbs, 'xLocalidadeIncid'), + $this->ufForIbge($incidenceCode), + ), + // Public buildData() key retained for consumers of the data + // model; HTML uses the complete official indicator field. 'localidade_incidencia' => $this->val($ibsCbs, 'xLocalidadeIncid') - ?: $this->val($ibsCbs, 'cLocalidadeIncid') + ?: $incidenceCode ?: '-', + // NT008 explicitly displays this sum of XML components. It + // does not authorize fresh IBS/CBS or ISS tax calculation. 'exclusoes_reducoes' => $this->sumCurrency( - $this->val($infNfse, 'valores', 'vISSQN'), + $this->val($dpsDiscounts, 'vDescIncond'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + $this->val($authorizedValues, 'vISSQN'), $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), 'base_calculo' => $this->currencyOrDash($this->val($ibsCbsValores, 'vBC')), + 'reducao_aliquotas' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pRedAliqUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pRedAliqMun')), + $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pRedAliqCBS')), + ), + 'aliquota_ibs' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + ), 'aliquota_ibs_uf' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), 'aliquota_ibs_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + 'aliquota_efetiva_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pAliqEfetMun')), + 'valor_ibs_municipal' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSMunTot', 'vIBSMun')), + 'aliquota_efetiva_estadual' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pAliqEfetUF')), + 'valor_ibs_estadual' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSUFTot', 'vIBSUF')), 'aliquota_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pCBS')), - 'total_ibs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'vIBSTot')), - 'total_cbs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gCBS', 'vCBS')), - 'total_ibs_cbs' => $ibsCbs === [] ? '-' : $this->sumCurrency( - $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'), - $this->val($ibsCbsTotais, 'gCBS', 'vCBS'), - ), + 'aliquota_efetiva_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pAliqEfetCBS')), + 'total_ibs' => $this->currencyOrDash($ibsTotal), + 'total_cbs' => $this->currencyOrDash($cbsTotal), + // An absent amount is not proof of zero. Two explicit amounts + // (including literal zero) are required for a displayed sum. + 'total_ibs_cbs' => $ibsTotal !== '' && $cbsTotal !== '' + ? $this->sumCurrency($ibsTotal, $cbsTotal) + : '-', 'valor_total_nfse' => $this->currencyOrDash($this->val($ibsCbsTotais, 'vTotNF')), ], @@ -343,6 +364,23 @@ private function locationWithUfAndCountry(string $city, string $ibge, string $co . ($country !== '' ? $country : '-'); } + /** + * Derive the UF only from an explicitly authorized, recognized IBGE code. + */ + private function ufForIbge(string $code): string + { + if (preg_match('/^\\d{7}$/D', $code) !== 1) { + return '-'; + } + + $name = Municipios::lookup($code); + if ($name === $code || !str_contains($name, ' - ')) { + return '-'; + } + + return substr($name, (int) strrpos($name, ' - ') + 3); + } + private function formattedNbs(string $code): string { if (preg_match('/^\\d{9}$/D', $code) !== 1) { diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 266ef91..7b57847 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -374,68 +374,31 @@ - +
- - - - + + + - - - - + + + + - - - - + + + + - - + + + +
- TRIBUTAÇÃO IBS / CBS - - CST / cClassTrib - - - Indicador de Operação - - - Localidade de Incidência - - TRIBUTAÇÃO IBS / CBSCST / cClassTribIndicador de Operação / Código IBGE / Município Incidência / Sigla UF
- Exclusões e Reduções da Base de Cálculo - - - Base de Cálculo - - - Alíquota IBS UF - - - Alíquota IBS Município - - Exclusões e Reduções da Base de CálculoBase de Cálculo Após Exclusões e ReduçõesRed. Alíquota IBS / Red. Alíquota CBSAlíquota - IBS UF / IBS Mun
- Reduções de Alíquota IBS/CBS - - - - Alíquota CBS - - - Total IBS - - - Total CBS - - Alíq. Efetiva Municipal - IBSValor Apurado Municipal - IBSAlíq. Efetiva Estadual - IBSValor Apurado Estadual - IBS
- Valor Total da NFS-e c/ IBS/CBS - - Valor Total Apurado - IBSAlíquota - CBSAlíquota Efetiva - CBSValor Total Apurado - CBS
@@ -450,9 +413,9 @@ Desconto Condicionado - Retenções Federais + Total das Retenções (ISSQN / Federais) VALOR LÍQUIDO DA NFS-e - ISSQN Retido / Total IBS/CBS / + Total do IBS/CBS VALOR LÍQUIDO DA NFS-e + IBS/CBS diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index f0dfabe..0476f45 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -135,6 +135,86 @@ public function testAuthorizedMunicipalTotalsUseNfseValuesNotDpsTaxFlags(): void self::assertSame('R$ 630,00', $data['tributacao_municipal']['issqn_apurado']); } + public function testIbsCbsFullGridUsesOnlyAuthorizedAmountsAndRates(): void + { + $nfse = $this->fixtureNfseData(); + $authorized = & $nfse['infNFSe']; + $dps = & $authorized['DPS']['infDPS']; + $dps['valores']['vDescCondIncond'] = ['vDescIncond' => '10.00', 'vDescCond' => '5.00']; + $authorized['valores']['vISSQN'] = '7.00'; + $authorized['valores']['vTotalRet'] = '123.45'; + $authorized['IBSCBS'] = [ + 'cLocalidadeIncid' => '3303302', + 'xLocalidadeIncid' => 'Niterói', + 'valores' => [ + 'vBC' => '1450.00', + 'vCalcReeRepRes' => '20.00', + 'uf' => ['pRedAliqUF' => '0.00', 'pIBSUF' => '0.10', 'pAliqEfetUF' => '0.10'], + 'mun' => ['pRedAliqMun' => '0.00', 'pIBSMun' => '0.05', 'pAliqEfetMun' => '0.05'], + 'fed' => ['pRedAliqCBS' => '0.00', 'pCBS' => '0.90', 'pAliqEfetCBS' => '0.90'], + ], + 'totCIBS' => [ + 'vTotNF' => '1515.75', + 'gIBS' => [ + 'vIBSTot' => '2.25', + 'gIBSMunTot' => ['vIBSMun' => '0.75'], + 'gIBSUFTot' => ['vIBSUF' => '1.50'], + ], + 'gCBS' => ['vCBS' => '13.50'], + ], + ]; + $dps['IBSCBS']['cIndOp'] = '010101'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('010101 / 3303302 / Niterói / RJ', $data['ibs_cbs']['indicador_operacao']); + self::assertSame('R$ 91,75', $data['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('0.00% / 0.00% / 0.00%', $data['ibs_cbs']['reducao_aliquotas']); + self::assertSame('0.10% / 0.05%', $data['ibs_cbs']['aliquota_ibs']); + self::assertSame('0.05%', $data['ibs_cbs']['aliquota_efetiva_municipal']); + self::assertSame('R$ 0,75', $data['ibs_cbs']['valor_ibs_municipal']); + self::assertSame('0.10%', $data['ibs_cbs']['aliquota_efetiva_estadual']); + self::assertSame('R$ 1,50', $data['ibs_cbs']['valor_ibs_estadual']); + self::assertSame('0.90%', $data['ibs_cbs']['aliquota_efetiva_cbs']); + self::assertSame('R$ 15,75', $data['ibs_cbs']['total_ibs_cbs']); + self::assertSame('R$ 123,45', $data['totais']['total_retencoes']); + self::assertSame('R$ 10,00', $data['totais']['desconto_incondicionado']); + self::assertSame('R$ 5,00', $data['totais']['desconto_condicionado']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Valor Apurado Municipal - IBS', $html); + self::assertStringContainsString('Valor Apurado Estadual - IBS', $html); + self::assertStringContainsString('Alíquota Efetiva - CBS', $html); + self::assertStringContainsString('Total das Retenções (ISSQN / Federais)', $html); + } + + public function testAbsentIbsOrCbsAmountIsNotTreatedAsRealZero(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['IBSCBS']['totCIBS']['gIBS']['vIBSTot'] = '0.00'; + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + + $nfse['infNFSe']['IBSCBS']['totCIBS']['gCBS']['vCBS'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + } + + public function testAuthorizedTotalRetentionNeverComesFromPartialDpsSum(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['valores']['trib']['tribFed']['vRetIRRF'] = '99.99'; + + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '78.90'; + self::assertSame('R$ 78,90', $template->buildData($nfse)['totais']['total_retencoes']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); From d41ee5d726fb771dd93d249d2e14e320a49fa8de Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:22:55 +0000 Subject: [PATCH 2/3] fix(danfse): preserve public totals model keys while rendering authorized totals (#118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 1e52133..b3c22ce 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -200,6 +200,22 @@ public function buildData(array $nfse): array // This field is calculated by SEFIN and exists in the authorized // NFS-e. Do not recalculate it from partially provided retentions. 'total_retencoes' => $this->currencyOrDash($this->val($authorizedValues, 'vTotalRet')), + // Historical buildData() keys are retained for consumers and + // golden contracts. The DANFSe itself prints vTotalRet as the + // authoritative total rather than any partial DPS sum. + 'issqn_retido' => ( + $this->val($authorizedValues, 'vISSQN') !== '' + && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1' + ) ? $this->currencyOrDash($this->val($authorizedValues, 'vISSQN')) : '-', + 'retencoes_federais' => $this->sumCurrency( + $this->val($tribFed, 'vRetIRRF'), + $this->val($tribFed, 'vRetCP'), + $this->val($tribFed, 'vRetCSLL'), + ), + 'pis_cofins' => $this->sumCurrency( + $this->val($tribFed, 'piscofins', 'vPis'), + $this->val($tribFed, 'piscofins', 'vCofins'), + ), 'valor_liquido' => $this->currencyOrDash($this->val($authorizedValues, 'vLiq')), ], From 8f83532f7de20a1af937c02873014f0be250e2c0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:34:58 +0000 Subject: [PATCH 3/3] fix(danfse): omit IBS base reductions when authorized group is absent (#118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 2 +- tests/Unit/Danfse/DanfseTemplateTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index b3c22ce..82ade14 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -243,7 +243,7 @@ public function buildData(array $nfse): array ?: '-', // NT008 explicitly displays this sum of XML components. It // does not authorize fresh IBS/CBS or ISS tax calculation. - 'exclusoes_reducoes' => $this->sumCurrency( + 'exclusoes_reducoes' => $ibsCbs === [] ? '-' : $this->sumCurrency( $this->val($dpsDiscounts, 'vDescIncond'), $this->val($ibsCbsValores, 'vCalcReeRepRes'), $this->val($authorizedValues, 'vISSQN'), diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index 0476f45..123610b 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -199,6 +199,19 @@ public function testAbsentIbsOrCbsAmountIsNotTreatedAsRealZero(): void self::assertSame('R$ 0,00', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); } + public function testLegacyXmlWithoutIbsCbsDoesNotInventIbsExclusions(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['IBSCBS']); + // The old DPS can still contain PIS/COFINS amounts. Without any + // authorized IBS/CBS block they are not a new IBS base reduction. + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['vPis'] = '12.25'; + + $model = (new DanfseTemplate())->buildData($nfse); + self::assertSame('-', $model['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('-', $model['ibs_cbs']['base_calculo']); + } + public function testAuthorizedTotalRetentionNeverComesFromPartialDpsSum(): void { $nfse = $this->fixtureNfseData();