Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/danfse-v2-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The stable contract covered by tests is:
- homologation documents have a visible environment marker;
- local generation returns a valid PDF artifact;
- 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.

Expand Down
98 changes: 89 additions & 9 deletions src/Danfse/DanfseTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,21 @@ 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');
Expand Down Expand Up @@ -209,9 +223,9 @@ public function buildData(array $nfse): array
],

'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' => [
Expand Down Expand Up @@ -242,10 +256,10 @@ public function buildData(array $nfse): array
],

'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],
),
];
}
Expand Down Expand Up @@ -439,6 +453,72 @@ 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<string,mixed> $infNfse
* @param array<string,mixed> $infDps
* @param array<string,mixed> $service
* @param array<string,mixed> $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<string,mixed> $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,
Expand All @@ -452,7 +532,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
Expand Down
28 changes: 24 additions & 4 deletions src/Danfse/XmlToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand All @@ -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<string,mixed> $out
* @param array<string,mixed>|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;
}
}
58 changes: 58 additions & 0 deletions tests/Unit/Danfse/DanfseTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,64 @@ public function testAuthorizedMunicipalTotalsUseNfseValuesNotDpsTaxFlags(): void
self::assertSame('R$ 630,00', $data['tributacao_municipal']['issqn_apurado']);
}

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();
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/Danfse/XmlToArrayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ public function testRejectsDoctypeBeforeParsingExternalXml(): void
(new XmlToArray())->convert($xml);
}

public function testPreservesRepeatedOrderItems(): void
{
$withItems = str_replace(
'</infoCompl>',
'<gItemPed><xItemPed>01</xItemPed><xItemPed>02</xItemPed><xItemPed>03</xItemPed></gItemPed></infoCompl>',
$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(
Expand Down
Loading