diff --git a/src/Config/CertConfig.php b/src/Config/CertConfig.php index 5a0df1d..1037b9e 100644 --- a/src/Config/CertConfig.php +++ b/src/Config/CertConfig.php @@ -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, ) { } } diff --git a/src/Config/EnvironmentConfig.php b/src/Config/EnvironmentConfig.php index d25bd66..81fb94e 100644 --- a/src/Config/EnvironmentConfig.php +++ b/src/Config/EnvironmentConfig.php @@ -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; diff --git a/src/Dto/DpsData.php b/src/Dto/DpsData.php index ba64c79..40be054 100644 --- a/src/Dto/DpsData.php +++ b/src/Dto/DpsData.php @@ -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, diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 5b8d2c1..9a333f3 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -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( @@ -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( @@ -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); @@ -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); @@ -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 + */ + 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. * @@ -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, ); } } diff --git a/src/Xml/XmlBuilder.php b/src/Xml/XmlBuilder.php index a9939cc..fe162a6 100644 --- a/src/Xml/XmlBuilder.php +++ b/src/Xml/XmlBuilder.php @@ -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 { @@ -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))); @@ -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 diff --git a/tests/Unit/Config/CertConfigTest.php b/tests/Unit/Config/CertConfigTest.php index ad09eb4..8ac2e6b 100644 --- a/tests/Unit/Config/CertConfigTest.php +++ b/tests/Unit/Config/CertConfigTest.php @@ -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 @@ -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'; + } } diff --git a/tests/Unit/Config/EnvironmentConfigTest.php b/tests/Unit/Config/EnvironmentConfigTest.php index a5fdc35..7d5eba8 100644 --- a/tests/Unit/Config/EnvironmentConfigTest.php +++ b/tests/Unit/Config/EnvironmentConfigTest.php @@ -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, ); } @@ -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); @@ -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); diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index 6d720c8..f566c6f 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -57,14 +57,15 @@ public function sign(string $xml, string $cnpj): string public function testEmitReturnsReceiptDataOnSuccess(): void { $payload = json_encode([ - 'nNFSe' => '42', - 'chaveAcesso' => 'abc-123', - 'dhEmi' => '2026-01-01T12:00:00', + 'nNFSe' => '42', + 'chaveAcesso' => 'abc-123', + 'dataHoraProcessamento' => '2026-01-01T12:00:00', + 'nfseXmlGZipB64' => base64_encode(gzencode('ok')), ], JSON_THROW_ON_ERROR); self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps', - new Response($payload, ['Content-Type' => 'application/json'], 200) + '/SefinNacional/nfse', + new Response($payload, ['Content-Type' => 'application/json'], 201) ); $client = $this->makeClient($this->signer); @@ -75,6 +76,7 @@ public function testEmitReturnsReceiptDataOnSuccess(): void self::assertSame('42', $receipt->nfseNumber); self::assertSame('abc-123', $receipt->chaveAcesso); self::assertSame('2026-01-01T12:00:00', $receipt->dataEmissao); + self::assertSame('ok', $receipt->rawXml); } public function testQueryReturnsReceiptDataOnSuccess(): void @@ -86,7 +88,7 @@ public function testQueryReturnsReceiptDataOnSuccess(): void ], JSON_THROW_ON_ERROR); self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/xyz-456', + '/SefinNacional/nfse/xyz-456', new Response($payload, ['Content-Type' => 'application/json'], 200) ); @@ -100,7 +102,7 @@ public function testQueryReturnsReceiptDataOnSuccess(): void public function testCancelReturnsTrueOnSuccess(): void { self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/abc-123', + '/SefinNacional/dps/abc-123', new Response('{}', ['Content-Type' => 'application/json'], 200) ); @@ -118,7 +120,7 @@ public function testEmitThrowsIssuanceExceptionWhenGatewayRejects(): void $payload = json_encode(['codigo' => 'E422', 'mensagem' => 'CNPJ inválido'], JSON_THROW_ON_ERROR); self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps', + '/SefinNacional/nfse', new Response($payload, ['Content-Type' => 'application/json'], 422), ); @@ -133,7 +135,7 @@ public function testIssuanceExceptionCarriesErrorCodeHttpStatusAndUpstreamPayloa $errorData = ['codigo' => 'E422', 'mensagem' => 'CNPJ inválido']; self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps', + '/SefinNacional/nfse', new Response(json_encode($errorData, JSON_THROW_ON_ERROR), ['Content-Type' => 'application/json'], 422), ); @@ -152,7 +154,7 @@ public function testIssuanceExceptionCarriesErrorCodeHttpStatusAndUpstreamPayloa public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void { self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/missing-key', + '/SefinNacional/nfse/missing-key', new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), ); @@ -165,7 +167,7 @@ public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void { self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/missing-key', + '/SefinNacional/nfse/missing-key', new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), ); @@ -183,7 +185,7 @@ public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError(): void { self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/blocked-key', + '/SefinNacional/dps/blocked-key', new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), ); @@ -196,7 +198,7 @@ public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError(): public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void { self::$server->setResponseOfPath( - '/NFS-e/api/v1/dps/blocked-key', + '/SefinNacional/dps/blocked-key', new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), ); @@ -217,12 +219,14 @@ private function makeClient(?XmlSignerInterface $signer = null): NfseClient { return new NfseClient( environment: new EnvironmentConfig( - baseUrl: self::$server->getServerRoot() . '/NFS-e/api/v1', + baseUrl: self::$server->getServerRoot() . '/SefinNacional', ), cert: new CertConfig( cnpj: '29842527000145', pfxPath: '/dev/null', vaultPath: 'secret/nfse/29842527000145', + transportCertificatePath: '/tmp/client.crt.pem', + transportPrivateKeyPath: '/tmp/client.key.pem', ), secretStore: new NoOpSecretStore(), signer: $signer, diff --git a/tests/Unit/Xml/XmlBuilderTest.php b/tests/Unit/Xml/XmlBuilderTest.php index d5d6bf1..57b6345 100644 --- a/tests/Unit/Xml/XmlBuilderTest.php +++ b/tests/Unit/Xml/XmlBuilderTest.php @@ -34,6 +34,29 @@ public function testBuildDpsReturnsWellFormedXml(): void self::assertTrue($doc->loadXML($xml), 'Generated XML must be valid XML'); } + public function testBuildDpsSetsSchemaVersionAttribute(): void + { + $xml = $this->builder->buildDps($this->makeDps()); + + $doc = new \DOMDocument(); + $doc->loadXML($xml); + + self::assertSame('1.01', $doc->documentElement?->getAttribute('versao')); + } + + public function testBuildDpsUsesOfficialIdentifierShape(): void + { + $xml = $this->builder->buildDps($this->makeDps(cnpjPrestador: '11222333000181', municipioIbge: '3303302', serie: '12', numeroDps: '345')); + + $doc = new \DOMDocument(); + $doc->loadXML($xml); + + self::assertSame( + 'DPS330330221122233300018100012000000000000345', + $doc->getElementsByTagName('infDPS')->item(0)?->attributes?->getNamedItem('Id')?->nodeValue, + ); + } + public function testBuildDpsContainsCnpjPrestador(): void { $dps = $this->makeDps(cnpjPrestador: '11222333000181'); @@ -48,6 +71,15 @@ public function testBuildDpsContainsMunicipioIbge(): void $xml = $this->builder->buildDps($dps); self::assertStringContainsString('3303302', $xml); + self::assertStringContainsString('3303302', $xml); + } + + public function testBuildDpsUsesNationalTaxCodeInCtribnac(): void + { + $dps = $this->makeDps(itemListaServico: '0107', codigoTributacaoNacional: '101011'); + $xml = $this->builder->buildDps($dps); + + self::assertStringContainsString('101011', str_replace(["\n", ' '], '', $xml)); } public function testBuildDpsContainsValorServico(): void @@ -56,6 +88,7 @@ public function testBuildDpsContainsValorServico(): void $xml = $this->builder->buildDps($dps); self::assertStringContainsString('1500.00', $xml); + self::assertStringContainsString('1500.00', str_replace(["\n", ' '], '', $xml)); } public function testDiscriminacaoIsXmlEscaped(): void @@ -70,14 +103,16 @@ public function testDiscriminacaoIsXmlEscaped(): void public function testIssRetidoSetsTribCode(): void { - $dpsRetido = $this->makeDps(issRetido: true); - $dpsProprio = $this->makeDps(issRetido: false); + $dpsRetido = $this->makeDps(issRetido: true, tipoRetencaoIss: 2); + $dpsProprio = $this->makeDps(issRetido: false, tipoRetencaoIss: 1); $xmlRetido = $this->builder->buildDps($dpsRetido); $xmlProprio = $this->builder->buildDps($dpsProprio); self::assertStringContainsString('2', $xmlRetido); self::assertStringContainsString('1', $xmlProprio); + self::assertStringContainsString('2', $xmlRetido); + self::assertStringContainsString('1', $xmlProprio); } // ------------------------------------------------------------------------- @@ -140,14 +175,14 @@ public function testRegimeEspecialTributacaoIsIncludedWhenSet(): void $xpath = new \DOMXPath($doc); $xpath->registerNamespace('n', 'http://www.sped.fazenda.gov.br/nfse'); - $nodes = $xpath->query('//n:regEspTrib'); - self::assertSame(1, $nodes->length, ' expected when regimeEspecialTributacao is set'); + $nodes = $xpath->query('//n:prest/n:regTrib/n:regEspTrib'); + self::assertSame(1, $nodes->length, ' expected when regimeEspecialTributacao is set'); self::assertSame('1', $nodes->item(0)->textContent); } - public function testRegimeEspecialTributacaoIsAbsentWhenNull(): void + public function testBuildDpsAlwaysIncludesProviderTaxRegime(): void { - $dps = $this->makeDps(regimeEspecialTributacao: null); + $dps = $this->makeDps(regimeEspecialTributacao: 0); $xml = $this->builder->buildDps($dps); $doc = new \DOMDocument(); @@ -155,8 +190,56 @@ public function testRegimeEspecialTributacaoIsAbsentWhenNull(): void $xpath = new \DOMXPath($doc); $xpath->registerNamespace('n', 'http://www.sped.fazenda.gov.br/nfse'); - $nodes = $xpath->query('//n:regEspTrib'); - self::assertSame(0, $nodes->length, ' must be absent when null'); + $nodes = $xpath->query('//n:prest/n:regTrib/n:regEspTrib'); + self::assertSame(1, $nodes->length, ' must always be present'); + self::assertSame('0', $nodes->item(0)->textContent); + } + + // ------------------------------------------------------------------------- + + public function testNonSimplesnacionalMustNotIncludeIndtottribAndPaliq(): void + { + // opSimpNac=1 = "não optante" (per SEFIN error messages) + $dpsNaoOptante = $this->makeDps( + cnpjPrestador: '11222333000181', + municipioIbge: '3303302', + itemListaServico: '0107', + valorServico: '1000.00', + aliquota: '5.00', + opcaoSimplesNacional: 1, // não optante + ); + $xml = $this->builder->buildDps($dpsNaoOptante); + + // For "não optante" (opSimpNac = 1), pAliq must NOT be present + // but indTotTrib is now ALWAYS included (even if 0) to avoid schema validation errors + self::assertStringNotContainsString('', $xml); + self::assertStringContainsString('', $xml); + + // totTrib container must exist with content + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + } + + public function testOptiontSimplesnacionalIncludesIndtottribAndPaliq(): void + { + // opSimpNac=2 = optante de Simples Nacional (inverse naming) + $dpsOptante = $this->makeDps( + cnpjPrestador: '11222333000181', + municipioIbge: '3303302', + itemListaServico: '0107', + valorServico: '1000.00', + aliquota: '5.00', + opcaoSimplesNacional: 2, // optante + indicadorTributacao: 1, + ); + $xml = $this->builder->buildDps($dpsOptante); + + // For "optante" (opSimpNac = 2), indTotTrib and pAliq MUST be present + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); } // ------------------------------------------------------------------------- @@ -165,13 +248,19 @@ private function makeDps( string $cnpjPrestador = '11222333000181', string $municipioIbge = '3303302', string $itemListaServico = '0107', + string $codigoTributacaoNacional = '000000', string $valorServico = '1000.00', string $aliquota = '5.00', string $discriminacao = 'Consultoria em TI', + string $serie = '00001', + string $numeroDps = '1', bool $issRetido = false, string $documentoTomador = '', string $nomeTomador = '', - ?int $regimeEspecialTributacao = null, + int $regimeEspecialTributacao = 0, + int $tipoRetencaoIss = 1, + int $opcaoSimplesNacional = 1, + int $indicadorTributacao = 0, ): DpsData { return new DpsData( cnpjPrestador: $cnpjPrestador, @@ -180,10 +269,16 @@ private function makeDps( valorServico: $valorServico, aliquota: $aliquota, discriminacao: $discriminacao, + serie: $serie, + numeroDps: $numeroDps, + codigoTributacaoNacional: $codigoTributacaoNacional, documentoTomador: $documentoTomador, nomeTomador: $nomeTomador, regimeEspecialTributacao: $regimeEspecialTributacao, + tipoRetencaoIss: $tipoRetencaoIss, issRetido: $issRetido, + opcaoSimplesNacional: $opcaoSimplesNacional, + indicadorTributacao: $indicadorTributacao, ); } }