Skip to content

Commit dc7e74e

Browse files
authored
Merge pull request #12 from LibreCodeCoop/test/legacy-pfx-cli-fallback
fix: align client config contracts and national tax code mapping
2 parents dda0661 + 4df1856 commit dc7e74e

File tree

9 files changed

+299
-53
lines changed

9 files changed

+299
-53
lines changed

src/Config/CertConfig.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ public function __construct(
2525

2626
/** OpenBao KV path for the PFX password (e.g. "secret/nfse/29842527000145"). */
2727
public string $vaultPath,
28+
29+
/** Optional PEM certificate path used for mTLS transport. */
30+
public ?string $transportCertificatePath = null,
31+
32+
/** Optional PEM private key path used for mTLS transport. */
33+
public ?string $transportPrivateKeyPath = null,
2834
) {
2935
}
3036
}

src/Config/EnvironmentConfig.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
* When no custom base URL is supplied the appropriate official endpoint is
1414
* selected automatically from the sandboxMode flag:
1515
*
16-
* - Production: https://nfse.fazenda.gov.br/NFS-e/api/v1
17-
* - Sandbox: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
16+
* - Production: https://sefin.nfse.gov.br/SefinNacional
17+
* - Sandbox: https://sefin.producaorestrita.nfse.gov.br/SefinNacional
1818
*/
1919
final readonly class EnvironmentConfig
2020
{
21-
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
22-
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';
21+
private const BASE_URL_PROD = 'https://sefin.nfse.gov.br/SefinNacional';
22+
private const BASE_URL_SANDBOX = 'https://sefin.producaorestrita.nfse.gov.br/SefinNacional';
2323

2424
public string $baseUrl;
2525

src/Dto/DpsData.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,44 @@ public function __construct(
3333
/** Descrição do serviço prestado. */
3434
public string $discriminacao,
3535

36+
/** Tipo de ambiente (1-Produção | 2-Homologação). */
37+
public int $tipoAmbiente = 2,
38+
39+
/** Application version string written into the DPS. */
40+
public string $versaoAplicativo = 'akaunting-nfse',
41+
42+
/** Série do DPS (1-5 digits). */
43+
public string $serie = '00001',
44+
45+
/** Número sequencial do DPS. */
46+
public string $numeroDps = '1',
47+
48+
/** Competence date in YYYY-MM-DD format. Defaults to emission date when null. */
49+
public ?string $dataCompetencia = null,
50+
51+
/** Tipo de emissão do DPS. */
52+
public int $tipoEmissao = 1,
53+
54+
/** Código de tributação nacional do serviço (6 digits). */
55+
public string $codigoTributacaoNacional = '000000',
56+
3657
/** CNPJ ou CPF do tomador (only digits, 11 or 14 chars). Empty string for foreign. */
3758
public string $documentoTomador = '',
3859

3960
/** Nome / Razão Social do tomador. */
4061
public string $nomeTomador = '',
4162

42-
/** Regime especial de tributação (optional). */
43-
public ?int $regimeEspecialTributacao = null,
63+
/** Whether the provider opts into Simples Nacional. */
64+
public int $opcaoSimplesNacional = 1,
65+
66+
/** Regime especial de tributação. */
67+
public int $regimeEspecialTributacao = 0,
68+
69+
/** Tipo de retenção do ISSQN. */
70+
public int $tipoRetencaoIss = 1,
71+
72+
/** Indicador de tributação total. */
73+
public int $indicadorTributacao = 0,
4474

4575
/** Whether ISS is retained at source. */
4676
public bool $issRetido = false,

src/Http/NfseClient.php

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function emit(DpsData $dps): ReceiptData
4848
$xml = (new XmlBuilder())->buildDps($dps);
4949
$signed = $this->signer->sign($xml, $dps->cnpjPrestador);
5050

51-
[$httpStatus, $body] = $this->post('/dps', $signed);
51+
[$httpStatus, $body] = $this->post('/nfse', $signed);
5252

5353
if ($httpStatus >= 400) {
5454
throw new IssuanceException(
@@ -64,7 +64,7 @@ public function emit(DpsData $dps): ReceiptData
6464

6565
public function query(string $chaveAcesso): ReceiptData
6666
{
67-
[$httpStatus, $body] = $this->get('/dps/' . $chaveAcesso);
67+
[$httpStatus, $body] = $this->get('/nfse/' . $chaveAcesso);
6868

6969
if ($httpStatus >= 400) {
7070
throw new QueryException(
@@ -103,13 +103,24 @@ public function cancel(string $chaveAcesso, string $motivo): bool
103103
*/
104104
private function post(string $path, string $xmlPayload): array
105105
{
106+
$compressedPayload = gzencode($xmlPayload);
107+
108+
if ($compressedPayload === false) {
109+
throw new NetworkException('Failed to compress DPS XML payload before transmission.');
110+
}
111+
112+
$payload = json_encode([
113+
'dpsXmlGZipB64' => base64_encode($compressedPayload),
114+
], JSON_THROW_ON_ERROR);
115+
106116
$context = stream_context_create([
107117
'http' => [
108118
'method' => 'POST',
109-
'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n",
110-
'content' => $xmlPayload,
119+
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
120+
'content' => $payload,
111121
'ignore_errors' => true,
112122
],
123+
'ssl' => $this->sslContextOptions(),
113124
]);
114125

115126
return $this->fetchAndDecode($path, $context);
@@ -126,6 +137,7 @@ private function get(string $path): array
126137
'header' => "Accept: application/json\r\n",
127138
'ignore_errors' => true,
128139
],
140+
'ssl' => $this->sslContextOptions(),
129141
]);
130142

131143
return $this->fetchAndDecode($path, $context);
@@ -144,11 +156,30 @@ private function delete(string $path, string $motivo): array
144156
'content' => $payload,
145157
'ignore_errors' => true,
146158
],
159+
'ssl' => $this->sslContextOptions(),
147160
]);
148161

149162
return $this->fetchAndDecode($path, $context);
150163
}
151164

165+
/**
166+
* @return array<string, bool|string>
167+
*/
168+
private function sslContextOptions(): array
169+
{
170+
$options = [
171+
'verify_peer' => true,
172+
'verify_peer_name' => true,
173+
];
174+
175+
if ($this->cert->transportCertificatePath !== null && $this->cert->transportPrivateKeyPath !== null) {
176+
$options['local_cert'] = $this->cert->transportCertificatePath;
177+
$options['local_pk'] = $this->cert->transportPrivateKeyPath;
178+
}
179+
180+
return $options;
181+
}
182+
152183
/**
153184
* Perform the raw HTTP request and decode the JSON body.
154185
*
@@ -206,12 +237,26 @@ private function parseHttpStatus(array $headers): int
206237
*/
207238
private function parseReceiptResponse(array $response): ReceiptData
208239
{
240+
$rawXml = null;
241+
242+
if (isset($response['nfseXmlGZipB64']) && is_string($response['nfseXmlGZipB64'])) {
243+
$decodedXml = base64_decode($response['nfseXmlGZipB64'], true);
244+
245+
if ($decodedXml !== false) {
246+
$inflatedXml = gzdecode($decodedXml);
247+
248+
if ($inflatedXml !== false) {
249+
$rawXml = $inflatedXml;
250+
}
251+
}
252+
}
253+
209254
return new ReceiptData(
210255
nfseNumber: (string) ($response['nNFSe'] ?? $response['numero'] ?? ''),
211-
chaveAcesso: (string) ($response['chaveAcesso'] ?? $response['id'] ?? ''),
212-
dataEmissao: (string) ($response['dhEmi'] ?? $response['dataEmissao'] ?? ''),
256+
chaveAcesso: (string) ($response['chaveAcesso'] ?? ''),
257+
dataEmissao: (string) ($response['dhEmi'] ?? $response['dataHoraProcessamento'] ?? $response['dataEmissao'] ?? ''),
213258
codigoVerificacao: isset($response['codigoVerificacao']) ? (string) $response['codigoVerificacao'] : null,
214-
rawXml: null,
259+
rawXml: $rawXml,
215260
);
216261
}
217262
}

src/Xml/XmlBuilder.php

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class XmlBuilder
1717
{
1818
private const XSD_NAMESPACE = 'http://www.sped.fazenda.gov.br/nfse';
1919
private const XSD_SCHEMA = 'http://www.sped.fazenda.gov.br/nfse tiDPS_v1.00.xsd';
20+
private const DPS_VERSION = '1.01';
2021

2122
public function buildDps(DpsData $dps): string
2223
{
@@ -25,29 +26,32 @@ public function buildDps(DpsData $dps): string
2526
$doc->formatOutput = true;
2627

2728
$root = $doc->createElementNS(self::XSD_NAMESPACE, 'DPS');
29+
$root->setAttribute('versao', self::DPS_VERSION);
2830
$root->setAttribute('xsi:schemaLocation', self::XSD_SCHEMA);
2931
$root->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
3032
$doc->appendChild($root);
3133

3234
$infDps = $doc->createElement('infDPS');
33-
$infDps->setAttribute('Id', 'DPS' . $dps->cnpjPrestador . date('YmdHis'));
35+
$infDps->setAttribute('Id', $this->buildIdentifier($dps));
3436
$root->appendChild($infDps);
3537

3638
// Municipality
3739
$cMun = $doc->createElement('cMun', $dps->municipioIbge);
3840
$infDps->appendChild($cMun);
41+
$infDps->appendChild($doc->createElement('cLocEmi', $dps->municipioIbge));
3942

4043
// Prestador
4144
$prest = $doc->createElement('prest');
4245
$cnpj = $doc->createElement('CNPJ', $dps->cnpjPrestador);
4346
$prest->appendChild($cnpj);
47+
$prest->appendChild($this->buildRegTrib($doc, $dps));
4448
$infDps->appendChild($prest);
4549

4650
// Service block
4751
$serv = $doc->createElement('serv');
4852

4953
$itemListaServico = $doc->createElement('cServ');
50-
$itemListaServico->appendChild($doc->createElement('cTribNac', $dps->itemListaServico));
54+
$itemListaServico->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional));
5155
$serv->appendChild($itemListaServico);
5256

5357
$serv->appendChild($doc->createElement('xDescServ', htmlspecialchars($dps->discriminacao, ENT_XML1)));
@@ -60,25 +64,55 @@ public function buildDps(DpsData $dps): string
6064

6165
// Values
6266
$valores = $doc->createElement('valores');
63-
$valores->appendChild($doc->createElement('vServ', $dps->valorServico));
64-
$valores->appendChild($this->buildTrib($doc, $dps));
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));
6573
$infDps->appendChild($valores);
6674

67-
// Regime especial de tributação (optional)
68-
if ($dps->regimeEspecialTributacao !== null) {
69-
$infDps->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao));
75+
return $doc->saveXML() ?: '';
76+
}
77+
78+
private function buildIdentifier(DpsData $dps): string
79+
{
80+
return 'DPS'
81+
. $dps->municipioIbge
82+
. $dps->tipoAmbiente
83+
. $dps->cnpjPrestador
84+
. str_pad($dps->serie, 5, '0', STR_PAD_LEFT)
85+
. str_pad($dps->numeroDps, 15, '0', STR_PAD_LEFT);
86+
}
87+
88+
private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
89+
{
90+
$totTrib = $doc->createElement('totTrib');
91+
92+
// tribMun contains ISS and conditional pAliq
93+
$tribMun = $doc->createElement('tribMun');
94+
$tribMun->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1'));
95+
$tribMun->appendChild($doc->createElement('tpRetISSQN', (string) $dps->tipoRetencaoIss));
96+
97+
// E0617: For não optante (opSimpNac=1), pAliq must NOT be present
98+
if ($dps->opcaoSimplesNacional !== 1) {
99+
$tribMun->appendChild($doc->createElement('pAliq', $dps->aliquota));
70100
}
71101

72-
return $doc->saveXML() ?: '';
102+
$totTrib->appendChild($tribMun);
103+
104+
// E0715: indTotTrib is ALWAYS included to avoid schema validation errors
105+
$totTrib->appendChild($doc->createElement('indTotTrib', (string) $dps->indicadorTributacao));
106+
107+
return $totTrib;
73108
}
74109

75-
private function buildTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
110+
private function buildRegTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
76111
{
77-
$trib = $doc->createElement('tribMun');
78-
$trib->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1'));
79-
$trib->appendChild($doc->createElement('pAliq', $dps->aliquota));
112+
$regTrib = $doc->createElement('regTrib');
113+
$regTrib->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao));
80114

81-
return $trib;
115+
return $regTrib;
82116
}
83117

84118
private function buildToma(\DOMDocument $doc, DpsData $dps): \DOMElement

tests/Unit/Config/CertConfigTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ public function testStoresAllProperties(): void
2121
cnpj: '29842527000145',
2222
pfxPath: '/etc/nfse/certs/company.pfx',
2323
vaultPath: 'secret/nfse/29842527000145',
24+
transportCertificatePath: '/etc/nfse/certs/client.crt.pem',
25+
transportPrivateKeyPath: '/etc/nfse/certs/client.key.pem',
2426
);
2527

2628
self::assertSame('29842527000145', $config->cnpj);
2729
self::assertSame('/etc/nfse/certs/company.pfx', $config->pfxPath);
2830
self::assertSame('secret/nfse/29842527000145', $config->vaultPath);
31+
self::assertSame('/etc/nfse/certs/client.crt.pem', $config->transportCertificatePath);
32+
self::assertSame('/etc/nfse/certs/client.key.pem', $config->transportPrivateKeyPath);
2933
}
3034

3135
public function testCnpjIsReadonly(): void
@@ -66,4 +70,32 @@ public function testVaultPathIsReadonly(): void
6670
/** @phpstan-ignore-next-line */
6771
$config->vaultPath = 'other';
6872
}
73+
74+
public function testTransportCertificatePathIsReadonly(): void
75+
{
76+
$config = new CertConfig(
77+
cnpj: '29842527000145',
78+
pfxPath: '/etc/nfse/certs/company.pfx',
79+
vaultPath: 'secret/nfse/29842527000145',
80+
transportCertificatePath: '/etc/nfse/certs/client.crt.pem',
81+
);
82+
83+
$this->expectException(\Error::class);
84+
/** @phpstan-ignore-next-line */
85+
$config->transportCertificatePath = 'other';
86+
}
87+
88+
public function testTransportPrivateKeyPathIsReadonly(): void
89+
{
90+
$config = new CertConfig(
91+
cnpj: '29842527000145',
92+
pfxPath: '/etc/nfse/certs/company.pfx',
93+
vaultPath: 'secret/nfse/29842527000145',
94+
transportPrivateKeyPath: '/etc/nfse/certs/client.key.pem',
95+
);
96+
97+
$this->expectException(\Error::class);
98+
/** @phpstan-ignore-next-line */
99+
$config->transportPrivateKeyPath = 'other';
100+
}
69101
}

tests/Unit/Config/EnvironmentConfigTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function testDefaultsToProductionUrl(): void
2121

2222
self::assertFalse($config->sandboxMode);
2323
self::assertSame(
24-
'https://nfse.fazenda.gov.br/NFS-e/api/v1',
24+
'https://sefin.nfse.gov.br/SefinNacional',
2525
$config->baseUrl,
2626
);
2727
}
@@ -32,14 +32,14 @@ public function testSandboxModeSelectsSandboxUrl(): void
3232

3333
self::assertTrue($config->sandboxMode);
3434
self::assertSame(
35-
'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1',
35+
'https://sefin.producaorestrita.nfse.gov.br/SefinNacional',
3636
$config->baseUrl,
3737
);
3838
}
3939

4040
public function testCustomBaseUrlOverridesMode(): void
4141
{
42-
$custom = 'http://localhost:8080/NFS-e/api/v1';
42+
$custom = 'http://localhost:8080/SefinNacional';
4343
$config = new EnvironmentConfig(sandboxMode: false, baseUrl: $custom);
4444

4545
self::assertFalse($config->sandboxMode);
@@ -48,7 +48,7 @@ public function testCustomBaseUrlOverridesMode(): void
4848

4949
public function testCustomBaseUrlOverridesSandboxUrl(): void
5050
{
51-
$custom = 'http://mock-server/NFS-e/api/v1';
51+
$custom = 'http://mock-server/SefinNacional';
5252
$config = new EnvironmentConfig(sandboxMode: true, baseUrl: $custom);
5353

5454
self::assertSame($custom, $config->baseUrl);

0 commit comments

Comments
 (0)