/* * This file is part of Treeunfe DFe. * * Treeunfe DFe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Treeunfe DFe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Treeunfe DFe. If not, see . */ /** * Serviço de autorização NFS-e no padrão próprio Nota Fiscal Paulistana (NFP) * para a Prefeitura de São Paulo - VERSÃO 1 (Layout antigo, fato gerador até 31/12/2025). * * Utiliza o WebService SOAP síncrono (LoteNFe): * https://nfews.prefeitura.sp.gov.br/lotenfe.asmx * * Operação: EnvioLoteRPS * Namespace: http://www.prefeitura.sp.gov.br/nfe * Schema: PedidoEnvioLoteRPS_v01.xsd */ import { BaseNFSeNFP, Environment, logger, SaveFiles, Utility, XmlBuilder } from '@treeunfe/shared'; import { NFSeNFPAutorizacaoServiceImpl } from '@treeunfe/types/interfaces'; import { NFSeNFP_AutorizacaoResponse, NFSeNFP_EnviarLoteRps, NFSeNFP_Rps, } from '@treeunfe/types'; import { AxiosInstance } from 'axios'; import { GerarConsultaImpl } from '@treeunfe/types/interfaces'; import xml2js from 'xml2js'; import crypto from 'crypto'; class NFSeNFP_SPAutorizacaoService_V1 extends BaseNFSeNFP implements NFSeNFPAutorizacaoServiceImpl { constructor( environment: Environment, utility: Utility, xmlBuilder: XmlBuilder, axios: AxiosInstance, saveFiles: SaveFiles, gerarConsulta: GerarConsultaImpl, ) { super( environment, utility, xmlBuilder, 'NFSe_NFP_EnvioLoteRPS_V1', axios, saveFiles, gerarConsulta, ); this.municipio = 'SP'; } protected getSoapAction(): string { return 'envioLoteRPS'; } protected getSoapVersion(): 1.1 | 1.2 { return 1.1; } protected getRequestWrapper(): string | undefined { return 'EnvioLoteRPSRequest'; } protected getVersaoSchema(): number { return 1; } /** * Gera a assinatura digital do RPS conforme item 4.3.2 do manual NFP SP (versão 1). * Monta cadeia de 86+ caracteres ASCII, gera hash SHA1 e assina com RSA-SHA1. */ private gerarAssinaturaRPS( inscricaoPrestador: string, serieRPS: string, numeroRPS: string, dataEmissao: string, tributacaoRPS: string, statusRPS: string, issRetido: boolean, valorServicos: number, valorDeducoes: number, codigoServico: string, tomadorCpfCnpj?: { tipo: 'CPF' | 'CNPJ'; valor: string }, intermediarioCpfCnpj?: { tipo: 'CPF' | 'CNPJ'; valor: string }, issRetidoIntermediario?: boolean, ): string { // #1 Inscrição Municipal do Prestador - 8 posições, zeros à esquerda const campo1 = inscricaoPrestador.padStart(8, '0'); // #2 Série do RPS - 5 posições, espaços à direita const campo2 = serieRPS.padEnd(5, ' '); // #3 Número do RPS - 12 posições, zeros à esquerda const campo3 = numeroRPS.padStart(12, '0'); // #4 Data de Emissão - formato AAAAMMDD const campo4 = dataEmissao.replace(/-/g, ''); // #5 Tipo de Tributação - 1 posição const campo5 = tributacaoRPS; // #6 Status do RPS - 1 posição const campo6 = statusRPS; // #7 ISS Retido - S ou N const campo7 = issRetido ? 'S' : 'N'; // #8 Valor dos Serviços - 15 posições em centavos, sem decimal const campo8 = Math.round(valorServicos * 100).toString().padStart(15, '0'); // #9 Valor das Deduções - 15 posições em centavos, sem decimal const campo9 = Math.round(valorDeducoes * 100).toString().padStart(15, '0'); // #10 Código do Serviço - 5 posições, zeros à esquerda const campo10 = codigoServico.padStart(5, '0'); // #11 Indicador CPF/CNPJ do Tomador - 1 posição (1=CPF, 2=CNPJ, 3=Não informado) let campo11: string; let campo12: string; if (tomadorCpfCnpj) { campo11 = tomadorCpfCnpj.tipo === 'CPF' ? '1' : '2'; campo12 = tomadorCpfCnpj.valor.padStart(14, '0'); } else { campo11 = '3'; campo12 = '00000000000000'; } let cadeia = campo1 + campo2 + campo3 + campo4 + campo5 + campo6 + campo7 + campo8 + campo9 + campo10 + campo11 + campo12; if (intermediarioCpfCnpj) { const campo13 = intermediarioCpfCnpj.tipo === 'CPF' ? '1' : '2'; const campo14 = intermediarioCpfCnpj.valor.padStart(14, '0'); const campo15 = issRetidoIntermediario ? 'S' : 'N'; cadeia += campo13 + campo14 + campo15; } const privateKey = this.environment.getCertKey(); const sign = crypto.createSign('SHA1'); sign.update(cadeia, 'ascii'); sign.end(); return sign.sign(privateKey, 'base64'); } /** * Formata valor conforme tpValor do schema NFP SP. * Pattern: 0|0\.[0-9]{2}|[1-9]{1}[0-9]{0,12}(\.[0-9]{0,2})? */ private formatarValor(valor: number): string { if (valor === 0) return '0'; const fixed = valor.toFixed(2); return fixed.replace(/\.?0+$/, '') || '0'; } /** * Gera o corpo SOAP para EnvioLoteRPS (formato V1 - Layout antigo). * Estrutura conforme PedidoEnvioLoteRPS_v01.xsd e TiposNFe_v01.xsd */ protected gerarXmlCorpo(data: NFSeNFP_EnviarLoteRps): string { const lote = data.LoteRps; const rpsArray = Array.isArray(lote.ListaRps.Rps) ? lote.ListaRps.Rps : [lote.ListaRps.Rps]; const datasEmissao = rpsArray.map(rps => new Date(rps.InfRps.DataEmissao)); const dtInicio = new Date(Math.min(...datasEmissao.map(d => d.getTime()))).toISOString().split('T')[0]; const dtFim = new Date(Math.max(...datasEmissao.map(d => d.getTime()))).toISOString().split('T')[0]; let valorTotalServicos = 0; let valorTotalDeducoes = 0; const rpsXmlList = rpsArray.map((rps: NFSeNFP_Rps) => { const inf = rps.InfRps; const valores = inf.Servico.Valores; valorTotalServicos += valores.ValorServicos || 0; valorTotalDeducoes += valores.ValorDeducoes || 0; const tributacaoRPS = inf.NaturezaOperacao === 1 ? 'T' : 'F'; const tipoRPS = inf.IdentificacaoRps.Tipo === 1 ? 'RPS' : inf.IdentificacaoRps.Tipo === 2 ? 'RPS-M' : 'RPS-C'; const dataEmissaoFormatada = inf.DataEmissao.split('T')[0]; const issRetido = valores.IssRetido === 1; // Para a NFP-SP, o campo exige o código municipal de // serviço da Prefeitura de São Paulo (5 dígitos), e NÃO o item da LC // 116/03. Quando informado, usa-se CodigoTributacaoMunicipio; caso // contrário, usa-se o ItemListaServico (sem ponto) por compatibilidade. const codigoServico = (inf.Servico.CodigoTributacaoMunicipio ? String(inf.Servico.CodigoTributacaoMunicipio) : (inf.Servico.ItemListaServico || '').replace('.', '') ).replace(/\D/g, '').padStart(5, '0'); let tomadorCpfCnpj: { tipo: 'CPF' | 'CNPJ'; valor: string } | undefined; let cpfCnpjTomadorXml = ''; if (inf.Tomador?.IdentificacaoTomador?.CpfCnpj?.Cnpj) { tomadorCpfCnpj = { tipo: 'CNPJ', valor: inf.Tomador.IdentificacaoTomador.CpfCnpj.Cnpj }; cpfCnpjTomadorXml = `${inf.Tomador.IdentificacaoTomador.CpfCnpj.Cnpj}`; } else if (inf.Tomador?.IdentificacaoTomador?.CpfCnpj?.Cpf) { tomadorCpfCnpj = { tipo: 'CPF', valor: inf.Tomador.IdentificacaoTomador.CpfCnpj.Cpf }; cpfCnpjTomadorXml = `${inf.Tomador.IdentificacaoTomador.CpfCnpj.Cpf}`; } const assinaturaRPS = this.gerarAssinaturaRPS( lote.InscricaoMunicipal || '', inf.IdentificacaoRps.Serie, String(inf.IdentificacaoRps.Numero), dataEmissaoFormatada, tributacaoRPS, 'N', issRetido, valores.ValorServicos || 0, valores.ValorDeducoes || 0, codigoServico, tomadorCpfCnpj, ); const aliquota = valores.Aliquota || 0; return '' + `${assinaturaRPS}` + '' + `${lote.InscricaoMunicipal || ''}` + `${inf.IdentificacaoRps.Serie}` + `${inf.IdentificacaoRps.Numero}` + '' + `${tipoRPS}` + `${dataEmissaoFormatada}` + 'N' + `${tributacaoRPS}` + `${this.formatarValor(valores.ValorServicos)}` + `${this.formatarValor(valores.ValorDeducoes || 0)}` + (valores.ValorPis ? `${this.formatarValor(valores.ValorPis)}` : '') + (valores.ValorCofins ? `${this.formatarValor(valores.ValorCofins)}` : '') + (valores.ValorInss ? `${this.formatarValor(valores.ValorInss)}` : '') + (valores.ValorIr ? `${this.formatarValor(valores.ValorIr)}` : '') + (valores.ValorCsll ? `${this.formatarValor(valores.ValorCsll)}` : '') + `${codigoServico}` + `${aliquota}` + `${issRetido ? 'true' : 'false'}` + cpfCnpjTomadorXml + (inf.Tomador?.IdentificacaoTomador?.InscricaoMunicipal ? `${inf.Tomador.IdentificacaoTomador.InscricaoMunicipal}` : '') + (inf.Tomador?.RazaoSocial ? `${inf.Tomador.RazaoSocial}` : '') + `${inf.Servico.Discriminacao}` + ''; }).join(''); const xmlPedido = `` + `` + `${lote.Cnpj}` + `true` + `${dtInicio}` + `${dtFim}` + `${lote.QuantidadeRps}` + `${this.formatarValor(valorTotalServicos)}` + `${this.formatarValor(valorTotalDeducoes)}` + `` + rpsXmlList + ``; const xmlAssinado = this.xmlBuilder.assinarXMLNFPSP(xmlPedido, 'PedidoEnvioLoteRPS'); return xmlAssinado; } /** * Parseia a resposta XML SOAP e extrai a NFS-e gerada ou erros. */ private async parsearResposta(xmlResposta: string): Promise { return new Promise((resolve, reject) => { xml2js.parseString(xmlResposta, { explicitArray: false, ignoreAttrs: false }, (err, result) => { if (err) { reject(new Error(`Erro ao parsear resposta SOAP: ${err.message}`)); return; } try { const body = result?.['soap:Envelope']?.['soap:Body'] || result?.['s:Envelope']?.['s:Body'] || result?.Envelope?.Body; if (!body) { reject(new Error('Resposta SOAP inválida: Body não encontrado')); return; } // Verifica fault SOAP const fault = body['soap:Fault'] || body['s:Fault'] || body?.Fault; if (fault) { const faultMsg = fault.faultstring || fault.Reason?.Text || 'Erro SOAP desconhecido'; reject(new Error(`SOAP Fault: ${faultMsg}`)); return; } // Extrai o RetornoXML do wrapper Response const responseKey = Object.keys(body).find(k => k.toLowerCase().includes('envioloterpsresponse') ); const responseWrapper = responseKey ? body[responseKey] : body; const retornoXml = responseWrapper?.RetornoXML; if (!retornoXml) { reject(new Error('RetornoXML não encontrado na resposta')); return; } // Parseia o RetornoXML interno (que é um XML escapado dentro do SOAP) xml2js.parseString(retornoXml, { explicitArray: false, ignoreAttrs: false }, (err2, retornoResult) => { if (err2) { reject(new Error(`Erro ao parsear RetornoXML: ${err2.message}`)); return; } try { const retornoEnvio = retornoResult?.RetornoEnvioLoteRPS; if (!retornoEnvio) { reject(new Error('RetornoEnvioLoteRPS não encontrado')); return; } const cabecalho = retornoEnvio.Cabecalho; const sucesso = cabecalho?.Sucesso === 'true' || cabecalho?.Sucesso === true; // Verifica erros const erros = retornoEnvio.Erro; if (erros || !sucesso) { resolve({ success: false, erros: erros, xmlRetorno: xmlResposta, }); return; } // NFS-e gerada const listaNfse = retornoEnvio.ListaNfse; const nfseGerada = listaNfse?.CompNfse?.Nfse || listaNfse?.CompNfse; resolve({ success: true, nfseGerada, xmlRetorno: xmlResposta, }); } catch (parseError: any) { reject(new Error(`Erro ao processar RetornoXML: ${parseError.message}`)); } }); } catch (parseError: any) { reject(new Error(`Erro ao processar resposta NFP SP: ${parseError.message}`)); } }); }); } public async Exec(data: NFSeNFP_EnviarLoteRps): Promise { try { const xmlResposta = await super.Exec(data); logger.info('Resposta recebida do WebService Nota Fiscal Paulistana (SP) V1', { context: 'NFSeNFP_SPAutorizacaoService_V1', responseSize: String(xmlResposta).length, }); const resultado = await this.parsearResposta(xmlResposta); if (resultado.success) { logger.info('NFS-e autorizada com sucesso (V1)', { context: 'NFSeNFP_SPAutorizacaoService_V1', }); } else { logger.warn('Erros retornados pelo WebService Nota Fiscal Paulistana (SP) V1', { context: 'NFSeNFP_SPAutorizacaoService_V1', erros: resultado.erros, }); } return resultado; } catch (error: any) { logger.error('Erro ao executar autorização NFS-e NFP SP V1', { context: 'NFSeNFP_SPAutorizacaoService_V1', error: error.message, }); throw error; } } } export default NFSeNFP_SPAutorizacaoService_V1;