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