Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/Config/CertConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public function __construct(

/** OpenBao KV path for the PFX password (e.g. "secret/nfse/29842527000145"). */
public string $vaultPath,

/** Optional PEM certificate path used for mTLS transport. */
public ?string $transportCertificatePath = null,

/** Optional PEM private key path used for mTLS transport. */
public ?string $transportPrivateKeyPath = null,
) {
}
}
8 changes: 4 additions & 4 deletions src/Config/EnvironmentConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
* When no custom base URL is supplied the appropriate official endpoint is
* selected automatically from the sandboxMode flag:
*
* - Production: https://nfse.fazenda.gov.br/NFS-e/api/v1
* - Sandbox: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
* - Production: https://sefin.nfse.gov.br/SefinNacional
* - Sandbox: https://sefin.producaorestrita.nfse.gov.br/SefinNacional
*/
final readonly class EnvironmentConfig
{
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';
private const BASE_URL_PROD = 'https://sefin.nfse.gov.br/SefinNacional';
private const BASE_URL_SANDBOX = 'https://sefin.producaorestrita.nfse.gov.br/SefinNacional';

public string $baseUrl;

Expand Down
34 changes: 32 additions & 2 deletions src/Dto/DpsData.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,44 @@ public function __construct(
/** Descrição do serviço prestado. */
public string $discriminacao,

/** Tipo de ambiente (1-Produção | 2-Homologação). */
public int $tipoAmbiente = 2,

/** Application version string written into the DPS. */
public string $versaoAplicativo = 'akaunting-nfse',

/** Série do DPS (1-5 digits). */
public string $serie = '00001',

/** Número sequencial do DPS. */
public string $numeroDps = '1',

/** Competence date in YYYY-MM-DD format. Defaults to emission date when null. */
public ?string $dataCompetencia = null,

/** Tipo de emissão do DPS. */
public int $tipoEmissao = 1,

/** Código de tributação nacional do serviço (6 digits). */
public string $codigoTributacaoNacional = '000000',

/** CNPJ ou CPF do tomador (only digits, 11 or 14 chars). Empty string for foreign. */
public string $documentoTomador = '',

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

/** Regime especial de tributação (optional). */
public ?int $regimeEspecialTributacao = null,
/** Whether the provider opts into Simples Nacional. */
public int $opcaoSimplesNacional = 1,

/** Regime especial de tributação. */
public int $regimeEspecialTributacao = 0,

/** Tipo de retenção do ISSQN. */
public int $tipoRetencaoIss = 1,

/** Indicador de tributação total. */
public int $indicadorTributacao = 0,

/** Whether ISS is retained at source. */
public bool $issRetido = false,
Expand Down
59 changes: 52 additions & 7 deletions src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function emit(DpsData $dps): ReceiptData
$xml = (new XmlBuilder())->buildDps($dps);
$signed = $this->signer->sign($xml, $dps->cnpjPrestador);

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

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

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

if ($httpStatus >= 400) {
throw new QueryException(
Expand Down Expand Up @@ -103,13 +103,24 @@ public function cancel(string $chaveAcesso, string $motivo): bool
*/
private function post(string $path, string $xmlPayload): array
{
$compressedPayload = gzencode($xmlPayload);

if ($compressedPayload === false) {
throw new NetworkException('Failed to compress DPS XML payload before transmission.');
}

$payload = json_encode([
'dpsXmlGZipB64' => base64_encode($compressedPayload),
], JSON_THROW_ON_ERROR);

$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n",
'content' => $xmlPayload,
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'ignore_errors' => true,
],
'ssl' => $this->sslContextOptions(),
]);

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

return $this->fetchAndDecode($path, $context);
Expand All @@ -144,11 +156,30 @@ private function delete(string $path, string $motivo): array
'content' => $payload,
'ignore_errors' => true,
],
'ssl' => $this->sslContextOptions(),
]);

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

/**
* @return array<string, bool|string>
*/
private function sslContextOptions(): array
{
$options = [
'verify_peer' => true,
'verify_peer_name' => true,
];

if ($this->cert->transportCertificatePath !== null && $this->cert->transportPrivateKeyPath !== null) {
$options['local_cert'] = $this->cert->transportCertificatePath;
$options['local_pk'] = $this->cert->transportPrivateKeyPath;
}

return $options;
}

/**
* Perform the raw HTTP request and decode the JSON body.
*
Expand Down Expand Up @@ -206,12 +237,26 @@ private function parseHttpStatus(array $headers): int
*/
private function parseReceiptResponse(array $response): ReceiptData
{
$rawXml = null;

if (isset($response['nfseXmlGZipB64']) && is_string($response['nfseXmlGZipB64'])) {
$decodedXml = base64_decode($response['nfseXmlGZipB64'], true);

if ($decodedXml !== false) {
$inflatedXml = gzdecode($decodedXml);

if ($inflatedXml !== false) {
$rawXml = $inflatedXml;
}
}
}

return new ReceiptData(
nfseNumber: (string) ($response['nNFSe'] ?? $response['numero'] ?? ''),
chaveAcesso: (string) ($response['chaveAcesso'] ?? $response['id'] ?? ''),
dataEmissao: (string) ($response['dhEmi'] ?? $response['dataEmissao'] ?? ''),
chaveAcesso: (string) ($response['chaveAcesso'] ?? ''),
dataEmissao: (string) ($response['dhEmi'] ?? $response['dataHoraProcessamento'] ?? $response['dataEmissao'] ?? ''),
codigoVerificacao: isset($response['codigoVerificacao']) ? (string) $response['codigoVerificacao'] : null,
rawXml: null,
rawXml: $rawXml,
);
}
}
60 changes: 47 additions & 13 deletions src/Xml/XmlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class XmlBuilder
{
private const XSD_NAMESPACE = 'http://www.sped.fazenda.gov.br/nfse';
private const XSD_SCHEMA = 'http://www.sped.fazenda.gov.br/nfse tiDPS_v1.00.xsd';
private const DPS_VERSION = '1.01';

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

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

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

// Municipality
$cMun = $doc->createElement('cMun', $dps->municipioIbge);
$infDps->appendChild($cMun);
$infDps->appendChild($doc->createElement('cLocEmi', $dps->municipioIbge));

// Prestador
$prest = $doc->createElement('prest');
$cnpj = $doc->createElement('CNPJ', $dps->cnpjPrestador);
$prest->appendChild($cnpj);
$prest->appendChild($this->buildRegTrib($doc, $dps));
$infDps->appendChild($prest);

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

$itemListaServico = $doc->createElement('cServ');
$itemListaServico->appendChild($doc->createElement('cTribNac', $dps->itemListaServico));
$itemListaServico->appendChild($doc->createElement('cTribNac', $dps->codigoTributacaoNacional));
$serv->appendChild($itemListaServico);

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

// Values
$valores = $doc->createElement('valores');
$valores->appendChild($doc->createElement('vServ', $dps->valorServico));
$valores->appendChild($this->buildTrib($doc, $dps));

$vServPrest = $doc->createElement('vServPrest');
$vServPrest->appendChild($doc->createElement('vServ', $dps->valorServico));
$valores->appendChild($vServPrest);

$valores->appendChild($this->buildTotTrib($doc, $dps));
$infDps->appendChild($valores);

// Regime especial de tributação (optional)
if ($dps->regimeEspecialTributacao !== null) {
$infDps->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao));
return $doc->saveXML() ?: '';
}

private function buildIdentifier(DpsData $dps): string
{
return 'DPS'
. $dps->municipioIbge
. $dps->tipoAmbiente
. $dps->cnpjPrestador
. str_pad($dps->serie, 5, '0', STR_PAD_LEFT)
. str_pad($dps->numeroDps, 15, '0', STR_PAD_LEFT);
}

private function buildTotTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
{
$totTrib = $doc->createElement('totTrib');

// tribMun contains ISS and conditional pAliq
$tribMun = $doc->createElement('tribMun');
$tribMun->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1'));
$tribMun->appendChild($doc->createElement('tpRetISSQN', (string) $dps->tipoRetencaoIss));

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

return $doc->saveXML() ?: '';
$totTrib->appendChild($tribMun);

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

return $totTrib;
}

private function buildTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
private function buildRegTrib(\DOMDocument $doc, DpsData $dps): \DOMElement
{
$trib = $doc->createElement('tribMun');
$trib->appendChild($doc->createElement('tribISSQN', $dps->issRetido ? '2' : '1'));
$trib->appendChild($doc->createElement('pAliq', $dps->aliquota));
$regTrib = $doc->createElement('regTrib');
$regTrib->appendChild($doc->createElement('regEspTrib', (string) $dps->regimeEspecialTributacao));

return $trib;
return $regTrib;
}

private function buildToma(\DOMDocument $doc, DpsData $dps): \DOMElement
Expand Down
32 changes: 32 additions & 0 deletions tests/Unit/Config/CertConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ public function testStoresAllProperties(): void
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
transportCertificatePath: '/etc/nfse/certs/client.crt.pem',
transportPrivateKeyPath: '/etc/nfse/certs/client.key.pem',
);

self::assertSame('29842527000145', $config->cnpj);
self::assertSame('/etc/nfse/certs/company.pfx', $config->pfxPath);
self::assertSame('secret/nfse/29842527000145', $config->vaultPath);
self::assertSame('/etc/nfse/certs/client.crt.pem', $config->transportCertificatePath);
self::assertSame('/etc/nfse/certs/client.key.pem', $config->transportPrivateKeyPath);
}

public function testCnpjIsReadonly(): void
Expand Down Expand Up @@ -66,4 +70,32 @@ public function testVaultPathIsReadonly(): void
/** @phpstan-ignore-next-line */
$config->vaultPath = 'other';
}

public function testTransportCertificatePathIsReadonly(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
transportCertificatePath: '/etc/nfse/certs/client.crt.pem',
);

$this->expectException(\Error::class);
/** @phpstan-ignore-next-line */
$config->transportCertificatePath = 'other';
}

public function testTransportPrivateKeyPathIsReadonly(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
transportPrivateKeyPath: '/etc/nfse/certs/client.key.pem',
);

$this->expectException(\Error::class);
/** @phpstan-ignore-next-line */
$config->transportPrivateKeyPath = 'other';
}
}
8 changes: 4 additions & 4 deletions tests/Unit/Config/EnvironmentConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public function testDefaultsToProductionUrl(): void

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

self::assertTrue($config->sandboxMode);
self::assertSame(
'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1',
'https://sefin.producaorestrita.nfse.gov.br/SefinNacional',
$config->baseUrl,
);
}

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

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

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

self::assertSame($custom, $config->baseUrl);
Expand Down
Loading
Loading