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..e08ebb4 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,35 @@ 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. CSS font families are namespaced by their +local files and cache directory because Dompdf caches font lookups across +renderer instances in the same PHP process; this prevents optional font +registration from changing the default renderer's typography. + +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 +73,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..bb244b5 --- /dev/null +++ b/src/Danfse/Config/DanfseFonts.php @@ -0,0 +1,119 @@ +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 + { + // Dompdf 2.x/3.x caches FontMetrics::getFont() lookups statically + // by CSS family name, across renderer instances in one PHP process. + // A namespaced family avoids poisoning the next default document. + $label = $this->labelFamily(); + $value = $this->valueFamily(); + + return implode("\n", [ + '@font-face { font-family: "' . $label . '"; font-style: normal; font-weight: 400; src: url("' . + self::fileUri($this->arialRegular) . '") format("truetype"); }', + '@font-face { font-family: "' . $label . '"; font-style: normal; font-weight: 700; src: url("' . + self::fileUri($this->arialBold) . '") format("truetype"); }', + '@font-face { font-family: "' . $value . '"; font-style: normal; font-weight: 400; src: url("' . + self::fileUri($this->microsoftSansSerif) . '") format("truetype"); }', + ]); + } + + public function fontOverrideCss(): string + { + return 'body, .label, .fiscal-state-watermark, .watermark { font-family: "' . + $this->labelFamily() . '", sans-serif; }' . "\n" . + '.value, .municipality-cell { font-family: "' . $this->valueFamily() . '", sans-serif; }'; + } + + public function labelFamily(): string + { + return 'NfseLabels' . $this->cacheNamespace(); + } + + public function valueFamily(): string + { + return 'NfseValues' . $this->cacheNamespace(); + } + + private function cacheNamespace(): string + { + return substr(hash('sha256', implode('|', [ + $this->arialRegular, + $this->arialBold, + $this->microsoftSansSerif, + $this->fontCacheDirectory, + ])), 0, 16); + } + + 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 171f609..ab657e5 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -43,14 +43,26 @@ 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() ?? ''; + $fontOverrides = $config->fonts?->fontOverrideCss() ?? ''; $ambiente = Ambiente::fromValue((string) $data['ambiente']); $isHomologacao = $ambiente->isHomologacao(); $qrCode = $this->generateQrCode((string) $data['chave_acesso'], $ambiente); @@ -73,7 +85,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 +104,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 +147,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; @@ -185,7 +248,23 @@ public function buildData(array $nfse): array $this->val($infNfse, 'cLocIncid'), $this->val($tribMun, 'cPaisResult'), ), - 'regime_especial' => RegimeEspecialTributacao::labelFor($this->val($regTrib, 'regEspTrib')), + 'regime_especial' => RegimeEspecialTributacao::labelFor($regimeEspecial), + 'tipo_imunidade' => $this->immunityLabel($tipoImunidade), + 'suspensao_issqn' => $this->suspensionLabel($tipoSuspensao), + 'numero_processo' => $numeroProcesso !== '' ? $numeroProcesso : '-', + 'mostrar_linha_regime' => $this->hasAnyPresent($regimeEspecial, $tipoImunidade, $tipoSuspensao, $numeroProcesso), + 'beneficio_municipal' => $this->municipalBenefitLabel($tipoBeneficio), + 'calculo_bm' => $this->currencyOrDash($calculoBeneficio), + 'total_deducoes' => $totalDeducoes, + 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), + 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $totalDeducoes !== '-' ? $totalDeducoes : '', $descontoIncondicionado), + // Note 4 permits the concise no-ISS block when the XML + // expressly identifies the operation as not incident. + 'nao_sujeito_issqn' => in_array( + $this->val($tribMun, 'tribISSQN'), + ['2', '3', '4'], + true, + ), '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,23 +280,36 @@ public function buildData(array $nfse): array ), 'irrf' => $this->currencyOrDash($this->val($tribFed, 'vRetIRRF')), 'cp' => $this->currencyOrDash($this->val($tribFed, 'vRetCP')), - 'contribuicoes_sociais' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), - // Alias de compatibilidade para consumidores de buildData(). - 'csll' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), - 'pis' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vPis')), - 'cofins' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vCofins')), - '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'), @@ -227,13 +319,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' => [ @@ -241,33 +333,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, ), ]; } @@ -436,6 +555,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) { @@ -474,6 +610,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) : '-'; @@ -560,12 +758,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, @@ -573,7 +842,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 a9ee6a7..2346a69 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -7,6 +7,8 @@ /** @var string $qrCode */ /** @var bool $isHomologacao */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState $fiscalState */ +/** @var string $fontFaces */ +/** @var string $fontOverrides */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\MunicipalityBranding $municipality */ ?> @@ -15,35 +17,49 @@ DANFSe - <?= $data['numero_nfse'] ?>
value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
+
HOMOLOGAÇÃO
@@ -105,10 +123,10 @@ -
DANFSe v2.0
-
Documento Auxiliar da NFS-e
+
DANFSe v2.0
+
Documento Auxiliar da NFS-e
-
NFS-e SEM VALIDADE JURÍDICA
+
NFS-e SEM VALIDADE JURÍDICA
@@ -349,15 +367,33 @@ - +
+ +
TRIBUTAÇÃO MUNICIPAL (ISSQN) - OPERAÇÃO NÃO SUJEITA AO ISSQN
+ - - + + + + + + + + + + + + + + + + + @@ -365,6 +401,7 @@
TRIBUTAÇÃO MUNICIPAL (ISSQN) Tipo de Tributação do ISSQNMunicípio / Sigla UF / País de Incidência do ISSQNRegime Especial de TributaçãoMunicípio / Sigla UF / País de Incidência do ISSQN
Regime Especial de Tributação do ISSQNTipo de Imunidade do ISSQNSuspensão da Exigibilidade do ISSQNNúmero Processo Suspensão
Benefício MunicipalCálculo do BMTotal Deduções/ReduçõesDesconto Incondicionado
BC ISSQN Alíquota AplicadaISSQN Apurado
+
@@ -386,68 +423,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
@@ -462,9 +462,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/src/Danfse/XmlToArray.php b/src/Danfse/XmlToArray.php index 02036aa..40eebba 100644 --- a/src/Danfse/XmlToArray.php +++ b/src/Danfse/XmlToArray.php @@ -55,14 +55,12 @@ private function nodeToArray(\SimpleXMLElement $node): array|string $nsChildren = $node->children(self::NFSE_NS); if ($nsChildren !== null && $nsChildren->count() > 0) { foreach ($nsChildren as $name => $child) { - $result[$name] = $this->nodeToArray($child); + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } else { // Fallback for XMLs that omit the namespace on children. foreach ($node->children() ?? [] as $name => $child) { - if (!isset($result[$name])) { - $result[$name] = $this->nodeToArray($child); - } + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } @@ -73,4 +71,26 @@ private function nodeToArray(\SimpleXMLElement $node): array|string return $result; } + + /** + * Repeating an XML element is not a replacement: preserve each distinct + * child, but leave single-occurrence fields backward compatible. + * + * @param array $out + * @param array|string $value + */ + private function appendNamedChild(array &$out, string $name, array|string $value): void + { + if (!array_key_exists($name, $out)) { + $out[$name] = $value; + + return; + } + + if (!is_array($out[$name]) || !array_is_list($out[$name])) { + $out[$name] = [$out[$name]]; + } + + $out[$name][] = $value; + } } diff --git a/tests/Unit/Danfse/DanfseGeneratorTest.php b/tests/Unit/Danfse/DanfseGeneratorTest.php index 83eadc0..8319a07 100644 --- a/tests/Unit/Danfse/DanfseGeneratorTest.php +++ b/tests/Unit/Danfse/DanfseGeneratorTest.php @@ -9,6 +9,7 @@ use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState; +use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFonts; use LibreCodeCoop\NfsePHP\Danfse\Config\MunicipalityBranding; use LibreCodeCoop\NfsePHP\Danfse\DanfseGenerator; use LibreCodeCoop\NfsePHP\Exception\ArtifactException; @@ -37,6 +38,51 @@ public function testGenerateFromXmlReturnsPdfBinary(): void self::assertStringStartsWith('%PDF-', $pdf); } + public function testFontConfigurationRejectsMissingLocalFiles(): void + { + $this->expectException(\InvalidArgumentException::class); + new DanfseFonts( + '/not-a-real-folder/arial.ttf', + '/not-a-real-folder/arialbd.ttf', + '/not-a-real-folder/micross.ttf', + sys_get_temp_dir(), + ); + } + + public function testLocalFontFilesCanBeRegisteredWithoutRemoteLoading(): void + { + // Dompdf's redistributable bundled fonts are safe stand-ins for + // proving the loading mechanism, NOT Arial/MSG or NT008 compliance. + $fontsPath = dirname(__DIR__, 3) . '/vendor/dompdf/dompdf/lib/fonts'; + $regular = $fontsPath . '/DejaVuSans.ttf'; + $bold = $fontsPath . '/DejaVuSans-Bold.ttf'; + self::assertFileExists($regular); + self::assertFileExists($bold); + + $cache = sys_get_temp_dir() . '/nfse-danfse-fonts-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($cache, 0o700)); + $fonts = new DanfseFonts($regular, $bold, $regular, $cache); + self::assertCount(1, $fonts->directories()); + + $generator = new DanfseGenerator(new DanfseConfig(fonts: $fonts)); + $html = $generator->generateHtml($this->xml); + self::assertStringContainsString('font-family: "' . $fonts->labelFamily() . '"', $html); + self::assertStringContainsString('font-family: "' . $fonts->valueFamily() . '"', $html); + self::assertStringNotContainsString('@font-face { font-family: "Arial"', $html); + self::assertStringContainsString('src: url("file://', $html); + self::assertStringNotContainsString('data:font/', $html); + + $pdf = $generator->generateFromXml($this->xml); + self::assertStringStartsWith('%PDF-', $pdf); + + // Distinct font registrations must not change the default font + // lookup later in the SAME PHP process. + $default = new DanfseGenerator(); + self::assertStringStartsWith('%PDF-', $default->generateFromXml($this->xml)); + $homolog = str_replace('1', '2', $this->xml); + self::assertStringContainsString('HOMOLOGAÇÃO', $default->generateHtml($homolog)); + } + public function testGeneratedPdfSizeIsReasonable(): void { $pdf = (new DanfseGenerator())->generateFromXml($this->xml); @@ -87,6 +133,41 @@ public function testSubstitutedFiscalStateRendersExplicitWatermark(): void self::assertStringContainsString('class="fiscal-state-watermark">SUBSTITUÍDA', $html); } + public function testLongSyntheticServiceKeepsExplicitEllipsisAndOnePageHtml(): void + { + $longDescription = str_repeat('DESCRICAO SINTETICA DE SERVICO E ETAPAS. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longDescription . '', + $this->xml, + ); + self::assertIsString($xml); + + $generator = new DanfseGenerator(); + $html = $generator->generateHtml($xml); + self::assertStringContainsString('DESCRICAO SINTETICA DE SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringStartsWith('%PDF-', $generator->generateFromXml($xml)); + } + + public function testLongSyntheticComplementaryInformationPreservesTaxDisclosure(): void + { + $longNotes = str_repeat('OBSERVACAO SINTETICA DO SERVICO. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longNotes . '', + $this->xml, + ); + self::assertIsString($xml); + + $html = (new DanfseGenerator())->generateHtml($xml); + self::assertStringContainsString('OBSERVACAO SINTETICA DO SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringContainsString('Federais: 4.50%', $html); + } + public function testGenerateHtmlContainsHomologacaoWatermark(): void { $xml = str_replace('1', '2', $this->xml); diff --git a/tests/Unit/Danfse/DanfseGoldenReferenceTest.php b/tests/Unit/Danfse/DanfseGoldenReferenceTest.php index cd10981..e7b7351 100644 --- a/tests/Unit/Danfse/DanfseGoldenReferenceTest.php +++ b/tests/Unit/Danfse/DanfseGoldenReferenceTest.php @@ -29,8 +29,12 @@ public function testNationalPortalStyleUsesCompactGridsAndFooter(): void { $html = (new DanfseTemplate())->render($this->fixtureData(), new DanfseConfig()); - self::assertStringContainsString('background: #f3f3f3', $html); - self::assertStringContainsString('background: #ededed', $html); + self::assertStringContainsString('background: #f2f2f2', $html); + self::assertStringContainsString('border: 1pt solid #000', $html); + self::assertStringContainsString('border-bottom: 0.5pt solid #000', $html); + self::assertStringContainsString('@page { margin: 5pt; }', $html); + self::assertStringContainsString('class="page-border"', $html); + self::assertStringContainsString('.first-section .label { font-size: 7pt; text-transform: uppercase; }', $html); self::assertStringContainsString('position: fixed;', $html); self::assertStringContainsString('bottom: 0;', $html); self::assertStringContainsString('PRESTADOR / FORNECEDOR', $html); diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index 220cfef..7db820e 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -229,6 +229,205 @@ public function testAuthorizedApproximateTaxValuesAreCopiedWithoutZeroFillingOrI self::assertSame('R$ 630,00', $sourced['tributacao_municipal']['issqn_apurado']); } + public function testFederalRetainedPisCofinsFollowsNt008PresentationWithoutRecomputingTax(): void + { + $nfse = $this->fixtureNfseData(); + $federal = & $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']; + $federal['vRetCSLL'] = '3.00'; + $federal['piscofins'] = [ + 'vPis' => '2.25', + 'vCofins' => '4.50', + 'tpRetPisCofins' => '1', + ]; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('R$ 9,75', $data['tributacao_federal']['contribuicoes_sociais']); + self::assertSame('R$ 0,00', $data['tributacao_federal']['pis']); + self::assertSame('R$ 0,00', $data['tributacao_federal']['cofins']); + self::assertSame('R$ 3,00', $data['tributacao_federal']['csll']); + + $federal['piscofins']['tpRetPisCofins'] = '4'; + $data = $template->buildData($nfse); + self::assertSame('R$ 3,00', $data['tributacao_federal']['contribuicoes_sociais']); + self::assertSame('R$ 2,25', $data['tributacao_federal']['pis']); + self::assertSame('R$ 4,50', $data['tributacao_federal']['cofins']); + + unset($federal['vRetCSLL']); + $data = $template->buildData($nfse); + self::assertSame('-', $data['tributacao_federal']['contribuicoes_sociais']); + } + + public function testImmunityExportationAndNonIncidenceUseNoIssBlock(): void + { + $nfse = $this->fixtureNfseData(); + $template = new DanfseTemplate(); + $municipal = & $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']; + + foreach (['2', '3', '4'] as $code) { + $municipal['tribISSQN'] = $code; + self::assertTrue($template->buildData($nfse)['tributacao_municipal']['nao_sujeito_issqn']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('OPERAÇÃO NÃO SUJEITA AO ISSQN', $html); + self::assertStringNotContainsString('class="municipal-conditional-regime"', $html); + } + + $municipal['tribISSQN'] = '1'; + self::assertFalse($template->buildData($nfse)['tributacao_municipal']['nao_sujeito_issqn']); + self::assertStringContainsString('class="municipal-conditional-regime"', $template->render($nfse, new DanfseConfig())); + } + + public function testIbsCbsFullGridUsesOnlyAuthorizedAmountsAndRates(): void + { + $nfse = $this->fixtureNfseData(); + $authorized = & $nfse['infNFSe']; + $dps = & $authorized['DPS']['infDPS']; + $dps['valores']['vDescCondIncond'] = ['vDescIncond' => '10.00', 'vDescCond' => '5.00']; + $authorized['valores']['vISSQN'] = '7.00'; + $authorized['valores']['vTotalRet'] = '123.45'; + $authorized['IBSCBS'] = [ + 'cLocalidadeIncid' => '3303302', + 'xLocalidadeIncid' => 'Niterói', + 'valores' => [ + 'vBC' => '1450.00', + 'vCalcReeRepRes' => '20.00', + 'uf' => ['pRedAliqUF' => '0.00', 'pIBSUF' => '0.10', 'pAliqEfetUF' => '0.10'], + 'mun' => ['pRedAliqMun' => '0.00', 'pIBSMun' => '0.05', 'pAliqEfetMun' => '0.05'], + 'fed' => ['pRedAliqCBS' => '0.00', 'pCBS' => '0.90', 'pAliqEfetCBS' => '0.90'], + ], + 'totCIBS' => [ + 'vTotNF' => '1515.75', + 'gIBS' => [ + 'vIBSTot' => '2.25', + 'gIBSMunTot' => ['vIBSMun' => '0.75'], + 'gIBSUFTot' => ['vIBSUF' => '1.50'], + ], + 'gCBS' => ['vCBS' => '13.50'], + ], + ]; + $dps['IBSCBS']['cIndOp'] = '010101'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('010101 / 3303302 / Niterói / RJ', $data['ibs_cbs']['indicador_operacao']); + self::assertSame('R$ 91,75', $data['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('0.00% / 0.00% / 0.00%', $data['ibs_cbs']['reducao_aliquotas']); + self::assertSame('0.10% / 0.05%', $data['ibs_cbs']['aliquota_ibs']); + self::assertSame('0.05%', $data['ibs_cbs']['aliquota_efetiva_municipal']); + self::assertSame('R$ 0,75', $data['ibs_cbs']['valor_ibs_municipal']); + self::assertSame('0.10%', $data['ibs_cbs']['aliquota_efetiva_estadual']); + self::assertSame('R$ 1,50', $data['ibs_cbs']['valor_ibs_estadual']); + self::assertSame('0.90%', $data['ibs_cbs']['aliquota_efetiva_cbs']); + self::assertSame('R$ 15,75', $data['ibs_cbs']['total_ibs_cbs']); + self::assertSame('R$ 123,45', $data['totais']['total_retencoes']); + self::assertSame('R$ 10,00', $data['totais']['desconto_incondicionado']); + self::assertSame('R$ 5,00', $data['totais']['desconto_condicionado']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Valor Apurado Municipal - IBS', $html); + self::assertStringContainsString('Valor Apurado Estadual - IBS', $html); + self::assertStringContainsString('Alíquota Efetiva - CBS', $html); + self::assertStringContainsString('Total das Retenções (ISSQN / Federais)', $html); + } + + public function testAbsentIbsOrCbsAmountIsNotTreatedAsRealZero(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['IBSCBS']['totCIBS']['gIBS']['vIBSTot'] = '0.00'; + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + + $nfse['infNFSe']['IBSCBS']['totCIBS']['gCBS']['vCBS'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['ibs_cbs']['total_ibs_cbs']); + } + + public function testLegacyXmlWithoutIbsCbsDoesNotInventIbsExclusions(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['IBSCBS']); + // The old DPS can still contain PIS/COFINS amounts. Without any + // authorized IBS/CBS block they are not a new IBS base reduction. + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribFed']['piscofins']['vPis'] = '12.25'; + + $model = (new DanfseTemplate())->buildData($nfse); + self::assertSame('-', $model['ibs_cbs']['exclusoes_reducoes']); + self::assertSame('-', $model['ibs_cbs']['base_calculo']); + } + + public function testAuthorizedTotalRetentionNeverComesFromPartialDpsSum(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['valores']['trib']['tribFed']['vRetIRRF'] = '99.99'; + + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['totais']['total_retencoes']); + + $nfse['infNFSe']['valores']['vTotalRet'] = '78.90'; + self::assertSame('R$ 78,90', $template->buildData($nfse)['totais']['total_retencoes']); + } + + public function testComplementaryInformationFollowsOfficialOrderWithoutInventingTaxZeros(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['subst'] = ['chSubstda' => str_repeat('3', 50)]; + $dps['serv']['obra'] = [ + 'cObra' => 'OBRA-FICTICIA-001', + 'inscImobFisc' => 'IMOB-TESTE-0002', + ]; + $dps['serv']['atvEvento'] = ['idAtvEvt' => 'EVENTO-TESTE-123']; + $dps['serv']['infoCompl'] = [ + 'xInfComp' => 'Observacao sintetica', + 'docRef' => 'DOC-REF-123', + 'idDocTec' => 'DOC-TEC-456', + 'xPed' => 'PED-456', + 'gItemPed' => ['xItemPed' => ['01', '02']], + ]; + $nfse['infNFSe']['xOutInf'] = 'Mensagem municipal sintetica'; + $dps['valores']['trib']['totTrib'] = [ + 'vTotTrib' => [ + 'vTotTribFed' => '0.00', + 'vTotTribEst' => '15.50', + 'vTotTribMun' => '3.25', + ], + ]; + + $data = (new DanfseTemplate())->buildData($nfse); + $actual = $data['informacoes_complementares']; + foreach (['Inf. Cont.:', 'NFS-e Subst.:', 'Doc. Ref.:', 'Cod. Obra:', + 'Insc. Imob.:', 'Cod. Evt.:', 'Doc. Tec.:', 'Núm. Ped.:', + 'Item Ped.:', 'Inf. A. T. Mun.:', 'Totais aproximados dos Tributos'] as $part) { + self::assertStringContainsString($part, $actual); + } + self::assertSame('R$ 0,00', $data['totais_tributos']['federais']); + self::assertSame('R$ 15,50', $data['totais_tributos']['estaduais']); + self::assertSame('R$ 3,25', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Item Ped.: 01, 02', $actual); + self::assertStringContainsString('Federais: R$ 0,00; Estaduais: R$ 15,50; Municipais: R$ 3,25', $actual); + self::assertLessThan(strpos($actual, 'Doc. Ref.:'), strpos($actual, 'NFS-e Subst.:')); + self::assertLessThan(strpos($actual, 'Totais aproximados'), strpos($actual, 'Inf. A. T. Mun.:')); + } + + public function testAllAbsentApproximateTaxesStayAbsentAndRealZeroIsDistinct(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']); + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('-', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: -;', $data['informacoes_complementares']); + + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']['pTotTrib']['pTotTribMun'] = '0.00'; + $data = $template->buildData($nfse); + self::assertSame('0.00%', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: 0.00%', $data['informacoes_complementares']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); @@ -386,6 +585,122 @@ public function testIntermediaryKeepsIbgeAndCepInSameCell(): void self::assertStringContainsString('3518800 / 07095-130', $html); } + + public function testMunicipalRegimeZeroIsPresentButMissingCodeIsNot(): void + { + $nfse = $this->fixtureNfseData(); + $regTrib = & $nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']; + $regTrib['regEspTrib'] = '0'; + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + + self::assertSame('Nenhum', $data['tributacao_municipal']['regime_especial']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringContainsString('class="municipal-conditional-regime"', $template->render($nfse, new DanfseConfig())); + + unset($regTrib['regEspTrib']); + $data = $template->buildData($nfse); + self::assertSame('-', $data['tributacao_municipal']['regime_especial']); + self::assertFalse($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringNotContainsString('class="municipal-conditional-regime"', $template->render($nfse, new DanfseConfig())); + } + + public function testMunicipalRegimeOtherIsRenderedWithoutChangingFiscalEligibility(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib'] = '9'; + $data = (new DanfseTemplate())->buildData($nfse); + + self::assertSame('Outros', $data['tributacao_municipal']['regime_especial']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertStringContainsString('>Outros', (new DanfseTemplate())->render($nfse, new DanfseConfig())); + } + + public function testMunicipalConditionalRegimeRowRemainsWhenSiblingHasData(): void + { + $nfse = $this->fixtureNfseData(); + $trib = & $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']; + unset($nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib']); + $trib['tpImunidade'] = '4'; + $trib['exigSusp'] = ['tpSusp' => '1', 'nProcesso' => 'PROCESSO-SINTETICO-123']; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + self::assertSame('PROCESSO-SINTETICO-123', $data['tributacao_municipal']['numero_processo']); + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Tipo de Imunidade do ISSQN', $html); + self::assertStringContainsString('PROCESSO-SINTETICO-123', $html); + self::assertSame(1, substr_count($html, 'class="municipal-conditional-regime"')); + } + + public function testMunicipalImmunityCodeZeroIsNotDisplayedAsAbsent(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['prest']['regTrib']['regEspTrib']); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']['tpImunidade'] = '0'; + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('Imunidade (tipo não informado)', $data['tributacao_municipal']['tipo_imunidade']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_regime']); + + $html = $template->render($nfse, new DanfseConfig()); + self::assertStringContainsString('Imunidade (tipo não informado)', $html); + self::assertStringContainsString('class="municipal-conditional-regime"', $html); + } + + public function testMunicipalDeductionsUseOfficialAuthorizedSourcesAndPreserveExplicitZero(): void + { + $nfse = $this->fixtureNfseData(); + $values = & $nfse['infNFSe']['valores']; + $dpsValues = & $nfse['infNFSe']['DPS']['infDPS']['valores']; + + unset($dpsValues['vDedRed'], $values['vCalcDR'], $nfse['infNFSe']['IBSCBS']); + $template = new DanfseTemplate(); + self::assertSame('-', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + + $values['vCalcDR'] = '10.50'; + $nfse['infNFSe']['IBSCBS']['valores']['vCalcReeRepRes'] = '3.25'; + $data = $template->buildData($nfse); + self::assertSame('R$ 13,75', $data['tributacao_municipal']['total_deducoes']); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_beneficio']); + + $dpsValues['vDedRed']['vDR'] = '7.00'; + self::assertSame('R$ 7,00', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + + $dpsValues['vDedRed']['vDR'] = '0.00'; + self::assertSame('R$ 0,00', $template->buildData($nfse)['tributacao_municipal']['total_deducoes']); + } + + public function testMunicipalBenefitZeroIsNotSuppressedAndAbsenceIsDifferent(): void + { + $nfse = $this->fixtureNfseData(); + $template = new DanfseTemplate(); + self::assertFalse($template->buildData($nfse)['tributacao_municipal']['mostrar_linha_beneficio']); + + $nfse['infNFSe']['valores']['vCalcBM'] = '0.00'; + $data = $template->buildData($nfse); + self::assertTrue($data['tributacao_municipal']['mostrar_linha_beneficio']); + self::assertSame('R$ 0,00', $data['tributacao_municipal']['calculo_bm']); + self::assertStringContainsString('class="municipal-conditional-benefit"', $template->render($nfse, new DanfseConfig())); + + unset($nfse['infNFSe']['valores']['vCalcBM']); + $nfse['infNFSe']['valores']['tpBM'] = '1'; + self::assertSame('Isenção', $template->buildData($nfse)['tributacao_municipal']['beneficio_municipal']); + } + + public function testExplicitNonIncidenceUsesConciseMunicipalMessage(): void + { + $nfse = $this->fixtureNfseData(); + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['tribMun']['tribISSQN'] = '4'; + + $html = (new DanfseTemplate())->render($nfse, new DanfseConfig()); + self::assertStringContainsString('TRIBUTAÇÃO MUNICIPAL (ISSQN) - OPERAÇÃO NÃO SUJEITA AO ISSQN', $html); + self::assertStringNotContainsString('class="municipal-conditional-regime"', $html); + self::assertStringContainsString('TRIBUTAÇÃO FEDERAL (EXCETO CBS)', $html); + } + public function testIntermediarioIsNullWhenAbsent(): void { $nfseData = $this->fixtureNfseData(); diff --git a/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php b/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php index 1ea5a82..753ae89 100644 --- a/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php +++ b/tests/Unit/Danfse/RegimeEspecialTributacaoTest.php @@ -35,6 +35,7 @@ public static function labelProvider(): array 'notário ou registrador' => ['4', 'Notário ou Registrador'], 'profissional autônomo' => ['5', 'Profissional Autônomo'], 'sociedade de profissionais' => ['6', 'Sociedade de Profissionais'], + 'outros' => ['9', 'Outros'], ]; } diff --git a/tests/Unit/Danfse/XmlToArrayTest.php b/tests/Unit/Danfse/XmlToArrayTest.php index a187b9f..d72bd07 100644 --- a/tests/Unit/Danfse/XmlToArrayTest.php +++ b/tests/Unit/Danfse/XmlToArrayTest.php @@ -57,6 +57,20 @@ public function testRejectsDoctypeBeforeParsingExternalXml(): void (new XmlToArray())->convert($xml); } + public function testPreservesRepeatedOrderItems(): void + { + $withItems = str_replace( + '', + '010203', + $this->fixture(), + ); + $value = (new XmlToArray())->convert($withItems); + self::assertSame( + ['01', '02', '03'], + $value['infNFSe']['DPS']['infDPS']['serv']['infoCompl']['gItemPed']['xItemPed'], + ); + } + public function testIgnoresDigitalSignature(): void { $signed = str_replace( diff --git a/tools/bin/render-danfse-visual-fixtures.php b/tools/bin/render-danfse-visual-fixtures.php index 18932b8..8f1dcd7 100644 --- a/tools/bin/render-danfse-visual-fixtures.php +++ b/tools/bin/render-danfse-visual-fixtures.php @@ -7,7 +7,7 @@ use LibreCodeCoop\NfsePHP\Danfse\DanfseGenerator; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; -use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState; +use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFonts; require dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -46,18 +46,82 @@ $longPdf = $generator->generateFromXml($longXml); file_put_contents($outputDir . '/danfse-long-description.pdf', $longPdf); -// State must be supplied by a trusted event/query response, not inferred -// from cStat or a substitution reference inside the original NFS-e XML. -foreach ([ - 'cancelled' => DanfseFiscalState::Cancelled, - 'substituted' => DanfseFiscalState::Substituted, -] as $label => $state) { - $statePdf = (new DanfseGenerator(new DanfseConfig(fiscalState: $state)))->generateFromXml($xml); - file_put_contents($outputDir . '/danfse-' . $label . '.pdf', $statePdf); -} - foreach ([$pdf, $longPdf] as $rendered) { if (!str_starts_with($rendered, '%PDF-')) { throw new RuntimeException('DANFSe generator did not return a PDF'); } } + +/** + * Additional independent synthetic PDFs for the municipal conditional rows. + * These fixtures exercise presentation only; no XML is transmitted or signed. + */ +foreach (['regime-zero', 'regime-nine', 'regime-absent', 'immunity-zero', 'benefit-zero', 'no-iss-incidence'] as $scenario) { + $caseDoc = new DOMDocument(); + if (!$caseDoc->loadXML($xml, LIBXML_NONET)) { + throw new RuntimeException('Invalid synthetic NFS-e base fixture'); + } + + $caseXPath = new DOMXPath($caseDoc); + $regime = $caseXPath->query('//*[local-name()="regEspTrib"]')?->item(0); + $municipalTax = $caseXPath->query('//*[local-name()="tribISSQN"]')?->item(0); + if (!$regime instanceof DOMElement || !$municipalTax instanceof DOMElement) { + throw new RuntimeException('Synthetic fixture lacks municipal tax nodes'); + } + + if ($scenario === 'regime-zero') { + $regime->nodeValue = '0'; + } elseif ($scenario === 'regime-nine') { + $regime->nodeValue = '9'; + } elseif ($scenario === 'regime-absent' || $scenario === 'immunity-zero') { + $regime->parentNode?->removeChild($regime); + } + + if ($scenario === 'immunity-zero') { + $municipalTax->nodeValue = '2'; + $immunity = $caseDoc->createElementNS($municipalTax->namespaceURI, 'tpImunidade', '0'); + $municipalTax->parentNode?->insertBefore($immunity, $municipalTax->nextSibling); + } + + if ($scenario === 'benefit-zero') { + $values = $caseXPath->query('//*[local-name()="infNFSe"]/*[local-name()="valores"]')?->item(0); + if (!$values instanceof DOMElement) { + throw new RuntimeException('Synthetic fixture lacks authorized totals'); + } + + $benefit = $caseDoc->createElementNS($values->namespaceURI, 'vCalcBM', '0.00'); + $values->insertBefore($benefit, $values->firstChild); + } + + if ($scenario === 'no-iss-incidence') { + $municipalTax->nodeValue = '4'; + } + + $caseXml = $caseDoc->saveXML(); + if (!is_string($caseXml)) { + throw new RuntimeException('Could not serialize municipal synthetic fixture'); + } + + $casePdf = $generator->generateFromXml($caseXml); + if (!str_starts_with($casePdf, '%PDF-')) { + throw new RuntimeException('Municipal synthetic scenario did not generate a PDF'); + } + file_put_contents($outputDir . '/danfse-' . $scenario . '.pdf', $casePdf); +} + +// Prove that external local TrueType fonts really embed, using only Dompdf's +// permissively licensed test font. This does not claim the actual NT008 +// proprietary font names are installed or licensed on the CI runner. +$fontRoot = dirname(__DIR__, 2) . '/vendor/dompdf/dompdf/lib/fonts'; +$fontCache = $outputDir . '/font-cache'; +if (!is_dir($fontCache) && !mkdir($fontCache, 0775, true) && !is_dir($fontCache)) { + throw new RuntimeException('Unable to create isolated font cache'); +} +$probeFonts = new DanfseFonts( + $fontRoot . '/DejaVuSans.ttf', + $fontRoot . '/DejaVuSans-Bold.ttf', + $fontRoot . '/DejaVuSans.ttf', + $fontCache, +); +$probePdf = (new DanfseGenerator(new DanfseConfig(fonts: $probeFonts)))->generateFromXml($xml); +file_put_contents($outputDir . '/danfse-font-probe.pdf', $probePdf);