Skip to content

Commit a7e8d62

Browse files
authored
feat: fix XML element ordering and add totalTributosPercentual fields (#14)
* feat(dto): add totalTributosPercentual fields to DpsData Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> * feat(xml): fix element ordering and extract buildServico/buildValores methods Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> * test(xml): update and extend XmlBuilder tests for new structure Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> * test(client): fix cMun->cLocEmi assertion and extend emit coverage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --------- Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent c3562b5 commit a7e8d62

File tree

4 files changed

+262
-68
lines changed

4 files changed

+262
-68
lines changed

src/Dto/DpsData.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ public function __construct(
7272
/** Indicador de tributação total. */
7373
public int $indicadorTributacao = 0,
7474

75+
/** Percentual total estimado de tributos federais. */
76+
public string $totalTributosPercentualFederal = '',
77+
78+
/** Percentual total estimado de tributos estaduais. */
79+
public string $totalTributosPercentualEstadual = '',
80+
81+
/** Percentual total estimado de tributos municipais. */
82+
public string $totalTributosPercentualMunicipal = '',
83+
7584
/** Whether ISS is retained at source. */
7685
public bool $issRetido = false,
7786

src/Xml/XmlBuilder.php

Lines changed: 119 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public function buildDps(DpsData $dps): string
2525
$doc->preserveWhiteSpace = false;
2626
$doc->formatOutput = true;
2727

28+
$emissionDateTime = $this->formattedEmissionDateTime();
29+
$competenceDate = $dps->dataCompetencia ?: substr($emissionDateTime, 0, 10);
30+
2831
$root = $doc->createElementNS(self::XSD_NAMESPACE, 'DPS');
2932
$root->setAttribute('versao', self::DPS_VERSION);
3033
$root->setAttribute('xsi:schemaLocation', self::XSD_SCHEMA);
@@ -35,42 +38,27 @@ public function buildDps(DpsData $dps): string
3538
$infDps->setAttribute('Id', $this->buildIdentifier($dps));
3639
$root->appendChild($infDps);
3740

38-
// Municipality
39-
$cMun = $doc->createElement('cMun', $dps->municipioIbge);
40-
$infDps->appendChild($cMun);
41+
$infDps->appendChild($doc->createElement('tpAmb', (string) $dps->tipoAmbiente));
42+
$infDps->appendChild($doc->createElement('dhEmi', $emissionDateTime));
43+
$infDps->appendChild($doc->createElement('verAplic', $dps->versaoAplicativo));
44+
$infDps->appendChild($doc->createElement('serie', str_pad($dps->serie, 5, '0', STR_PAD_LEFT)));
45+
$infDps->appendChild($doc->createElement('nDPS', $dps->numeroDps));
46+
$infDps->appendChild($doc->createElement('dCompet', $competenceDate));
47+
$infDps->appendChild($doc->createElement('tpEmit', (string) $dps->tipoEmissao));
4148
$infDps->appendChild($doc->createElement('cLocEmi', $dps->municipioIbge));
4249

43-
// Prestador
4450
$prest = $doc->createElement('prest');
4551
$cnpj = $doc->createElement('CNPJ', $dps->cnpjPrestador);
4652
$prest->appendChild($cnpj);
4753
$prest->appendChild($this->buildRegTrib($doc, $dps));
4854
$infDps->appendChild($prest);
4955

50-
// Service block
51-
$serv = $doc->createElement('serv');
52-
53-
$itemListaServico = $doc->createElement('cServ');
54-
$itemListaServico->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional));
55-
$serv->appendChild($itemListaServico);
56-
57-
$serv->appendChild($doc->createElement('xDescServ', htmlspecialchars($dps->discriminacao, ENT_XML1)));
58-
$infDps->appendChild($serv);
59-
60-
// Tomador (optional — absent for foreign buyers with no document)
6156
if ($dps->documentoTomador !== '') {
6257
$infDps->appendChild($this->buildToma($doc, $dps));
6358
}
6459

65-
// Values
66-
$valores = $doc->createElement('valores');
67-
68-
$vServPrest = $doc->createElement('vServPrest');
69-
$vServPrest->appendChild($doc->createElement('vServ', $dps->valorServico));
70-
$valores->appendChild($vServPrest);
71-
72-
$valores->appendChild($this->buildTotTrib($doc, $dps));
73-
$infDps->appendChild($valores);
60+
$infDps->appendChild($this->buildServico($doc, $dps));
61+
$infDps->appendChild($this->buildValores($doc, $dps));
7462

7563
return $doc->saveXML() ?: '';
7664
}
@@ -85,35 +73,90 @@ private function buildIdentifier(DpsData $dps): string
8573
. str_pad($dps->numeroDps, 15, '0', STR_PAD_LEFT);
8674
}
8775

88-
private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
76+
private function buildValores(\DOMDocument $doc, DpsData $dps): \DOMElement
8977
{
90-
$totTrib = $doc->createElement('totTrib');
78+
$valores = $doc->createElement('valores');
9179

92-
// tribMun contains ISS and conditional pAliq
80+
$vServPrest = $doc->createElement('vServPrest');
81+
$vServPrest->appendChild($doc->createElement('vServ', $dps->valorServico));
82+
$valores->appendChild($vServPrest);
83+
84+
$trib = $doc->createElement('trib');
85+
$trib->appendChild($this->buildTribMun($doc, $dps));
86+
87+
if ($this->hasFederalTaxationData($dps)) {
88+
$trib->appendChild($this->buildTribFederal($doc, $dps));
89+
}
90+
91+
if ($this->hasTotalTributosPercentuais($dps)) {
92+
$trib->appendChild($this->buildTotTrib($doc, $dps));
93+
}
94+
95+
$valores->appendChild($trib);
96+
97+
return $valores;
98+
}
99+
100+
private function buildTribMun(\DOMDocument $doc, DpsData $dps): \DOMElement
101+
{
93102
$tribMun = $doc->createElement('tribMun');
94103
$tribMun->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1'));
95104
$tribMun->appendChild($doc->createElement('tpRetISSQN', (string) $dps->tipoRetencaoIss));
96105

97-
// E0617: For não optante (opSimpNac=1), pAliq must NOT be present
98106
if ($dps->opcaoSimplesNacional !== 1) {
99107
$tribMun->appendChild($doc->createElement('pAliq', $dps->aliquota));
100108
}
101109

102-
$totTrib->appendChild($tribMun);
110+
return $tribMun;
111+
}
103112

104-
if ($this->hasFederalTaxationData($dps)) {
105-
$totTrib->appendChild($this->buildTribFederal($doc, $dps));
113+
private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
114+
{
115+
$totTrib = $doc->createElement('totTrib');
116+
$percentuais = $doc->createElement('pTotTrib');
117+
118+
if ($dps->totalTributosPercentualFederal !== '') {
119+
$percentuais->appendChild($doc->createElement('pTotTribFed', $dps->totalTributosPercentualFederal));
120+
}
121+
122+
if ($dps->totalTributosPercentualEstadual !== '') {
123+
$percentuais->appendChild($doc->createElement('pTotTribEst', $dps->totalTributosPercentualEstadual));
124+
}
125+
126+
if ($dps->totalTributosPercentualMunicipal !== '') {
127+
$percentuais->appendChild($doc->createElement('pTotTribMun', $dps->totalTributosPercentualMunicipal));
106128
}
107129

108-
// E0715: indTotTrib is ALWAYS included to avoid schema validation errors
109-
$totTrib->appendChild($doc->createElement('indTotTrib', (string) $dps->indicadorTributacao));
130+
$totTrib->appendChild($percentuais);
110131

111132
return $totTrib;
112133
}
113134

135+
private function buildServico(\DOMDocument $doc, DpsData $dps): \DOMElement
136+
{
137+
$serv = $doc->createElement('serv');
138+
139+
$locPrest = $doc->createElement('locPrest');
140+
$locPrest->appendChild($doc->createElement('cLocPrestacao', $dps->municipioIbge));
141+
$serv->appendChild($locPrest);
142+
143+
$cServ = $doc->createElement('cServ');
144+
$cServ->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional));
145+
146+
if ($dps->itemListaServico !== '') {
147+
$cServ->appendChild($doc->createElement('cTribMun', $dps->itemListaServico));
148+
}
149+
150+
$cServ->appendChild($doc->createElement('xDescServ', htmlspecialchars($dps->discriminacao, ENT_XML1)));
151+
$serv->appendChild($cServ);
152+
153+
return $serv;
154+
}
155+
114156
private function buildRegTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
115157
{
116158
$regTrib = $doc->createElement('regTrib');
159+
$regTrib->appendChild($doc->createElement('opSimpNac', (string) $dps->opcaoSimplesNacional));
117160
$regTrib->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao));
118161

119162
return $regTrib;
@@ -141,33 +184,57 @@ private function buildToma(\DOMDocument $doc, DpsData $dps): \DOMElement
141184
private function buildTribFederal(\DOMDocument $doc, DpsData $dps): \DOMElement
142185
{
143186
$tribFed = $doc->createElement('tribFed');
187+
$piscofins = $doc->createElement('piscofins');
144188

145189
if ($dps->federalPiscofinsSituacaoTributaria !== '') {
146-
$tribFed->appendChild($doc->createElement('sitTribPISCOFINS', $dps->federalPiscofinsSituacaoTributaria));
190+
$piscofins->appendChild($doc->createElement('CST', str_pad($dps->federalPiscofinsSituacaoTributaria, 2, '0', STR_PAD_LEFT)));
191+
}
192+
193+
foreach ([
194+
'vBCPisCofins' => $dps->federalPiscofinsBaseCalculo,
195+
'pAliqPis' => $dps->federalPiscofinsAliquotaPis,
196+
'pAliqCofins' => $dps->federalPiscofinsAliquotaCofins,
197+
'vPis' => $dps->federalPiscofinsValorPis,
198+
'vCofins' => $dps->federalPiscofinsValorCofins,
199+
] as $tag => $value) {
200+
if ($value !== '') {
201+
$piscofins->appendChild($doc->createElement($tag, $value));
202+
}
147203
}
148204

149205
if ($dps->federalPiscofinsTipoRetencao !== '') {
150-
$tribFed->appendChild($doc->createElement('tpRetPISCOFINSCSLL', $dps->federalPiscofinsTipoRetencao));
206+
$piscofins->appendChild($doc->createElement('tpRetPisCofins', $dps->federalPiscofinsTipoRetencao));
207+
}
208+
209+
if ($piscofins->hasChildNodes()) {
210+
$tribFed->appendChild($piscofins);
151211
}
152212

153213
foreach ([
154-
'vBCPISCOFINS' => $dps->federalPiscofinsBaseCalculo,
155-
'pAliqPIS' => $dps->federalPiscofinsAliquotaPis,
156-
'vPIS' => $dps->federalPiscofinsValorPis,
157-
'pAliqCOFINS' => $dps->federalPiscofinsAliquotaCofins,
158-
'vCOFINS' => $dps->federalPiscofinsValorCofins,
159-
'vIRRF' => $dps->federalValorIrrf,
160-
'vCSLL' => $dps->federalValorCsll,
161-
'vCP' => $dps->federalValorCp,
214+
'vRetIRRF' => $dps->federalValorIrrf,
215+
'vRetCSLL' => $dps->federalValorCsll,
216+
'vRetCP' => $dps->federalValorCp,
162217
] as $tag => $value) {
163-
if ($value !== '') {
218+
if ($this->hasNonZeroDecimalValue($value)) {
164219
$tribFed->appendChild($doc->createElement($tag, $value));
165220
}
166221
}
167222

168223
return $tribFed;
169224
}
170225

226+
private function formattedEmissionDateTime(): string
227+
{
228+
return (new \DateTimeImmutable())->format('Y-m-d\\TH:i:sP');
229+
}
230+
231+
private function hasTotalTributosPercentuais(DpsData $dps): bool
232+
{
233+
return $dps->totalTributosPercentualFederal !== ''
234+
|| $dps->totalTributosPercentualEstadual !== ''
235+
|| $dps->totalTributosPercentualMunicipal !== '';
236+
}
237+
171238
private function hasFederalTaxationData(DpsData $dps): bool
172239
{
173240
return $dps->federalPiscofinsSituacaoTributaria !== ''
@@ -177,8 +244,13 @@ private function hasFederalTaxationData(DpsData $dps): bool
177244
|| $dps->federalPiscofinsValorPis !== ''
178245
|| $dps->federalPiscofinsAliquotaCofins !== ''
179246
|| $dps->federalPiscofinsValorCofins !== ''
180-
|| $dps->federalValorIrrf !== ''
181-
|| $dps->federalValorCsll !== ''
182-
|| $dps->federalValorCp !== '';
247+
|| $this->hasNonZeroDecimalValue($dps->federalValorIrrf)
248+
|| $this->hasNonZeroDecimalValue($dps->federalValorCsll)
249+
|| $this->hasNonZeroDecimalValue($dps->federalValorCp);
250+
}
251+
252+
private function hasNonZeroDecimalValue(string $value): bool
253+
{
254+
return $value !== '' && (float) $value !== 0.0;
183255
}
184256
}

tests/Unit/Http/NfseClientTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,50 @@ public function testEmitReturnsReceiptDataOnSuccess(): void
7979
self::assertSame('<NFS-e>ok</NFS-e>', $receipt->rawXml);
8080
}
8181

82+
public function testEmitBuildsXmlWithTpAmbBeforeMunicipalityFields(): void
83+
{
84+
$payload = json_encode([
85+
'nNFSe' => '100',
86+
'chaveAcesso' => 'tpamb-order-ok',
87+
'dataHoraProcessamento' => '2026-01-02T10:00:00',
88+
], JSON_THROW_ON_ERROR);
89+
90+
self::$server->setResponseOfPath(
91+
'/SefinNacional/nfse',
92+
new Response($payload, ['Content-Type' => 'application/json'], 201)
93+
);
94+
95+
$holder = new class () {
96+
public string $capturedXml = '';
97+
};
98+
99+
$capturingSigner = new class ($holder) implements XmlSignerInterface {
100+
public function __construct(private object $holder)
101+
{
102+
}
103+
104+
public function sign(string $xml, string $cnpj): string
105+
{
106+
$this->holder->capturedXml = $xml;
107+
108+
return $xml;
109+
}
110+
};
111+
112+
$client = $this->makeClient($capturingSigner);
113+
$client->emit($this->makeDps());
114+
115+
self::assertNotSame('', $holder->capturedXml);
116+
117+
$normalizedXml = str_replace(["\n", ' '], '', $holder->capturedXml);
118+
$tpAmbIndex = strpos($normalizedXml, '<tpAmb>2</tpAmb>');
119+
$cLocEmiIndex = strpos($normalizedXml, '<cLocEmi>3303302</cLocEmi>');
120+
121+
self::assertNotFalse($tpAmbIndex);
122+
self::assertNotFalse($cLocEmiIndex);
123+
self::assertLessThan($cLocEmiIndex, $tpAmbIndex);
124+
}
125+
82126
public function testQueryReturnsReceiptDataOnSuccess(): void
83127
{
84128
$payload = json_encode([

0 commit comments

Comments
 (0)