/* * 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. * * Utiliza o WebService SOAP síncrono (LoteNFe): * https://nfews.prefeitura.sp.gov.br/lotenfe.asmx * * Operação: EnvioLoteRPS * Schema: PedidoEnvioLoteRPS_v01.xsd / TiposNFe_v01.xsd * Namespace: http://www.prefeitura.sp.gov.br/nfe * Referência: Manual de Utilização do Web Service de NFS-e SP (v3.3+) */ 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 extends BaseNFSeNFP implements NFSeNFPAutorizacaoServiceImpl { constructor( environment: Environment, utility: Utility, xmlBuilder: XmlBuilder, axios: AxiosInstance, saveFiles: SaveFiles, gerarConsulta: GerarConsultaImpl, ) { super( environment, utility, xmlBuilder, 'NFSe_NFP_EnviarLoteRpsSincrono', axios, saveFiles, gerarConsulta, ); this.municipio = 'SP'; } protected getSoapAction(): string { return 'envioLoteRPS'; } 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. * Monta cadeia de 86+ caracteres ASCII, assina com RSA-SHA1 e retorna base64. */ 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 }, ): string { const campo1 = inscricaoPrestador.padStart(8, '0'); const campo2 = serieRPS.padEnd(5, ' '); const campo3 = numeroRPS.padStart(12, '0'); const campo4 = dataEmissao.replace(/-/g, ''); const campo5 = tributacaoRPS; const campo6 = statusRPS; const campo7 = issRetido ? 'S' : 'N'; const campo8 = Math.round(valorServicos * 100).toString().padStart(15, '0'); const campo9 = Math.round(valorDeducoes * 100).toString().padStart(15, '0'); const campo10 = codigoServico.padStart(5, '0'); 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'; } const cadeia = campo1 + campo2 + campo3 + campo4 + campo5 + campo6 + campo7 + campo8 + campo9 + campo10 + campo11 + campo12; 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 NFP SP). * Estrutura conforme PedidoEnvioLoteRPS_v01.xsd e TiposNFe_v01.xsd. */ protected gerarXmlCorpo(data: NFSeNFP_EnviarLoteRps): string { const lote = data.LoteRps; if (!lote.InscricaoMunicipal || lote.InscricaoMunicipal.trim() === '') { throw new Error( 'NFSe NFP SP: InscricaoMunicipal do prestador é obrigatória em LoteRps.InscricaoMunicipal ' + '(tipo tpInscricaoMunicipal, ex: "00000000").', ); } 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 + ``; return this.xmlBuilder.assinarXMLNFPSP(xmlPedido, 'PedidoEnvioLoteRPS'); } /** * 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 (novo formato) const responseKey = Object.keys(body).find(k => k.toLowerCase().includes('envioloterpsresponse') || k.toLowerCase().includes('enviarloterpssincronoresponse') ); 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; // Alertas (avisos não impedem emissão) const alertasRaw = retornoEnvio.Alerta; const alertas = alertasRaw ? (Array.isArray(alertasRaw) ? alertasRaw : [alertasRaw]) : undefined; // Verifica erros const erros = retornoEnvio.Erro; if (erros || !sucesso) { resolve({ success: false, erros: erros, alertas, xmlRetorno: xmlResposta, }); return; } // Formato síncrono NFP-SP: retorna ChaveNFeRPS com ChaveNFe const chaveNFeRPS = retornoEnvio.ChaveNFeRPS; if (chaveNFeRPS) { const chaveNFe = chaveNFeRPS.ChaveNFe; resolve({ success: true, chaveNFe: { NumeroNFe: chaveNFe?.NumeroNFe ?? '', CodigoVerificacao: chaveNFe?.CodigoVerificacao ?? '', InscricaoPrestador: chaveNFe?.InscricaoPrestador ?? '', }, alertas, xmlRetorno: xmlResposta, }); return; } // Formato alternativo: ListaNfse (padrão ABRASF) 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)', { context: 'NFSeNFP_SPAutorizacaoService', responseSize: String(xmlResposta).length, }); const resultado = await this.parsearResposta(String(xmlResposta)); if (resultado.success) { const numero = resultado.chaveNFe?.NumeroNFe || resultado.nfseGerada?.InfNfse?.Numero || 'N/A'; logger.info('NFS-e Nota Fiscal Paulistana (SP) autorizada com sucesso', { context: 'NFSeNFP_SPAutorizacaoService', numero, }); } return resultado; } catch (error: any) { logger.error('Erro na autorização NFS-e Nota Fiscal Paulistana (SP)', error, { context: 'NFSeNFP_SPAutorizacaoService', }); throw error; } } } export default NFSeNFP_SPAutorizacaoService;