Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
766 lines
25 KiB
TypeScript
766 lines
25 KiB
TypeScript
import AdmZip from 'adm-zip';
|
|
import { XMLParser } from 'fast-xml-parser';
|
|
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
|
|
|
|
interface CfdiParsed {
|
|
uuid: string;
|
|
type: TipoCfdi;
|
|
tipoComprobante: string;
|
|
serie: string | null;
|
|
folio: string | null;
|
|
status: EstadoCfdi;
|
|
fechaEmision: Date;
|
|
fechaCertSat: Date | null;
|
|
rfcEmisor: string;
|
|
nombreEmisor: string;
|
|
rfcReceptor: string;
|
|
nombreReceptor: string;
|
|
subtotal: number;
|
|
descuento: number;
|
|
total: number;
|
|
moneda: string;
|
|
tipoCambio: number;
|
|
metodoPago: string | null;
|
|
formaPago: string | null;
|
|
usoCfdi: string | null;
|
|
pac: string | null;
|
|
// Impuestos del comprobante
|
|
ivaTraslado: number;
|
|
isrRetencion: number;
|
|
ivaRetencion: number;
|
|
iepsTraslado: number;
|
|
iepsRetencion: number;
|
|
// Impuestos locales
|
|
impuestosLocalesTrasladado: number;
|
|
impuestosLocalesRetenidos: number;
|
|
// Complemento de pagos
|
|
montoPago: number;
|
|
fechaPagoP: string | null;
|
|
numParcialidad: string | null;
|
|
uuidRelacionado: string | null;
|
|
saldoInsoluto: string | null;
|
|
isrRetencionPago: number;
|
|
ivaTrasladoPago: number;
|
|
ivaRetencionPago: number;
|
|
iepsTrasladoPago: number;
|
|
iepsRetencionPago: number;
|
|
// Nómina
|
|
fechaPago: string | null;
|
|
fechaInicialPago: string | null;
|
|
fechaFinalPago: string | null;
|
|
numDiasPagados: number;
|
|
numSeguroSocial: string | null;
|
|
puesto: string | null;
|
|
salarioBaseCotApor: number;
|
|
salarioDiarioIntegrado: number;
|
|
totalPercepciones: number;
|
|
totalDeducciones: number;
|
|
impRetenidosNomina: number;
|
|
otrasDeduccionesNomina: number;
|
|
subsidioCausado: number;
|
|
|
|
regimenFiscalEmisor: string | null;
|
|
regimenFiscalReceptor: string | null;
|
|
codigoPostalReceptor: string | null;
|
|
// CfdiRelacionados a nivel raíz del comprobante (CFDI 4.0).
|
|
// `cfdiTipoRelacion` — clave SAT (01..07). NULL si no hay relación.
|
|
// `cfdisRelacionados` — UUIDs pipe-separated.
|
|
cfdiTipoRelacion: string | null;
|
|
cfdisRelacionados: string | null;
|
|
conceptos: ConceptoParsed[];
|
|
xmlOriginal: string;
|
|
|
|
// Factura global (InformacionGlobal)
|
|
periodicidad: string | null;
|
|
mesesGlobal: string | null;
|
|
añoGlobal: string | null;
|
|
}
|
|
|
|
interface ConceptoParsed {
|
|
claveProdServ: string | null;
|
|
noIdentificacion: string | null;
|
|
descripcion: string;
|
|
cantidad: number;
|
|
claveUnidad: string | null;
|
|
unidad: string | null;
|
|
valorUnitario: number;
|
|
importe: number;
|
|
descuento: number;
|
|
// Impuestos por concepto
|
|
isrRetencion: number;
|
|
ivaTraslado: number;
|
|
ivaRetencion: number;
|
|
iepsTraslado: number;
|
|
iepsRetencion: number;
|
|
}
|
|
|
|
interface ExtractedXml {
|
|
filename: string;
|
|
content: string;
|
|
}
|
|
|
|
const xmlParser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: '@_',
|
|
removeNSPrefix: true,
|
|
});
|
|
|
|
/**
|
|
* Extrae archivos XML de un paquete ZIP en base64
|
|
*/
|
|
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
|
const zipBuffer = Buffer.from(zipBase64, 'base64');
|
|
const zip = new AdmZip(zipBuffer);
|
|
const entries = zip.getEntries();
|
|
|
|
const xmlFiles: ExtractedXml[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
|
let content = entry.getData().toString('utf-8');
|
|
// Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve
|
|
// result.Comprobante = undefined, dejando el CFDI sin parsear.
|
|
if (content.charCodeAt(0) === 0xFEFF) {
|
|
content = content.slice(1);
|
|
}
|
|
xmlFiles.push({
|
|
filename: entry.entryName,
|
|
content,
|
|
});
|
|
}
|
|
}
|
|
|
|
return xmlFiles;
|
|
}
|
|
|
|
/**
|
|
* Parsea una fecha del XML/CSV del SAT preservando la hora **literal**.
|
|
*
|
|
* Problema: el CFDI 4.0 define `Fecha` y `FechaTimbrado` como ISO-8601 sin
|
|
* zona horaria (hora local del contribuyente = México). Si se pasa tal cual
|
|
* a `new Date(str)`, Node lo interpreta según la timezone de la máquina:
|
|
* en CDMX (UTC-6), "2025-12-31T18:37:51" se convierte a UTC
|
|
* "2026-01-01T00:37:51Z", cambiando la fecha efectiva y desalineando el
|
|
* mes/año del CFDI. Postgres guarda ese valor UTC, y los filtros por rango
|
|
* lo sacan del mes correcto.
|
|
*
|
|
* Solución: forzar 'Z' si el string no trae TZ indicator. Esto hace que
|
|
* Node interprete el texto como UTC literal y preserve la hora tal cual.
|
|
* El valor queda naive pero consistente: todo el sistema filtra con
|
|
* fechas naive (sin TZ), así que el resultado es correcto.
|
|
*/
|
|
function parseCfdiDate(str: string | null | undefined): Date {
|
|
if (!str) return new Date(0);
|
|
let s = String(str).trim();
|
|
if (!s) return new Date(0);
|
|
// Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en
|
|
// FechaTimbrado duplicado). Tomamos solo la primera fecha válida.
|
|
if (s.includes('|')) {
|
|
s = s.split('|')[0].trim();
|
|
}
|
|
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
|
return new Date(hasTz ? s : s + 'Z');
|
|
}
|
|
|
|
function toArray(val: any): any[] {
|
|
if (!val) return [];
|
|
return Array.isArray(val) ? val : [val];
|
|
}
|
|
|
|
function pf(val: any): number {
|
|
return parseFloat(val || '0') || 0;
|
|
}
|
|
|
|
/**
|
|
* Extrae el UUID del TimbreFiscalDigital
|
|
*/
|
|
function getFirstTimbre(comprobante: any): any {
|
|
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
|
if (!timbre) return null;
|
|
return Array.isArray(timbre) ? timbre[0] : timbre;
|
|
}
|
|
|
|
/**
|
|
* Extrae el UUID del TimbreFiscalDigital
|
|
*/
|
|
function extractUuid(comprobante: any): string {
|
|
const timbre = getFirstTimbre(comprobante);
|
|
return timbre?.['@_UUID'] || '';
|
|
}
|
|
|
|
/**
|
|
* Extrae datos del timbre: fecha cert SAT y PAC
|
|
*/
|
|
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
|
|
const timbre = getFirstTimbre(comprobante);
|
|
if (!timbre) return { fechaCertSat: null, pac: null };
|
|
|
|
return {
|
|
fechaCertSat: timbre['@_FechaTimbrado'] ? parseCfdiDate(timbre['@_FechaTimbrado']) : null,
|
|
pac: timbre['@_RfcProvCertif'] || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extrae impuestos trasladados (IVA 002, IEPS 003)
|
|
*/
|
|
function extractTraslados(comprobante: any): { iva: number; ieps: number } {
|
|
const traslados = toArray(comprobante.Impuestos?.Traslados?.Traslado);
|
|
let iva = 0, ieps = 0;
|
|
|
|
for (const t of traslados) {
|
|
const importe = pf(t['@_Importe']);
|
|
if (t['@_Impuesto'] === '002') iva += importe;
|
|
else if (t['@_Impuesto'] === '003') ieps += importe;
|
|
}
|
|
|
|
return { iva, ieps };
|
|
}
|
|
|
|
/**
|
|
* Extrae impuestos retenidos (ISR 001, IVA 002, IEPS 003)
|
|
*/
|
|
function extractRetenciones(comprobante: any): { isr: number; iva: number; ieps: number } {
|
|
const retenciones = toArray(comprobante.Impuestos?.Retenciones?.Retencion);
|
|
let isr = 0, iva = 0, ieps = 0;
|
|
|
|
for (const r of retenciones) {
|
|
const importe = pf(r['@_Importe']);
|
|
if (r['@_Impuesto'] === '001') isr += importe;
|
|
else if (r['@_Impuesto'] === '002') iva += importe;
|
|
else if (r['@_Impuesto'] === '003') ieps += importe;
|
|
}
|
|
|
|
return { isr, iva, ieps };
|
|
}
|
|
|
|
/**
|
|
* Extrae impuestos locales
|
|
*/
|
|
function extractImpuestosLocales(comprobante: any): { trasladado: number; retenido: number } {
|
|
const complemento = comprobante.Complemento;
|
|
if (!complemento) return { trasladado: 0, retenido: 0 };
|
|
|
|
const impLocales = complemento.ImpuestosLocales;
|
|
if (!impLocales) return { trasladado: 0, retenido: 0 };
|
|
|
|
return {
|
|
trasladado: pf(impLocales['@_TotaldeTraslados']),
|
|
retenido: pf(impLocales['@_TotaldeRetenciones']),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extrae CfdiRelacionados a nivel raíz del Comprobante. Puede haber 1+
|
|
* nodos `cfdi:CfdiRelacionados` (cada uno con un `TipoRelacion`), y dentro
|
|
* de cada uno 1+ `cfdi:CfdiRelacionado` con UUID. Retorna el primer
|
|
* TipoRelacion encontrado (lo más común) y todos los UUIDs pipe-separated.
|
|
*/
|
|
function extractCfdiRelacionados(comprobante: any): {
|
|
tipoRelacion: string | null;
|
|
uuids: string | null;
|
|
} {
|
|
const nodes = toArray(comprobante.CfdiRelacionados);
|
|
if (nodes.length === 0) return { tipoRelacion: null, uuids: null };
|
|
|
|
let tipoRelacion: string | null = null;
|
|
const allUuids: string[] = [];
|
|
|
|
for (const node of nodes) {
|
|
if (!tipoRelacion && node['@_TipoRelacion']) {
|
|
tipoRelacion = String(node['@_TipoRelacion']);
|
|
}
|
|
const rels = toArray(node.CfdiRelacionado);
|
|
for (const r of rels) {
|
|
if (r['@_UUID']) allUuids.push(String(r['@_UUID']));
|
|
}
|
|
}
|
|
|
|
return {
|
|
tipoRelacion,
|
|
uuids: allUuids.length > 0 ? allUuids.join('|') : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extrae datos del complemento de pagos (pago20)
|
|
*/
|
|
function extractPagos(comprobante: any): {
|
|
montoPago: number;
|
|
fechaPagoP: string | null;
|
|
numParcialidad: string | null;
|
|
uuidRelacionado: string | null;
|
|
saldoInsoluto: string | null;
|
|
isrRetencion: number;
|
|
ivaTraslado: number;
|
|
ivaRetencion: number;
|
|
iepsTraslado: number;
|
|
iepsRetencion: number;
|
|
} {
|
|
const result = {
|
|
montoPago: 0, fechaPagoP: null as string | null,
|
|
numParcialidad: null as string | null,
|
|
uuidRelacionado: null as string | null,
|
|
saldoInsoluto: null as string | null,
|
|
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
|
|
iepsTraslado: 0, iepsRetencion: 0,
|
|
};
|
|
|
|
const complemento = comprobante.Complemento;
|
|
if (!complemento) return result;
|
|
|
|
// Try pago20:Pagos or just Pagos
|
|
const pagosNode = complemento.Pagos;
|
|
if (!pagosNode) return result;
|
|
|
|
const pagos = toArray(pagosNode.Pago);
|
|
const fechas: string[] = [];
|
|
const parcialidades: string[] = [];
|
|
const uuids: string[] = [];
|
|
const saldos: string[] = [];
|
|
|
|
for (const pago of pagos) {
|
|
result.montoPago += pf(pago['@_Monto']);
|
|
if (pago['@_FechaPago']) fechas.push(pago['@_FechaPago']);
|
|
|
|
// Impuestos del pago
|
|
const retPago = toArray(pago.ImpuestosP?.RetencionesPP?.RetencionP || pago.ImpuestosP?.RetencionesPP);
|
|
for (const r of retPago) {
|
|
const importe = pf(r['@_ImporteP']);
|
|
if (r['@_ImpuestoP'] === '001') result.isrRetencion += importe;
|
|
else if (r['@_ImpuestoP'] === '002') result.ivaRetencion += importe;
|
|
else if (r['@_ImpuestoP'] === '003') result.iepsRetencion += importe;
|
|
}
|
|
|
|
const trasPago = toArray(pago.ImpuestosP?.TrasladosP?.TrasladoP || pago.ImpuestosP?.TrasladosP);
|
|
for (const t of trasPago) {
|
|
const importe = pf(t['@_ImporteP']);
|
|
if (t['@_ImpuestoP'] === '002') result.ivaTraslado += importe;
|
|
else if (t['@_ImpuestoP'] === '003') result.iepsTraslado += importe;
|
|
}
|
|
|
|
// Documentos relacionados
|
|
const doctos = toArray(pago.DoctoRelacionado);
|
|
for (const d of doctos) {
|
|
if (d['@_IdDocumento']) uuids.push(d['@_IdDocumento']);
|
|
if (d['@_NumParcialidad']) parcialidades.push(d['@_NumParcialidad']);
|
|
if (d['@_ImpSaldoInsoluto'] !== undefined) saldos.push(d['@_ImpSaldoInsoluto']);
|
|
}
|
|
}
|
|
|
|
result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : null;
|
|
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
|
|
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
|
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extrae datos del complemento de nómina (nomina12)
|
|
*/
|
|
function extractNomina(comprobante: any): {
|
|
fechaPago: string | null;
|
|
fechaInicialPago: string | null;
|
|
fechaFinalPago: string | null;
|
|
numDiasPagados: number;
|
|
numSeguroSocial: string | null;
|
|
puesto: string | null;
|
|
salarioBaseCotApor: number;
|
|
salarioDiarioIntegrado: number;
|
|
totalPercepciones: number;
|
|
totalDeducciones: number;
|
|
impRetenidosNomina: number;
|
|
otrasDeduccionesNomina: number;
|
|
subsidioCausado: number;
|
|
} {
|
|
const result = {
|
|
fechaPago: null as string | null,
|
|
fechaInicialPago: null as string | null,
|
|
fechaFinalPago: null as string | null,
|
|
numDiasPagados: 0,
|
|
numSeguroSocial: null as string | null,
|
|
puesto: null as string | null,
|
|
salarioBaseCotApor: 0,
|
|
salarioDiarioIntegrado: 0,
|
|
totalPercepciones: 0,
|
|
totalDeducciones: 0,
|
|
impRetenidosNomina: 0,
|
|
otrasDeduccionesNomina: 0,
|
|
subsidioCausado: 0,
|
|
};
|
|
|
|
const complemento = comprobante.Complemento;
|
|
if (!complemento) return result;
|
|
|
|
const nomina = complemento.Nomina;
|
|
if (!nomina) return result;
|
|
|
|
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
|
|
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
|
|
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
|
|
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
|
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
|
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
|
|
|
// Receptor de nómina
|
|
const receptor = nomina.Receptor;
|
|
if (receptor) {
|
|
result.numSeguroSocial = receptor['@_NumSeguridadSocial'] || null;
|
|
result.puesto = receptor['@_Puesto'] || null;
|
|
result.salarioBaseCotApor = pf(receptor['@_SalarioBaseCotApor']);
|
|
result.salarioDiarioIntegrado = pf(receptor['@_SalarioDiarioIntegrado']);
|
|
}
|
|
|
|
// Deducciones
|
|
const deducciones = nomina.Deducciones;
|
|
if (deducciones) {
|
|
result.impRetenidosNomina = pf(deducciones['@_TotalImpuestosRetenidos']);
|
|
result.otrasDeduccionesNomina = pf(deducciones['@_TotalOtrasDeducciones']);
|
|
}
|
|
|
|
// Subsidio causado (OtrosPagos/OtroPago[@TipoOtroPago='002'])
|
|
const otrosPagos = toArray(nomina.OtrosPagos?.OtroPago);
|
|
for (const op of otrosPagos) {
|
|
if (op['@_TipoOtroPago'] === '002') {
|
|
result.subsidioCausado = pf(op.SubsidioAlEmpleo?.['@_SubsidioCausado']);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extrae los conceptos del comprobante
|
|
*/
|
|
function extractConceptos(comprobante: any): ConceptoParsed[] {
|
|
const conceptosNode = comprobante.Conceptos?.Concepto;
|
|
if (!conceptosNode) return [];
|
|
|
|
const conceptos = toArray(conceptosNode);
|
|
return conceptos.map((c: any) => {
|
|
// Impuestos por concepto
|
|
const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado);
|
|
const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion);
|
|
|
|
let ivaTraslado = 0, iepsTraslado = 0;
|
|
for (const t of trasladosC) {
|
|
const importe = pf(t['@_Importe']);
|
|
if (t['@_Impuesto'] === '002') ivaTraslado += importe;
|
|
else if (t['@_Impuesto'] === '003') iepsTraslado += importe;
|
|
}
|
|
|
|
let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0;
|
|
for (const r of retencionesC) {
|
|
const importe = pf(r['@_Importe']);
|
|
if (r['@_Impuesto'] === '001') isrRetencion += importe;
|
|
else if (r['@_Impuesto'] === '002') ivaRetencion += importe;
|
|
else if (r['@_Impuesto'] === '003') iepsRetencion += importe;
|
|
}
|
|
|
|
return {
|
|
claveProdServ: c['@_ClaveProdServ'] || null,
|
|
noIdentificacion: c['@_NoIdentificacion'] || null,
|
|
descripcion: c['@_Descripcion'] || '',
|
|
cantidad: pf(c['@_Cantidad']) || 1,
|
|
claveUnidad: c['@_ClaveUnidad'] || null,
|
|
unidad: c['@_Unidad'] || null,
|
|
valorUnitario: pf(c['@_ValorUnitario']),
|
|
importe: pf(c['@_Importe']),
|
|
descuento: pf(c['@_Descuento']),
|
|
isrRetencion,
|
|
ivaTraslado,
|
|
ivaRetencion,
|
|
iepsTraslado,
|
|
iepsRetencion,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parsea un XML de CFDI y extrae los datos relevantes
|
|
* @param downloadType - 'emitidos' o 'recibidos' para determinar el type (EMITIDO/RECIBIDO)
|
|
*/
|
|
export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed | null {
|
|
try {
|
|
const result = xmlParser.parse(xmlContent);
|
|
const comprobante = result.Comprobante;
|
|
|
|
if (!comprobante) {
|
|
console.error('[Parser] No se encontró el nodo Comprobante');
|
|
return null;
|
|
}
|
|
|
|
const emisor = comprobante.Emisor || {};
|
|
const receptor = comprobante.Receptor || {};
|
|
const retenciones = extractRetenciones(comprobante);
|
|
const traslados = extractTraslados(comprobante);
|
|
const timbreData = extractTimbreData(comprobante);
|
|
const impLocales = extractImpuestosLocales(comprobante);
|
|
const tipoComprobante = comprobante['@_TipoDeComprobante'] || 'I';
|
|
|
|
// Complemento de pagos (solo tipo P)
|
|
const pagosData = tipoComprobante === 'P' ? extractPagos(comprobante) : {
|
|
montoPago: 0, fechaPagoP: null, numParcialidad: null,
|
|
uuidRelacionado: null, saldoInsoluto: null,
|
|
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
|
|
iepsTraslado: 0, iepsRetencion: 0,
|
|
};
|
|
|
|
// CfdiRelacionados a nivel raíz. CFDI 4.0 permite 1+ nodos
|
|
// `cfdi:CfdiRelacionados` cada uno con un TipoRelacion y múltiples UUIDs.
|
|
// Aquí capturamos el PRIMER TipoRelacion (lo más común es que haya uno
|
|
// solo, especialmente en NC tipo E). Los UUIDs de todos los bloques se
|
|
// concatenan con `|`.
|
|
const relacionesData = extractCfdiRelacionados(comprobante);
|
|
|
|
// Complemento de nómina (solo tipo N)
|
|
const nominaData = tipoComprobante === 'N' ? extractNomina(comprobante) : {
|
|
fechaPago: null, fechaInicialPago: null, fechaFinalPago: null,
|
|
numDiasPagados: 0, numSeguroSocial: null, puesto: null,
|
|
salarioBaseCotApor: 0, salarioDiarioIntegrado: 0,
|
|
totalPercepciones: 0, totalDeducciones: 0,
|
|
impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0,
|
|
};
|
|
|
|
const cfdi: CfdiParsed = {
|
|
uuid: extractUuid(comprobante),
|
|
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
|
|
tipoComprobante,
|
|
serie: comprobante['@_Serie'] || null,
|
|
folio: comprobante['@_Folio'] || null,
|
|
status: 'Vigente',
|
|
fechaEmision: parseCfdiDate(comprobante['@_Fecha']),
|
|
fechaCertSat: timbreData.fechaCertSat,
|
|
rfcEmisor: emisor['@_Rfc'] || '',
|
|
nombreEmisor: emisor['@_Nombre'] || '',
|
|
rfcReceptor: receptor['@_Rfc'] || '',
|
|
nombreReceptor: receptor['@_Nombre'] || '',
|
|
subtotal: pf(comprobante['@_SubTotal']),
|
|
descuento: pf(comprobante['@_Descuento']),
|
|
total: pf(comprobante['@_Total']),
|
|
moneda: comprobante['@_Moneda'] || 'MXN',
|
|
tipoCambio: pf(comprobante['@_TipoCambio']) || 1,
|
|
metodoPago: comprobante['@_MetodoPago'] || null,
|
|
formaPago: comprobante['@_FormaPago'] || null,
|
|
usoCfdi: receptor['@_UsoCFDI'] || null,
|
|
pac: timbreData.pac,
|
|
regimenFiscalEmisor: emisor['@_RegimenFiscal'] || null,
|
|
regimenFiscalReceptor: receptor['@_RegimenFiscalReceptor'] || receptor['@_RegimenFiscal'] || null,
|
|
codigoPostalReceptor: receptor['@_DomicilioFiscalReceptor'] || null,
|
|
cfdiTipoRelacion: relacionesData.tipoRelacion,
|
|
cfdisRelacionados: relacionesData.uuids,
|
|
// Impuestos comprobante
|
|
ivaTraslado: traslados.iva,
|
|
isrRetencion: retenciones.isr,
|
|
ivaRetencion: retenciones.iva,
|
|
iepsTraslado: traslados.ieps,
|
|
iepsRetencion: retenciones.ieps,
|
|
// Impuestos locales
|
|
impuestosLocalesTrasladado: impLocales.trasladado,
|
|
impuestosLocalesRetenidos: impLocales.retenido,
|
|
// Complemento de pagos
|
|
montoPago: pagosData.montoPago,
|
|
fechaPagoP: pagosData.fechaPagoP,
|
|
numParcialidad: pagosData.numParcialidad,
|
|
uuidRelacionado: pagosData.uuidRelacionado,
|
|
saldoInsoluto: pagosData.saldoInsoluto,
|
|
isrRetencionPago: pagosData.isrRetencion,
|
|
ivaTrasladoPago: pagosData.ivaTraslado,
|
|
ivaRetencionPago: pagosData.ivaRetencion,
|
|
iepsTrasladoPago: pagosData.iepsTraslado,
|
|
iepsRetencionPago: pagosData.iepsRetencion,
|
|
// Nómina
|
|
...nominaData,
|
|
conceptos: extractConceptos(comprobante),
|
|
xmlOriginal: xmlContent,
|
|
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
|
|
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
|
|
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
|
|
};
|
|
|
|
if (!cfdi.uuid) {
|
|
console.error('[Parser] CFDI sin UUID');
|
|
return null;
|
|
}
|
|
|
|
return cfdi;
|
|
} catch (error) {
|
|
console.error('[Parser Error]', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
|
|
* @param downloadType - 'emitidos' o 'recibidos'
|
|
*/
|
|
export function processPackage(zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed[] {
|
|
const xmlFiles = extractXmlsFromZip(zipBase64);
|
|
const cfdis: CfdiParsed[] = [];
|
|
|
|
for (const { content } of xmlFiles) {
|
|
const cfdi = parseXml(content, downloadType);
|
|
if (cfdi) {
|
|
cfdis.push(cfdi);
|
|
}
|
|
}
|
|
|
|
return cfdis;
|
|
}
|
|
|
|
/**
|
|
* Datos parseados de un registro de metadata del SAT
|
|
*/
|
|
interface CfdiMetadata {
|
|
uuid: string;
|
|
rfcEmisor: string;
|
|
nombreEmisor: string;
|
|
rfcReceptor: string;
|
|
nombreReceptor: string;
|
|
rfcPac: string | null;
|
|
fechaEmision: Date;
|
|
fechaCertSat: Date | null;
|
|
fechaCancelacion: Date | null;
|
|
monto: number;
|
|
tipoComprobante: string;
|
|
status: string; // 'Vigente' | 'Cancelado'
|
|
type: 'EMITIDO' | 'RECIBIDO';
|
|
}
|
|
|
|
/**
|
|
* Extrae archivos CSV de un paquete ZIP de metadata en base64.
|
|
* Usa AdmZip directamente para evitar problemas de archivos temporales en Windows.
|
|
*/
|
|
function extractCsvsFromZip(zipBase64: string): string[] {
|
|
const zipBuffer = Buffer.from(zipBase64, 'base64');
|
|
const zip = new AdmZip(zipBuffer);
|
|
const entries = zip.getEntries();
|
|
const csvContents: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const name = entry.entryName.toLowerCase();
|
|
if (name.endsWith('.csv') || name.endsWith('.txt')) {
|
|
csvContents.push(entry.getData().toString('utf-8'));
|
|
}
|
|
}
|
|
|
|
return csvContents;
|
|
}
|
|
|
|
/**
|
|
* Parsea una línea CSV respetando campos entrecomillados
|
|
*/
|
|
function parseCsvLine(line: string): string[] {
|
|
const fields: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (ch === '"') {
|
|
inQuotes = !inQuotes;
|
|
} else if (ch === '~' && !inQuotes) {
|
|
fields.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
fields.push(current.trim());
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Procesa un paquete de metadata del SAT (ZIP con CSV) y retorna los registros.
|
|
* Usa AdmZip directo en vez de MetadataPackageReader para compatibilidad Windows.
|
|
*/
|
|
export function processMetadataPackage(
|
|
zipBase64: string,
|
|
downloadType: 'emitidos' | 'recibidos' = 'emitidos'
|
|
): CfdiMetadata[] {
|
|
const csvContents = extractCsvsFromZip(zipBase64);
|
|
const results: CfdiMetadata[] = [];
|
|
|
|
const tipoMap: Record<string, string> = {
|
|
'Ingreso': 'I', 'Egreso': 'E', 'Traslado': 'T', 'Nómina': 'N', 'Nomina': 'N', 'Pago': 'P',
|
|
};
|
|
|
|
for (const csv of csvContents) {
|
|
const lines = csv.split(/\r?\n/).filter(l => l.trim());
|
|
if (lines.length < 2) continue;
|
|
|
|
// Header line — SAT uses ~ as delimiter
|
|
const headers = parseCsvLine(lines[0]);
|
|
|
|
// Find column indices (case-insensitive)
|
|
const idx = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase());
|
|
const iUuid = idx('Uuid');
|
|
const iRfcEmisor = idx('RfcEmisor');
|
|
const iNombreEmisor = idx('NombreEmisor');
|
|
const iRfcReceptor = idx('RfcReceptor');
|
|
const iNombreReceptor = idx('NombreReceptor');
|
|
const iRfcPac = idx('RfcPac');
|
|
const iFechaEmision = idx('FechaEmision');
|
|
const iFechaCert = idx('FechaCertificacionSat');
|
|
const iFechaCancel = idx('FechaCancelacion');
|
|
const iMonto = idx('Monto');
|
|
const iEfecto = idx('EfectoComprobante');
|
|
const iEstatus = idx('Estatus');
|
|
// Fallback column names
|
|
const iEstado = iEstatus >= 0 ? iEstatus : idx('Estado');
|
|
|
|
if (iUuid < 0) continue; // No UUID column = invalid CSV
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const fields = parseCsvLine(lines[i]);
|
|
const uuid = (fields[iUuid] || '').trim();
|
|
if (!uuid) continue;
|
|
|
|
const estatus = (fields[iEstado] || 'Vigente').trim();
|
|
const fechaCancelStr = iFechaCancel >= 0 ? (fields[iFechaCancel] || '').trim() : '';
|
|
const fechaEmisionStr = iFechaEmision >= 0 ? (fields[iFechaEmision] || '').trim() : '';
|
|
const fechaCertStr = iFechaCert >= 0 ? (fields[iFechaCert] || '').trim() : '';
|
|
const efecto = iEfecto >= 0 ? (fields[iEfecto] || 'Ingreso').trim() : 'Ingreso';
|
|
|
|
results.push({
|
|
uuid: uuid.toUpperCase(),
|
|
rfcEmisor: iRfcEmisor >= 0 ? (fields[iRfcEmisor] || '').trim() : '',
|
|
nombreEmisor: iNombreEmisor >= 0 ? (fields[iNombreEmisor] || '').trim() : '',
|
|
rfcReceptor: iRfcReceptor >= 0 ? (fields[iRfcReceptor] || '').trim() : '',
|
|
nombreReceptor: iNombreReceptor >= 0 ? (fields[iNombreReceptor] || '').trim() : '',
|
|
rfcPac: iRfcPac >= 0 ? (fields[iRfcPac] || '').trim() || null : null,
|
|
fechaEmision: fechaEmisionStr ? parseCfdiDate(fechaEmisionStr) : new Date(),
|
|
fechaCertSat: fechaCertStr ? parseCfdiDate(fechaCertStr) : null,
|
|
fechaCancelacion: fechaCancelStr ? parseCfdiDate(fechaCancelStr) : null,
|
|
monto: parseFloat(iMonto >= 0 ? fields[iMonto] || '0' : '0') || 0,
|
|
tipoComprobante: tipoMap[efecto] || efecto.charAt(0) || 'I',
|
|
status: estatus === '0' || estatus.toLowerCase().includes('cancel') ? 'Cancelado' : 'Vigente',
|
|
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Valida que un XML sea un CFDI válido
|
|
*/
|
|
export function isValidCfdi(xmlContent: string): boolean {
|
|
try {
|
|
const result = xmlParser.parse(xmlContent);
|
|
const comprobante = result.Comprobante;
|
|
|
|
if (!comprobante) return false;
|
|
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
|
|
if (!extractUuid(comprobante)) return false;
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export type { CfdiParsed, CfdiMetadata, ConceptoParsed, ExtractedXml };
|