diff --git a/.github/workflows/danfse-visual.yml b/.github/workflows/danfse-visual.yml index d4e479c..d543789 100644 --- a/.github/workflows/danfse-visual.yml +++ b/.github/workflows/danfse-visual.yml @@ -44,15 +44,22 @@ jobs: - name: Generate synthetic PDFs for visual review run: php tools/bin/render-danfse-visual-fixtures.php "${{ runner.temp }}/danfse" - - name: Verify printable receipt footer and long-description pagination + - name: Verify required fields and one-page A4 layout shell: bash run: | for pdf in "${{ runner.temp }}/danfse/"*.pdf; do pdftotext -layout "$pdf" - | grep -Fq "DATA CIENTIFICAÇÃO" pdftotext -layout "$pdf" - | grep -Fq "IDENTIFICAÇÃO E ASSINATURA" + # Normalize PDF line wraps while still requiring the complete official disclosure. + pdftotext -layout "$pdf" - | tr -s '[:space:]' ' ' | grep -Fq "Totais aproximados dos Tributos" + pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}') + test "$pages" -eq 1 + pdfinfo "$pdf" | grep -Fq 'Page size: 595.28 x 841.89 pts (A4)' done - pages=$(pdfinfo "${{ runner.temp }}/danfse/danfse-long-description.pdf" | awk '/^Pages:/ {print $2}') - test "$pages" -gt 1 + pdftotext -layout "${{ runner.temp }}/danfse/danfse-long-description.pdf" - | grep -Fq '...' + # A locally supplied TTF must be genuinely embedded (not silently + # replaced by Helvetica). The probe font is freely licensed. + pdffonts "${{ runner.temp }}/danfse/danfse-font-probe.pdf" | grep -Fq 'DejaVuSans' - name: Preserve render evidence if: always() diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index 65f2568..16da01b 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -15,6 +15,7 @@ The stable contract covered by tests is: - header municipality and UF only from `infNFSe/xLocEmi` and `emit/enderNac/UF`, suppressed for national taxation item 99; environment identification remains visible; - provider, taker and intermediary identity fields; - retained ISS labels from the DPS and municipal base/rate/assessed tax from the authorized `infNFSe/valores` (not the DPS `tribMun`); +- municipal special regime codes `0` (Nenhum), `9` (Outros) and missing values retain distinct presentation; the two optional municipal rows are omitted only if every field of that row is absent, and a present numeric zero is displayed; - foreign taker NIF without inventing a Brazilian CPF/CNPJ; - 60-character taxation-description content with an ellipsis suffix when truncated; - IBS/CBS block rendered only when the authorized XML contains it; @@ -22,11 +23,33 @@ The stable contract covered by tests is: - legacy authorized XML without IBS/CBS remains renderable; - homologation documents have a visible environment marker; - cancelled and substituted documents may show the required 50pt diagonal status watermark when the consuming application supplies an **explicit, externally verified event state** through `DanfseConfig::fiscalState`; the original authorized XML alone does not establish that a subsequent cancel/substitution occurred and is not mutated; -- local generation returns a valid PDF artifact; +- local generation returns a valid single-page A4 portrait PDF artifact; overflowing optional free text uses visible ellipses as permitted by NT 008 (item 2.1), without modifying the XML, the fiscal fields or mandatory tax disclosure. Layouts that still cannot fit fail explicitly instead of returning a nonconforming multi-page PDF; - line breaks in the authorized service description are preserved as escaped HTML line breaks; +- the supplementary text follows NT 008's field order and pipe separators (including substitution, service references, construction, events and municipality information) when they exist in the authorized XML; estimated tax disclosure accepts explicitly supplied monetary **or** percentage values, preserving missing versus numeric zero; - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. +## Locally licensed typography + +NT 008 requires **Arial** for labels and **Microsoft Sans Serif** for values. +CSS family names alone do not satisfy this: Dompdf otherwise falls back to +Helvetica. The library cannot redistribute those proprietary font binaries. + +A caller holding suitable licenses can provide local TrueType files using +`DanfseConfig(fonts: new DanfseFonts($arialRegular, $arialBold, $microsoftSansSerif, $writableFontCacheDir))`. +Files are validated when the options are built, used only from local paths +within Dompdf's explicitly restricted chroot, and never loaded remotely or +serialized as font bytes in the HTML. Files must permit PDF embedding under +their own license. The last argument must be an existing writable font cache +directory reserved for this use; the renderer isolates its metrics from +Dompdf's bundled fonts to prevent affecting unrelated documents. + +An A4 PDF should be inspected with `pdffonts receipt.pdf`; exact typography +is demonstrated only when the correct typefaces are present and embedded. +The default compatibility path may still fall back to Helvetica and must not +be claimed to prove complete NT 008 font compliance. CI exercises local-font +embedding with Dompdf's freely licensed DejaVuSans **only as a test surrogate**. + The project does not guarantee byte-identical PDF output. PDF metadata, font metrics and QR SVG serialization can change when rendering dependencies are updated. Reviews should therefore treat the named structural reference tests @@ -48,5 +71,4 @@ The image remains directly viewable on disk and is encoded in the temporary HTML All monetary values and document identity continue to come only from authorized NFS-e XML; the layout neither recalculates taxes nor invents absent fiscal data. Homologation warnings remain visible. No receipt date or signature -is invented. The footer reserves bottom page space, including when a lengthy -description flows onto additional pages. +is invented. The footer reserves bottom page space. Long free-text fields are progressively abbreviated with visible ellipses when needed to keep the DANFSe on one page; if mandatory content cannot fit, generation fails rather than producing additional pages. diff --git a/src/Danfse/Config/DanfseConfig.php b/src/Danfse/Config/DanfseConfig.php index ac67a6e..f1aa06e 100644 --- a/src/Danfse/Config/DanfseConfig.php +++ b/src/Danfse/Config/DanfseConfig.php @@ -23,6 +23,7 @@ public function __construct( ?string $logoPath = null, public ?MunicipalityBranding $municipality = null, public ?DanfseFiscalState $fiscalState = null, + public ?DanfseFonts $fonts = null, ) { $this->logoDataUri = ($logoDataUri !== null || $logoPath !== null) ? LogoLoader::resolve($logoDataUri, $logoPath) diff --git a/src/Danfse/Config/DanfseFonts.php b/src/Danfse/Config/DanfseFonts.php new file mode 100644 index 0000000..1ee445b --- /dev/null +++ b/src/Danfse/Config/DanfseFonts.php @@ -0,0 +1,86 @@ +fontCacheDirectory = $cache; + $this->arialRegular = self::existingFont($arialRegular); + $this->arialBold = self::existingFont($arialBold); + $this->microsoftSansSerif = self::existingFont($microsoftSansSerif); + } + + /** + * @return list + */ + public function directories(): array + { + return array_values(array_unique([ + dirname($this->arialRegular), + dirname($this->arialBold), + dirname($this->microsoftSansSerif), + ])); + } + + /** + * Output references files on disk, never inline font bytes or network URIs. + */ + public function fontFaceCss(): string + { + return implode("\n", [ + '@font-face { font-family: "Arial"; font-style: normal; font-weight: 400; src: url("' . + self::fileUri($this->arialRegular) . '") format("truetype"); }', + '@font-face { font-family: "Arial"; font-style: normal; font-weight: 700; src: url("' . + self::fileUri($this->arialBold) . '") format("truetype"); }', + '@font-face { font-family: "Microsoft Sans Serif"; font-style: normal; font-weight: 400; src: url("' . + self::fileUri($this->microsoftSansSerif) . '") format("truetype"); }', + ]); + } + + private static function existingFont(string $path): string + { + $real = realpath($path); + if ($real === false || !is_file($real) || !is_readable($real) + || preg_match('/\.ttf$/iD', $real) !== 1 + ) { + throw new \InvalidArgumentException('An existing, readable local TrueType font file is required.'); + } + + return $real; + } + + private static function fileUri(string $path): string + { + // realpath() above guarantees an absolute local path; encode + // whitespace and quotes so this cannot inject arbitrary CSS. + return 'file://' . str_replace('%2F', '/', rawurlencode($path)); + } +} diff --git a/src/Danfse/DanfseGenerator.php b/src/Danfse/DanfseGenerator.php index 075f790..bd8ba8e 100644 --- a/src/Danfse/DanfseGenerator.php +++ b/src/Danfse/DanfseGenerator.php @@ -28,37 +28,32 @@ public function __construct( } /** - * Render the DANFSe PDF from the NFS-e XML and return its raw bytes. + * Render one A4 portrait page or fail explicitly. NT 008, item 2.2, + * forbids multiple-page DANFSe output. */ public function generateFromXml(string $xml): string { - $html = $this->generateHtml($xml); - - try { - $options = new Options(); - $options->set('isHtml5ParserEnabled', true); - $options->set('isRemoteEnabled', false); - $options->set('defaultFont', 'Helvetica'); - - $dompdf = new Dompdf($options); - $dompdf->loadHtml($html, 'UTF-8'); - $dompdf->setPaper('A4', 'portrait'); - $dompdf->render(); - - return (string) $dompdf->output(); - } catch (\Throwable $e) { - throw new ArtifactException( - 'Failed to render DANFSe PDF: ' . $e->getMessage(), - NfseErrorCode::ArtifactRetrievalFailed, - previous: $e, - ); - } + return $this->renderOnePage($xml)[1]; } /** - * Render the intermediate HTML (useful for inspection and testing). + * Return exactly the intermediate HTML used for the one-page PDF. */ public function generateHtml(string $xml): string + { + return $this->renderOnePage($xml)[0]; + } + + /** + * Progressive, visible ellipsis is allowed by NT 008, item 2.1. Ordinary + * invoices keep their full description. When a layout exceeds one page, + * trim only free-text descriptions, never keys, fields or fiscal totals. + * If even the minimum readable text does not fit, fail rather than + * silently returning a second page or hiding required fiscal fields. + * + * @return array{string, string} HTML and PDF bytes of the same rendering + */ + private function renderOnePage(string $xml): array { try { $data = (new XmlToArray())->convert($xml); @@ -71,8 +66,62 @@ public function generateHtml(string $xml): string } $this->assertAuthorizedNfse($data); + $template = new DanfseTemplate(); + + // [service-description limit, additional free-text limit]. + // These are adaptive budgets, NOT new fiscal-field constraints. + foreach ([ + [null, null], + [1300, 1900], + [850, 1100], + [600, 800], + [400, 500], + [250, 300], + [150, 150], + [60, 60], + ] as [$serviceLimit, $complementaryLimit]) { + $html = $template->render($data, $this->config, $serviceLimit, $complementaryLimit); + + try { + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', false); + $options->set('defaultFont', 'Helvetica'); + if ($this->config->fonts !== null) { + // Permit only the caller-provided, validated font + // directories, not arbitrary filesystem access. + $options->setChroot(array_values(array_unique(array_merge( + $options->getChroot(), + $this->config->fonts->directories(), + )))); + // Isolate dynamic font metrics from Dompdf's bundled + // cache. Without this, later default-font documents can + // silently inherit a different typeface and overflow A4. + $options->setFontDir($this->config->fonts->fontCacheDirectory); + $options->setFontCache($this->config->fonts->fontCacheDirectory); + } + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + } catch (\Throwable $e) { + throw new ArtifactException( + 'Failed to render DANFSe PDF: ' . $e->getMessage(), + NfseErrorCode::ArtifactRetrievalFailed, + previous: $e, + ); + } + + if ($dompdf->getCanvas()->get_page_count() === 1) { + return [$html, (string) $dompdf->output()]; + } + } - return (new DanfseTemplate())->render($data, $this->config); + throw new ArtifactException( + 'DANFSe does not fit on one A4 page without suppressing mandatory information.', + NfseErrorCode::ArtifactRetrievalFailed, + ); } /** diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index c94b1dc..f4df3a7 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -43,14 +43,25 @@ public function __construct() * * @param array $nfse */ - public function render(array $nfse, DanfseConfig $config): string - { - $data = $this->buildData($nfse); + public function render( + array $nfse, + DanfseConfig $config, + ?int $serviceDescriptionLimit = null, + ?int $complementaryTextLimit = null, + ): string { + $data = $this->buildData($nfse, $complementaryTextLimit); + if ($serviceDescriptionLimit !== null) { + $data['servico']['descricao'] = $this->formatter->limit( + (string) $data['servico']['descricao'], + $serviceDescriptionLimit, + ); + } $logo = $config->logoDataUri; $municipality = $config->municipality; // This is an explicitly supplied, externally verified state; it // cannot be inferred from an unchanged authorized NFS-e XML. $fiscalState = $config->fiscalState; + $fontFaces = $config->fonts?->fontFaceCss() ?? ''; $ambiente = Ambiente::fromValue((string) $data['ambiente']); $isHomologacao = $ambiente->isHomologacao(); $qrCode = $this->generateQrCode((string) $data['chave_acesso'], $ambiente); @@ -73,7 +84,7 @@ public function render(array $nfse, DanfseConfig $config): string * @param array $nfse * @return array */ - public function buildData(array $nfse): array + public function buildData(array $nfse, ?int $complementaryTextLimit = null): array { $infNfse = $this->node($nfse, 'infNFSe'); $infDps = $this->node($infNfse, 'DPS', 'infDPS'); @@ -92,10 +103,28 @@ public function buildData(array $nfse): array $authorizedValues = $this->node($infNfse, 'valores'); $tribMun = $this->node($valores, 'trib', 'tribMun'); $tribFed = $this->node($valores, 'trib', 'tribFed'); - $totTrib = $this->node($valores, 'trib', 'totTrib', 'pTotTrib'); + $totTribGroup = $this->node($valores, 'trib', 'totTrib'); + $approxPercent = $this->node($totTribGroup, 'pTotTrib'); + $approxMoney = $this->node($totTribGroup, 'vTotTrib'); + $approxValues = $approxMoney !== [] + ? [ + $this->currencyOrDash($this->val($approxMoney, 'vTotTribFed')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribEst')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribMun')), + ] + : [ + $this->percentOrDash($this->val($approxPercent, 'pTotTribFed')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribEst')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribMun')), + ]; + $infoComplementares = $this->node($serv, 'infoCompl'); $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + $ibsTotal = $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'); + $cbsTotal = $this->val($ibsCbsTotais, 'gCBS', 'vCBS'); + $incidenceCode = $this->val($ibsCbs, 'cLocalidadeIncid'); + $dpsDiscounts = $this->node($valores, 'vDescCondIncond'); // The authorized DPS is the primary source for the provider. The // authorized infNFSe/emit enriches omitted legacy address/name data. @@ -117,6 +146,39 @@ public function buildData(array $nfse): array $provider['simples_nacional'] = OptanteSimplesNacional::labelFor($this->val($regTrib, 'opSimpNac')); $provider['regime_sn'] = RegimeApuracaoTributariaSN::labelFor($this->val($regTrib, 'regApTribSN')); + // NT 008 note 5: each optional municipal row is suppressed only + // when all four of its source fields are absent. A literal '0' is + // supplied data and must not be treated as absent. + $regimeEspecial = $this->val($regTrib, 'regEspTrib'); + $tipoImunidade = $this->val($tribMun, 'tpImunidade'); + $tipoSuspensao = $this->val($tribMun, 'exigSusp', 'tpSusp'); + $numeroProcesso = $this->val($tribMun, 'exigSusp', 'nProcesso'); + + $tipoBeneficio = $this->val($authorizedValues, 'tpBM'); + $calculoBeneficio = $this->firstPresent( + $this->val($authorizedValues, 'vCalcBM'), + $this->val($tribMun, 'BM', 'vCalcBM'), + $this->val($tribMun, 'BM', 'vRedBCBM'), + ); + // The official display table chooses the explicit DPS vDR, or the + // calculated deduction and reimbursement amounts supplied in the + // authorized NFS-e. It does not re-derive these from service prices. + $explicitDeductions = $this->val($valores, 'vDedRed', 'vDR'); + $totalDeducoes = $explicitDeductions !== '' + ? $this->currencyOrDash($explicitDeductions) + : $this->sumCurrency( + $this->val($authorizedValues, 'vCalcDR'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + ); + $descontoIncondicionado = $this->val($valores, 'vDescCondIncond', 'vDescIncond'); + + // NT008 v1.02, 2.4.5: these are display compositions of XML + // amounts, never a recalculation of a tax or retention liability. + $pisCofinsRetention = $this->val($tribFed, 'piscofins', 'tpRetPisCofins'); + $retainedSocial = $this->val($tribFed, 'vRetCSLL'); + $pisValue = $this->val($tribFed, 'piscofins', 'vPis'); + $cofinsValue = $this->val($tribFed, 'piscofins', 'vCofins'); + $id = $this->val($infNfse, 'Id'); $chaveAcesso = str_starts_with($id, 'NFS') ? substr($id, 3) : $id; @@ -183,9 +245,25 @@ public function buildData(array $nfse): array 'municipio_incidencia' => $this->locationWithUfAndCountry( $this->val($infNfse, 'xLocIncid'), $this->val($infNfse, 'cLocIncid'), - '', + $this->val($tribMun, 'cPaisResult'), + ), + 'regime_especial' => RegimeEspecialTributacao::labelFor($regimeEspecial), + 'tipo_imunidade' => $this->immunityLabel($tipoImunidade), + 'suspensao_issqn' => $this->suspensionLabel($tipoSuspensao), + 'numero_processo' => $numeroProcesso !== '' ? $numeroProcesso : '-', + 'mostrar_linha_regime' => $this->hasAnyPresent($regimeEspecial, $tipoImunidade, $tipoSuspensao, $numeroProcesso), + 'beneficio_municipal' => $this->municipalBenefitLabel($tipoBeneficio), + 'calculo_bm' => $this->currencyOrDash($calculoBeneficio), + 'total_deducoes' => $totalDeducoes, + 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), + 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $totalDeducoes !== '-' ? $totalDeducoes : '', $descontoIncondicionado), + // Note 4 permits the concise no-ISS block when the XML + // expressly identifies the operation as not incident. + 'nao_sujeito_issqn' => in_array( + $this->val($tribMun, 'tribISSQN'), + ['2', '3', '4'], + true, ), - 'regime_especial' => RegimeEspecialTributacao::labelFor($this->val($regTrib, 'regEspTrib')), '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')), @@ -201,21 +279,36 @@ public function buildData(array $nfse): array ), 'irrf' => $this->currencyOrDash($this->val($tribFed, 'vRetIRRF')), 'cp' => $this->currencyOrDash($this->val($tribFed, 'vRetCP')), - 'csll' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), - 'pis' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vPis')), - 'cofins' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vCofins')), - 'descricao_retencao' => $this->pisCofinsRetentionLabel( - $this->val($tribFed, 'piscofins', 'tpRetPisCofins'), - ), + 'contribuicoes_sociais' => $pisCofinsRetention === '1' + ? $this->sumCurrency($retainedSocial, $pisValue, $cofinsValue) + : $this->currencyOrDash($retainedSocial), + // Keep legacy model keys unchanged for existing consumers. + 'csll' => $this->currencyOrDash($retainedSocial), + // When PIS/COFINS are retained, the NT008 prescribes zero + // for the own-assessment cells, not the retained amounts. + 'pis' => $pisCofinsRetention === '1' + ? $this->formatter->currency('0.00') + : $this->currencyOrDash($pisValue), + 'cofins' => $pisCofinsRetention === '1' + ? $this->formatter->currency('0.00') + : $this->currencyOrDash($cofinsValue), + 'descricao_retencao' => $this->pisCofinsRetentionLabel($pisCofinsRetention), ], 'totais' => [ - 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), - 'desconto_condicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescCond')), - 'desconto_incondicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescIncond')), - 'issqn_retido' => ($this->val($authorizedValues, 'vISSQN') !== '' && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1') - ? $this->formatter->currency($this->val($authorizedValues, 'vISSQN')) - : '-', + 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), + 'desconto_condicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescCond')), + 'desconto_incondicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescIncond')), + // This field is calculated by SEFIN and exists in the authorized + // NFS-e. Do not recalculate it from partially provided retentions. + 'total_retencoes' => $this->currencyOrDash($this->val($authorizedValues, 'vTotalRet')), + // Historical buildData() keys are retained for consumers and + // golden contracts. The DANFSe itself prints vTotalRet as the + // authoritative total rather than any partial DPS sum. + 'issqn_retido' => ( + $this->val($authorizedValues, 'vISSQN') !== '' + && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1' + ) ? $this->currencyOrDash($this->val($authorizedValues, 'vISSQN')) : '-', 'retencoes_federais' => $this->sumCurrency( $this->val($tribFed, 'vRetIRRF'), $this->val($tribFed, 'vRetCP'), @@ -225,13 +318,13 @@ public function buildData(array $nfse): array $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), - 'valor_liquido' => $this->formatter->currency($this->val($infNfse, 'valores', 'vLiq')), + 'valor_liquido' => $this->currencyOrDash($this->val($authorizedValues, 'vLiq')), ], 'totais_tributos' => [ - 'federais' => $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - 'estaduais' => $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - 'municipais' => $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + 'federais' => $approxValues[0], + 'estaduais' => $approxValues[1], + 'municipais' => $approxValues[2], ], 'ibs_cbs' => [ @@ -239,33 +332,60 @@ 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 ?: '-', - 'exclusoes_reducoes' => $this->sumCurrency( - $this->val($infNfse, 'valores', 'vISSQN'), + // NT008 explicitly displays this sum of XML components. It + // does not authorize fresh IBS/CBS or ISS tax calculation. + 'exclusoes_reducoes' => $ibsCbs === [] ? '-' : $this->sumCurrency( + $this->val($dpsDiscounts, 'vDescIncond'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + $this->val($authorizedValues, 'vISSQN'), $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), 'base_calculo' => $this->currencyOrDash($this->val($ibsCbsValores, 'vBC')), + 'reducao_aliquotas' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pRedAliqUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pRedAliqMun')), + $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pRedAliqCBS')), + ), + 'aliquota_ibs' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + ), 'aliquota_ibs_uf' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), 'aliquota_ibs_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + 'aliquota_efetiva_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pAliqEfetMun')), + 'valor_ibs_municipal' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSMunTot', 'vIBSMun')), + 'aliquota_efetiva_estadual' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pAliqEfetUF')), + 'valor_ibs_estadual' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSUFTot', 'vIBSUF')), 'aliquota_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pCBS')), - 'total_ibs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'vIBSTot')), - 'total_cbs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gCBS', 'vCBS')), - 'total_ibs_cbs' => $ibsCbs === [] ? '-' : $this->sumCurrency( - $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'), - $this->val($ibsCbsTotais, 'gCBS', 'vCBS'), - ), + 'aliquota_efetiva_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pAliqEfetCBS')), + 'total_ibs' => $this->currencyOrDash($ibsTotal), + 'total_cbs' => $this->currencyOrDash($cbsTotal), + // An absent amount is not proof of zero. Two explicit amounts + // (including literal zero) are required for a displayed sum. + 'total_ibs_cbs' => $ibsTotal !== '' && $cbsTotal !== '' + ? $this->sumCurrency($ibsTotal, $cbsTotal) + : '-', 'valor_total_nfse' => $this->currencyOrDash($this->val($ibsCbsTotais, 'vTotNF')), ], 'informacoes_complementares' => $this->complementaryInformation( - $this->val($serv, 'infoCompl', 'xInfComp'), - $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $this->authorizedComplementaryFields($infNfse, $infDps, $serv, $infoComplementares), + $approxValues[0], + $approxValues[1], + $approxValues[2], + $complementaryTextLimit, ), ]; } @@ -408,8 +528,11 @@ private function address(array $endereco): string } /** - * Show the municipality, UF and an optional country only when present in - * the authorized XML. The UF is resolved from the official IBGE code. + * Display ISO country BR for a recognized Brazilian IBGE municipality. + * The NT 008/2026 v1.02 DANFSe table explicitly uses BR as an example; + * this is a presentation inference from the authorized IBGE municipality, + * NOT a cPaisPrestacao XML value (which E0304 forbids for BR). + * For foreign operations, preserve the explicit XML country instead. */ private function locationWithUfAndCountry(string $city, string $ibge, string $country): string { @@ -422,10 +545,32 @@ private function locationWithUfAndCountry(string $city, string $ibge, string $co } } + $country = strtoupper(trim($country)); + if ($country === '' && $uf !== '') { + $country = 'BR'; + } + return ($city !== '' ? $city : '-') . ' / ' . ($uf !== '' ? $uf : '-') . ' / ' . ($country !== '' ? $country : '-'); } + /** + * 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) { @@ -464,6 +609,68 @@ private function city(string $cMun): string return $cMun !== '' ? Municipios::lookup($cMun) : '-'; } + private function firstPresent(string ...$values): string + { + foreach ($values as $value) { + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function hasAnyPresent(string ...$values): bool + { + foreach ($values as $value) { + if ($value !== '') { + return true; + } + } + + return false; + } + + private function immunityLabel(string $value): string + { + $label = match ($value) { + // XSD 1.01 explicitly assigns 0 to a legacy, unspecified type. + // Display the actual fiscal meaning instead of confusing it with + // a missing XML tag, even though NT008's table lists 1–5. + '0' => 'Imunidade (tipo não informado)', + '1' => 'Patrimônio, renda ou serviços de entes públicos', + '2' => 'Entidades religiosas e templos de qualquer culto', + '3' => 'Partidos, sindicatos, educação e assistência social', + '4' => 'Livros, jornais, periódicos e o papel destinado à impressão', + '5' => 'Fonogramas e videofonogramas musicais brasileiros', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function suspensionLabel(string $value): string + { + $label = match ($value) { + '1' => 'Exigibilidade Suspensa por Decisão Judicial', + '2' => 'Exigibilidade Suspensa por Processo Administrativo', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function municipalBenefitLabel(string $value): string + { + return match ($value) { + '1' => 'Isenção', + '2' => 'Redução da BC em percentual', + '3' => 'Redução da BC em reais', + '4' => 'Alíquota Diferenciada', + default => '-', + }; + } + private function currencyOrDash(string $value): string { return $value !== '' ? $this->formatter->currency($value) : '-'; @@ -533,6 +740,10 @@ private function pisCofinsRetentionLabel(string $value): string '3' => '3 - PIS/COFINS/CSLL Retidos', '4' => '4 - PIS/COFINS Retidos, CSLL Não Retido', '5' => '5 - PIS Retido, COFINS/CSLL Não Retido', + '6' => '6 - COFINS Retido, PIS/CSLL Não Retido', + '7' => '7 - PIS Não Retido, COFINS/CSLL Retidos', + '8' => '8 - PIS/COFINS Não Retidos, CSLL Retido', + '9' => '9 - COFINS Não Retido, PIS/CSLL Retidos', default => '-', }; } @@ -546,12 +757,83 @@ private function joinedOrDash(string ...$values): string return implode(' / ', array_map(static fn (string $value): string => $value !== '' ? $value : '-', $values)); } + /** + * NT008 table 2.4.5 prescribes a stable order and pipe separators. + * The values below exist exclusively in the authorized NFS-e XML. + * + * @param array $infNfse + * @param array $infDps + * @param array $service + * @param array $info + */ + private function authorizedComplementaryFields( + array $infNfse, + array $infDps, + array $service, + array $info, + ): string { + $parts = []; + $fieldSources = [ + ['Inf. Cont.:', $this->val($info, 'xInfComp')], + ['NFS-e Subst.:', $this->val($infDps, 'subst', 'chSubstda')], + ['Doc. Ref.:', $this->val($info, 'docRef')], + ['Cod. Obra:', $this->val($service, 'obra', 'cObra')], + ['Insc. Imob.:', $this->val($service, 'obra', 'inscImobFisc') + ?: $this->val($infDps, 'IBSCBS', 'imovel', 'inscImobFisc')], + ['Cod. Evt.:', $this->val($service, 'atvEvento', 'idAtvEvt')], + ['Doc. Tec.:', $this->val($info, 'idDocTec')], + ['Núm. Ped.:', $this->val($info, 'xPed')], + ['Item Ped.:', $this->listItemNumbers($this->node($info, 'gItemPed'))], + ['Inf. A. T. Mun.:', $this->val($infNfse, 'xOutInf')], + ]; + + foreach ($fieldSources as [$prefix, $content]) { + if ($content !== '') { + $parts[] = $prefix . ' ' . $content; + } + } + + return implode(' | ', $parts); + } + + /** + * Preserve all repeated xItemPed occurrences from XML without dropping + * the first items (up to 99 in the active XSD). + * + * @param array $group + */ + private function listItemNumbers(array $group): string + { + $items = $group['xItemPed'] ?? null; + if (is_scalar($items)) { + return trim((string) $items); + } + + if (!is_array($items)) { + return ''; + } + + $clean = []; + foreach ($items as $item) { + if (is_scalar($item) && trim((string) $item) !== '') { + $clean[] = trim((string) $item); + } + } + + return implode(', ', $clean); + } + private function complementaryInformation( string $existing, string $federal, string $state, string $municipal, + ?int $freeTextLimit = null, ): string { + if ($freeTextLimit !== null) { + $existing = $this->formatter->limit(trim($existing), $freeTextLimit); + } + $taxes = sprintf( 'Totais aproximados dos Tributos cfe. Lei n° 12.741/2012: Federais: %s; Estaduais: %s; Municipais: %s;', $federal, @@ -559,7 +841,7 @@ private function complementaryInformation( $municipal, ); - return trim($existing) === '' ? $taxes : trim($existing) . "\n" . $taxes; + return $existing === '' ? $taxes : $existing . " | " . $taxes; } private function generateQrCode(string $chaveAcesso, Ambiente $ambiente): string diff --git a/src/Danfse/Enum/RegimeEspecialTributacao.php b/src/Danfse/Enum/RegimeEspecialTributacao.php index 7291b9b..f3bef0a 100644 --- a/src/Danfse/Enum/RegimeEspecialTributacao.php +++ b/src/Danfse/Enum/RegimeEspecialTributacao.php @@ -18,6 +18,7 @@ enum RegimeEspecialTributacao: int case NOTARIO_REGISTRADOR = 4; case PROFISSIONAL_AUTONOMO = 5; case SOCIEDADE_PROFISSIONAIS = 6; + case OUTROS = 9; public function label(): string { @@ -29,6 +30,7 @@ public function label(): string self::NOTARIO_REGISTRADOR => 'Notário ou Registrador', self::PROFISSIONAL_AUTONOMO => 'Profissional Autônomo', self::SOCIEDADE_PROFISSIONAIS => 'Sociedade de Profissionais', + self::OUTROS => 'Outros', }; } } diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index 66efa21..95c0453 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -7,6 +7,7 @@ /** @var string $qrCode */ /** @var bool $isHomologacao */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState $fiscalState */ +/** @var string $fontFaces */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\MunicipalityBranding $municipality */ ?> @@ -15,35 +16,49 @@ DANFSe - <?= $data['numero_nfse'] ?>