/*
* 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;